Skip to main content

readcon_db/
topology.rs

1//! Bonded-topology identity via `seams fingerprint --format json`.
2//!
3//! Exact frame identity remains xxHash3 on CON bytes. The topology key is
4//! coarser: the bonded graph up to relabelling (d-SEAMS frame HEX plus the
5//! recorded cutoff / graph / hops / method).
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use serde::Deserialize;
12
13use crate::error::{Error, Result};
14
15/// Clear error when the engine binary cannot be resolved.
16pub const SEAMS_MISSING: &str = "seams fingerprint: binary not found; set --seams, the SEAMS environment variable, or put the seams executable on PATH";
17
18static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
19
20/// Per-corpus fingerprint options. Cutoff is required; graph defaults to
21/// `cutoff` and hops defaults to 2.
22#[derive(Clone, Debug)]
23pub struct AnnotateTopologyOpts {
24    pub cutoff: f64,
25    pub graph: String,
26    pub hops: u32,
27    pub seams: Option<PathBuf>,
28}
29
30impl AnnotateTopologyOpts {
31    pub fn new(cutoff: f64) -> Self {
32        Self {
33            cutoff,
34            graph: "cutoff".into(),
35            hops: 2,
36            seams: None,
37        }
38    }
39
40    pub fn graph(mut self, graph: impl Into<String>) -> Self {
41        self.graph = graph.into();
42        self
43    }
44
45    pub fn hops(mut self, hops: u32) -> Self {
46        self.hops = hops;
47        self
48    }
49
50    pub fn seams(mut self, path: impl Into<PathBuf>) -> Self {
51        self.seams = Some(path.into());
52        self
53    }
54}
55
56/// One parsed `seams fingerprint` object (frame HEX + method).
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FingerprintRecord {
59    pub frame: i64,
60    pub key: String,
61    pub method: String,
62}
63
64/// Recorded per-corpus topology parameters (every annotated traj must agree).
65#[derive(Clone, Debug, PartialEq)]
66pub struct TopologyParams {
67    pub cutoff: Option<f64>,
68    pub graph: Option<String>,
69    pub hops: Option<u32>,
70    pub method: Option<String>,
71}
72
73impl TopologyParams {
74    pub fn is_empty(&self) -> bool {
75        self.cutoff.is_none()
76            && self.graph.is_none()
77            && self.hops.is_none()
78            && self.method.is_none()
79    }
80
81    pub fn agrees(&self, other: &Self) -> bool {
82        self.cutoff == other.cutoff
83            && self.graph == other.graph
84            && self.hops == other.hops
85            && self.method == other.method
86    }
87
88    pub fn cutoff_or_err(&self) -> Result<f64> {
89        self.cutoff
90            .ok_or_else(|| Error::Message("recorded topology parameters lack cutoff".into()))
91    }
92
93    pub fn graph_or_default(&self) -> String {
94        self.graph.clone().unwrap_or_else(|| "cutoff".into())
95    }
96
97    pub fn hops_or_default(&self) -> u32 {
98        self.hops.unwrap_or(2)
99    }
100}
101
102#[derive(Deserialize)]
103struct SeamsJson {
104    command: Option<String>,
105    frame: Option<i64>,
106    status: i64,
107    text: String,
108}
109
110/// Lowercase hex digits only; used as the `idx_topo` prefix.
111pub fn normalize_topo_hex(raw: &str) -> Result<String> {
112    let s = raw.trim();
113    if s.is_empty() || !s.bytes().all(|c| c.is_ascii_hexdigit()) {
114        return Err(Error::Message(format!("invalid topology key {raw}")));
115    }
116    Ok(s.to_ascii_lowercase())
117}
118
119/// Strip CSI / OSC color sequences so token scans stay stable.
120pub fn strip_ansi(s: &str) -> String {
121    if !s.contains('\u{1b}') {
122        return s.to_owned();
123    }
124    let mut out = String::with_capacity(s.len());
125    let bytes = s.as_bytes();
126    let mut i = 0;
127    while i < bytes.len() {
128        if bytes[i] == 0x1b {
129            if i + 1 < bytes.len() && bytes[i + 1] == b'[' {
130                i += 2;
131                while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
132                    i += 1;
133                }
134                if i < bytes.len() {
135                    i += 1;
136                }
137                continue;
138            }
139            if i + 1 < bytes.len() && bytes[i + 1] == b']' {
140                i += 2;
141                while i < bytes.len() && bytes[i] != 0x07 && bytes[i] != b'\\' {
142                    i += 1;
143                }
144                if i < bytes.len() {
145                    i += 1;
146                }
147                continue;
148            }
149        }
150        let rest = &s[i..];
151        match rest.chars().next() {
152            Some(ch) => {
153                out.push(ch);
154                i += ch.len_utf8();
155            }
156            None => break,
157        }
158    }
159    out
160}
161
162/// Token after `key` is the frame HEX; token after `method` is nauty or wl.
163pub fn parse_fingerprint_text(text: &str) -> Result<(String, String)> {
164    let cleaned = strip_ansi(text);
165    let tokens: Vec<&str> = cleaned.split_whitespace().collect();
166    let mut key = None;
167    let mut method = None;
168    let mut i = 0;
169    while i + 1 < tokens.len() {
170        match tokens[i] {
171            "key" => {
172                key = Some(tokens[i + 1].to_owned());
173                i += 2;
174            }
175            "method" => {
176                method = Some(tokens[i + 1].to_owned());
177                i += 2;
178            }
179            _ => i += 1,
180        }
181    }
182    let key = key.ok_or_else(|| {
183        Error::Message(format!(
184            "seams fingerprint text missing key token: {cleaned}"
185        ))
186    })?;
187    let method = method.ok_or_else(|| {
188        Error::Message(format!(
189            "seams fingerprint text missing method token: {cleaned}"
190        ))
191    })?;
192    let key = normalize_topo_hex(&key)?;
193    Ok((key, method))
194}
195
196/// One JSON object from `seams fingerprint --format json`.
197pub fn parse_fingerprint_json_line(line: &str) -> Result<FingerprintRecord> {
198    let rec: SeamsJson = serde_json::from_str(line.trim())
199        .map_err(|e| Error::Message(format!("seams fingerprint json: {e}: {line}")))?;
200    if rec.status != 0 {
201        return Err(Error::Message(format!(
202            "seams fingerprint status {}: {}",
203            rec.status, rec.text
204        )));
205    }
206    if let Some(cmd) = rec.command.as_deref() {
207        if cmd != "fingerprint" {
208            return Err(Error::Message(format!(
209                "seams fingerprint unexpected command {cmd}"
210            )));
211        }
212    }
213    let (key, method) = parse_fingerprint_text(&rec.text)?;
214    Ok(FingerprintRecord {
215        frame: rec.frame.unwrap_or(0),
216        key,
217        method,
218    })
219}
220
221/// Parse every JSON object in `seams fingerprint --format json` stdout.
222pub fn parse_fingerprint_json_stdout(stdout: &str) -> Result<Vec<FingerprintRecord>> {
223    let mut out = Vec::new();
224    for line in stdout.lines() {
225        let line = line.trim();
226        if line.is_empty() {
227            continue;
228        }
229        out.push(parse_fingerprint_json_line(line)?);
230    }
231    if out.is_empty() {
232        return Err(Error::Message(
233            "seams fingerprint produced no JSON objects".into(),
234        ));
235    }
236    Ok(out)
237}
238
239fn search_path(name: &str) -> Option<PathBuf> {
240    if name.contains('/') {
241        let p = PathBuf::from(name);
242        return p.is_file().then_some(p);
243    }
244    let path = std::env::var_os("PATH")?;
245    for dir in std::env::split_paths(&path) {
246        let cand = dir.join(name);
247        if cand.is_file() {
248            return Some(cand);
249        }
250    }
251    None
252}
253
254/// Resolve the engine from `--seams`, `SEAMS`, or `PATH`.
255pub fn resolve_seams_binary(explicit: Option<&Path>) -> Result<PathBuf> {
256    if let Some(p) = explicit {
257        if p.is_file() {
258            return Ok(p.to_path_buf());
259        }
260        return Err(Error::Message(format!(
261            "{SEAMS_MISSING} (not a file: {})",
262            p.display()
263        )));
264    }
265    if let Ok(val) = std::env::var("SEAMS") {
266        if !val.is_empty() {
267            let p = PathBuf::from(&val);
268            if p.is_file() {
269                return Ok(p);
270            }
271            if let Some(found) = search_path(&val) {
272                return Ok(found);
273            }
274            return Err(Error::Message(SEAMS_MISSING.into()));
275        }
276    }
277    search_path("seams").ok_or_else(|| Error::Message(SEAMS_MISSING.into()))
278}
279
280struct TempCon {
281    path: PathBuf,
282}
283
284impl Drop for TempCon {
285    fn drop(&mut self) {
286        let _ = std::fs::remove_file(&self.path);
287    }
288}
289
290fn write_temp_con(bytes: &[u8]) -> Result<TempCon> {
291    let id = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
292    let path =
293        std::env::temp_dir().join(format!("readcon-db-topo-{}-{id}.con", std::process::id()));
294    std::fs::write(&path, bytes)?;
295    Ok(TempCon { path })
296}
297
298/// Run `seams fingerprint FILE --format json --cutoff C --graph G --hops H`.
299pub fn run_seams_fingerprint(
300    seams: &Path,
301    file: &Path,
302    cutoff: f64,
303    graph: &str,
304    hops: u32,
305) -> Result<Vec<FingerprintRecord>> {
306    let file_s = file.to_str().ok_or(Error::Nul)?;
307    let cutoff_s = cutoff.to_string();
308    let hops_s = hops.to_string();
309    let output = Command::new(seams)
310        .args([
311            "fingerprint",
312            file_s,
313            "--format",
314            "json",
315            "--cutoff",
316            &cutoff_s,
317            "--graph",
318            graph,
319            "--hops",
320            &hops_s,
321        ])
322        .output()?;
323    if !output.status.success() {
324        let err = String::from_utf8_lossy(&output.stderr);
325        let out = String::from_utf8_lossy(&output.stdout);
326        return Err(Error::Message(format!(
327            "seams fingerprint failed (exit {:?}): {err}{out}",
328            output.status.code()
329        )));
330    }
331    let stdout = String::from_utf8_lossy(&output.stdout);
332    parse_fingerprint_json_stdout(&stdout)
333}
334
335/// Fingerprint CON bytes by writing a temp file for the engine.
336pub fn fingerprint_con_bytes(
337    seams: &Path,
338    bytes: &[u8],
339    cutoff: f64,
340    graph: &str,
341    hops: u32,
342) -> Result<Vec<FingerprintRecord>> {
343    let tmp = write_temp_con(bytes)?;
344    run_seams_fingerprint(seams, &tmp.path, cutoff, graph, hops)
345}
346
347/// Mixed-parameter error used by annotate and reindex.
348pub fn mixed_topo_error(a: &TopologyParams, b: &TopologyParams) -> Error {
349    Error::Message(format!(
350        "mixed topology parameters: do not mix cutoff/graph/hops/method (have cutoff={:?} graph={:?} hops={:?} method={:?}; saw cutoff={:?} graph={:?} hops={:?} method={:?})",
351        a.cutoff, a.graph, a.hops, a.method, b.cutoff, b.graph, b.hops, b.method
352    ))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn parse_text_key_and_method() {
361        let text =
362            "nop 4 graph cutoff hops 2 method nauty key abcdef0123 classes 2 rings 3:0 4:1 top abc=2";
363        let (key, method) = parse_fingerprint_text(text).unwrap();
364        assert_eq!(key, "abcdef0123");
365        assert_eq!(method, "nauty");
366    }
367
368    #[test]
369    fn parse_text_strips_ansi_and_normalizes_hex() {
370        let text = "nop 4 \u{1b}[1;36mmethod\u{1b}[0m WL \u{1b}[1;36mkey\u{1b}[0m DEADBEEF";
371        let (key, method) = parse_fingerprint_text(text).unwrap();
372        assert_eq!(key, "deadbeef");
373        assert_eq!(method, "WL");
374    }
375
376    #[test]
377    fn parse_json_object_per_frame() {
378        let line = r#"{"schema":"dseams.cli/v1","command":"fingerprint","frame":1,"status":0,"text":"nop 4 graph cutoff hops 2 method nauty key 00ff classes 1 rings 3:0 top 00ff=4"}"#;
379        let rec = parse_fingerprint_json_line(line).unwrap();
380        assert_eq!(rec.frame, 1);
381        assert_eq!(rec.key, "00ff");
382        assert_eq!(rec.method, "nauty");
383    }
384
385    #[test]
386    fn parse_json_rejects_nonzero_status() {
387        let line = r#"{"schema":"dseams.cli/v1","command":"fingerprint","frame":1,"status":2,"text":"boom"}"#;
388        let err = parse_fingerprint_json_line(line).unwrap_err().to_string();
389        assert!(err.contains("status 2"), "{err}");
390    }
391
392    #[test]
393    fn parse_json_stdout_two_frames() {
394        let stdout = r#"{"schema":"dseams.cli/v1","command":"fingerprint","frame":1,"status":0,"text":"nop 1 graph cutoff hops 2 method nauty key aa classes 1 rings 3:0 top aa=1"}
395{"schema":"dseams.cli/v1","command":"fingerprint","frame":2,"status":0,"text":"nop 1 graph cutoff hops 2 method nauty key bb classes 1 rings 3:0 top bb=1"}
396"#;
397        let recs = parse_fingerprint_json_stdout(stdout).unwrap();
398        assert_eq!(recs.len(), 2);
399        assert_eq!(recs[0].key, "aa");
400        assert_eq!(recs[1].key, "bb");
401    }
402
403    #[test]
404    fn normalize_hex_rejects_garbage() {
405        assert!(normalize_topo_hex("not-hex").is_err());
406        assert!(normalize_topo_hex("").is_err());
407        assert_eq!(normalize_topo_hex("AbC").unwrap(), "abc");
408    }
409
410    #[test]
411    fn resolve_missing_seams_names_command() {
412        let err = resolve_seams_binary(Some(Path::new("/no/such/seams-binary")))
413            .unwrap_err()
414            .to_string();
415        assert!(err.contains("seams fingerprint"), "{err}");
416    }
417
418    #[test]
419    fn params_agree_requires_all_fields() {
420        let a = TopologyParams {
421            cutoff: Some(3.0),
422            graph: Some("cutoff".into()),
423            hops: Some(2),
424            method: Some("nauty".into()),
425        };
426        let mut b = a.clone();
427        assert!(a.agrees(&b));
428        b.method = Some("wl".into());
429        assert!(!a.agrees(&b));
430    }
431}