1use 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
15pub 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FingerprintRecord {
59 pub frame: i64,
60 pub key: String,
61 pub method: String,
62}
63
64#[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
110pub 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
119pub 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
162pub 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
196pub 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
221pub 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
254pub 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
298pub 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
335pub 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
347pub 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}