//! Conservative exact matching and deliberately simple candidate detection. //! //! Exact normalization ignores only the root footprint name and generated //! uuid/tstamp records in recognized footprint elements. All other fields //! (including unknown subtrees) remain. use crate::sexpr::Node; use sha2::{Digest, Sha256}; use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, }; fn digest(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn contextual_model(path: &str, source: &Path) -> String { let normalized = path.replace('\\', "/"); let mut expanded = normalized.clone(); // Expand known environment variables, leaving unsupported ones explicit. while let Some(start) = expanded.find("${") { let Some(end) = expanded[start + 2..].find('}') else { break; }; let end = start + 2 + end; let key = &expanded[start + 2..end]; let value = if key == "KIPRJMOD" { source .parent() .into_iter() .flat_map(Path::ancestors) .find(|p| { fs::read_dir(p).ok().is_some_and(|entries| { entries .filter_map(Result::ok) .any(|e| e.path().extension().is_some_and(|x| x == "kicad_pro")) }) }) .map(|p| p.to_string_lossy().into_owned()) .or_else(|| std::env::var(key).ok()) } else { std::env::var(key).ok() }; let Some(value) = value else { break; }; expanded.replace_range(start..=end, &value); // Avoid pathological self-referential variables. if value.contains("${") { break; } } let path = PathBuf::from(&expanded); let resolved = if path.is_absolute() { path } else { source.parent().unwrap_or(Path::new(".")).join(path) }; if let Ok(bytes) = fs::read(&resolved) { return format!("model-content:{}", digest(&bytes)); } // Relative model strings from different libraries are not proof of identity. // Include lexical origin even when canonicalization cannot resolve the model. let origin = fs::canonicalize(source).unwrap_or_else(|_| { if source.is_absolute() { source.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(source) } }); format!( "unresolved-model:{}:{}", origin.to_string_lossy(), normalized ) } pub fn exact_fingerprint(node: &Node, source_path: &Path) -> String { fn normalized(n: &Node, source: &Path, root: bool) -> Node { match n { Node::List(v) => { // Do not normalize an unknown subtree: its identifiers may have // semantics we do not understand. if !matches!( n.head(), Some( "footprint" | "module" | "pad" | "fp_line" | "fp_arc" | "fp_rect" | "fp_circle" | "fp_poly" | "fp_curve" | "fp_text" | "fp_text_box" | "property" | "model" ) ) { return n.clone(); } let mut children: Vec = v .iter() .filter(|c| !matches!(c.head(), Some("uuid" | "tstamp"))) .map(|c| normalized(c, source, false)) .collect(); if root && matches!(n.head(), Some("footprint" | "module")) && children.len() > 1 { children[1] = Node::string(""); } if n.head() == Some("model") && let Some(path) = n.value() { children[1] = Node::string(contextual_model(path, source)); } Node::List(children) } _ => n.clone(), } } digest(normalized(node, source_path, true).to_string().as_bytes()) } fn pads(n: &Node) -> BTreeMap> { let mut result: BTreeMap> = BTreeMap::new(); for pad in n.children().iter().filter(|n| n.head() == Some("pad")) { result .entry(pad.value().unwrap_or("").to_owned()) .or_default() .push(pad); } result } fn coordinates(n: &Node, field: &str) -> Option<(f64, f64)> { let v = n.field(field)?.children(); let x = v.get(1)?.atom()?.parse::().ok()?; let y = v.get(2)?.atom()?.parse::().ok()?; (x.is_finite() && y.is_finite()).then_some((x, y)) } pub fn similar(a: &Node, b: &Node, tolerance: f64) -> bool { if let (Some(an), Some(bn)) = (a.value(), b.value()) && !an.is_empty() && an == bn { return true; } let ap = pads(a); let bp = pads(b); if ap.is_empty() || ap.len() != bp.len() { return false; } let tolerance = if tolerance.is_finite() { tolerance.max(0.0) } else { 0.0 }; for (number, aa) in ap { let Some(bb) = bp.get(&number) else { return false; }; if aa.len() != bb.len() { return false; } // Multiple pads with the same number are common. Match one-to-one, // without depending on file ordering. let mut used = vec![false; bb.len()]; for pad in aa { let Some((ax, ay)) = coordinates(pad, "at") else { return false; }; let Some(index) = bb.iter().enumerate().position(|(i, other)| { !used[i] && coordinates(other, "at").is_some_and(|(bx, by)| { (ax - bx).abs() <= tolerance && (ay - by).abs() <= tolerance }) }) else { return false; }; used[index] = true; } } true } pub fn differences(a: &Node, b: &Node) -> Vec { let mut result = Vec::new(); let ap = pads(a); let bp = pads(b); let ac: usize = ap.values().map(Vec::len).sum(); let bc: usize = bp.values().map(Vec::len).sum(); if ac != bc { result.push(format!("Pad count: incoming {ac}, candidate {bc}")); } let ak: Vec<_> = ap.keys().collect(); let bk: Vec<_> = bp.keys().collect(); if ak != bk { result.push(format!("Pad numbers: incoming {ak:?}, candidate {bk:?}")); } for (number, aa) in &ap { if let Some(bb) = bp.get(number) { for (a, b) in aa.iter().zip(bb.iter()) { for (index, label) in [(2, "type"), (3, "shape")] { let av = a .children() .get(index) .and_then(Node::atom) .unwrap_or("missing"); let bv = b .children() .get(index) .and_then(Node::atom) .unwrap_or("missing"); if av != bv { result.push(format!("Pad {number} {label}: {av} → {bv}")); } } for field in [ "at", "size", "drill", "layers", "solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", ] { let av = a.field(field).map(ToString::to_string); let bv = b.field(field).map(ToString::to_string); if av != bv { result.push(format!( "Pad {number} {field}: {} → {}", av.as_deref().unwrap_or("default"), bv.as_deref().unwrap_or("default") )); } } } } } for field in [ "attr", "layer", "solder_mask_margin", "solder_paste_margin", "solder_paste_ratio", "clearance", ] { if a.field(field) != b.field(field) { result.push(format!("Footprint {field} differs")); } } let am: Vec<_> = a .children() .iter() .filter(|n| n.head() == Some("model")) .collect(); let bm: Vec<_> = b .children() .iter() .filter(|n| n.head() == Some("model")) .collect(); if am != bm { result.push("3D model references or transforms differ".into()); } let graphics = |n: &Node| -> Vec { n.children() .iter() .filter(|c| { c.head() .is_some_and(|h| h.starts_with("fp_") || h == "property") }) .map(ToString::to_string) .collect() }; if graphics(a) != graphics(b) { result.push("Graphics, labels or courtyard differ".into()); } if result.is_empty() && a != b { result.push("Other fields or generated identifiers differ; inspect the files".into()); } result } #[cfg(test)] mod tests { use super::*; use crate::sexpr::parse; #[test] fn exact_ignores_only_name_whitespace_and_identifiers() { let a = parse( r#"(footprint "A" (uuid "a") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")))"#, ) .unwrap(); let b = parse( r#"(footprint "B" (uuid "b") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")))"#, ) .unwrap(); assert_eq!( exact_fingerprint(&a, Path::new("a")), exact_fingerprint(&b, Path::new("b")) ); let c = parse(r#"(footprint "A" (uuid "a") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu")) (unknown_future 1))"#).unwrap(); assert_ne!( exact_fingerprint(&a, Path::new("a")), exact_fingerprint(&c, Path::new("a")) ); let future_a = parse(r#"(footprint "A" (future (uuid "a")))"#).unwrap(); let future_b = parse(r#"(footprint "A" (future (uuid "b")))"#).unwrap(); assert_ne!( exact_fingerprint(&future_a, Path::new("a")), exact_fingerprint(&future_b, Path::new("a")) ); } #[test] fn relative_models_are_contextual_and_content_based() { let temp = tempfile::tempdir().unwrap(); let a = temp.path().join("a"); let b = temp.path().join("b"); fs::create_dir_all(&a).unwrap(); fs::create_dir_all(&b).unwrap(); let fp = parse(r#"(footprint "A" (model "part.step" (offset (xyz 0 0 0))))"#).unwrap(); let pa = a.join("a.kicad_mod"); let pb = b.join("b.kicad_mod"); assert_ne!(exact_fingerprint(&fp, &pa), exact_fingerprint(&fp, &pb)); fs::write(a.join("part.step"), "same model").unwrap(); fs::write(b.join("part.step"), "same model").unwrap(); assert_eq!(exact_fingerprint(&fp, &pa), exact_fingerprint(&fp, &pb)); fs::write(b.join("part.step"), "different model").unwrap(); assert_ne!(exact_fingerprint(&fp, &pa), exact_fingerprint(&fp, &pb)); } #[test] fn similar_is_only_a_candidate_and_keeps_pad_numbering() { let a = parse(r#"(footprint "A" (pad "1" smd rect (at 0 0) (size 1 1)))"#).unwrap(); let b = parse(r#"(footprint "B" (pad "1" smd rect (at 0.01 0) (size 2 1)))"#).unwrap(); let c = parse(r#"(footprint "C" (pad "2" smd rect (at 0 0) (size 1 1)))"#).unwrap(); assert!(similar(&a, &b, 0.05)); assert!(!similar(&a, &c, 0.05)); assert!(differences(&a, &b).iter().any(|d| d.contains("size"))); assert_ne!( exact_fingerprint(&a, Path::new("a")), exact_fingerprint(&b, Path::new("b")) ); } }