use crate::{ destination::{Destination, resolve_variables}, sexpr::Node, }; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}, }; use walkdir::WalkDir; #[derive(Debug, Default)] pub struct ModelPlan { pub copies: Vec<(PathBuf, Vec)>, pub messages: Vec, } fn supported(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "step" | "stp" | "wrl")) } fn digest(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } fn reference_parts(reference: &str) -> Vec { reference .replace('\\', "/") .split('/') .filter(|s| !s.is_empty() && *s != ".") .map(|s| s.to_ascii_lowercase()) .collect() } fn suffix_score(path: &Path, reference: &[String]) -> usize { let parts = reference_parts(&path.to_string_lossy()); parts .iter() .rev() .zip(reference.iter().rev()) .take_while(|(a, b)| a == b) .count() } // KIPRJMOD belongs to the footprint's source project when copying a // registered project library into another project. The target project is only // the fallback for fresh bundle imports without discoverable source context. fn source_project(source: &Path) -> Option { for directory in source.parent()?.ancestors() { let Ok(entries) = std::fs::read_dir(directory) else { continue; }; if entries.filter_map(Result::ok).any(|entry| { entry .path() .extension() .is_some_and(|ext| ext == "kicad_pro") && entry.path().is_file() }) { return Some(directory.to_path_buf()); } } None } /// Resolve an original path first; otherwise rank recursive bundle/search-root /// matches by trailing path components. Equal-content candidates collapse safely. pub fn model_candidates( reference: &str, source_footprint: &Path, source_root: &Path, search_roots: &[PathBuf], variables: &BTreeMap, project: Option<&Path>, ) -> Result> { let source_project = source_project(source_footprint); let project = source_project.as_deref().or(project); let normalized = reference.replace('\\', "/"); if let Ok(expanded) = resolve_variables(&normalized, project, variables) { let path = PathBuf::from(&expanded); let direct = if path.is_absolute() { path } else { source_footprint.parent().unwrap_or(source_root).join(path) }; if direct.is_file() && supported(&direct) { return Ok(vec![direct]); } let bundled = source_root.join(expanded.trim_start_matches('/')); if bundled.is_file() && supported(&bundled) { return Ok(vec![bundled]); } } let parts = reference_parts(&normalized); let Some(filename) = parts.last() else { return Ok(vec![]); }; let mut roots = vec![source_root.to_path_buf()]; roots.extend_from_slice(search_roots); let mut candidates = BTreeSet::new(); let mut best_score = 0; for root in roots { if !root.exists() { continue; } if root.is_file() { if supported(&root) && root .file_name() .is_some_and(|s| s.to_string_lossy().eq_ignore_ascii_case(filename)) { let score = suffix_score(&root, &parts); if score > best_score { candidates.clear(); best_score = score; } if score == best_score { candidates.insert(root.canonicalize()?); } } continue; } for entry in WalkDir::new(&root).follow_links(false) { let entry = entry.with_context(|| format!("Search model files in {}", root.display()))?; if !entry.file_type().is_file() || !supported(entry.path()) { continue; } if !entry .file_name() .to_string_lossy() .eq_ignore_ascii_case(filename) { continue; } let score = suffix_score(entry.path(), &parts); if score > best_score { candidates.clear(); best_score = score; } if score == best_score { candidates.insert(entry.path().canonicalize()?); } } } let mut unique = BTreeMap::new(); for candidate in candidates { let bytes = std::fs::read(&candidate) .with_context(|| format!("Read model {}", candidate.display()))?; unique.entry(digest(&bytes)).or_insert(candidate); } let mut paths: Vec<_> = unique.into_values().collect(); paths.sort(); Ok(paths) } #[cfg(test)] pub fn repair_models( footprint: &mut Node, source_footprint: &Path, source_root: &Path, destination: &Destination, search_roots: &[PathBuf], variables: &BTreeMap, enabled: bool, ) -> Result { repair_models_with_choices( footprint, source_footprint, source_root, destination, search_roots, variables, enabled, &BTreeMap::new(), ) } #[allow(clippy::too_many_arguments)] pub fn repair_models_with_choices( footprint: &mut Node, source_footprint: &Path, source_root: &Path, destination: &Destination, search_roots: &[PathBuf], variables: &BTreeMap, enabled: bool, choices: &BTreeMap, ) -> Result { let mut plan = ModelPlan::default(); repair_node( footprint, source_footprint, source_root, destination, search_roots, variables, enabled, choices, &mut plan, )?; Ok(plan) } #[allow(clippy::too_many_arguments)] fn repair_node( node: &mut Node, source_footprint: &Path, source_root: &Path, destination: &Destination, search_roots: &[PathBuf], variables: &BTreeMap, enabled: bool, choices: &BTreeMap, plan: &mut ModelPlan, ) -> Result<()> { let Node::List(items) = node else { return Ok(()); }; if matches!(items.first(), Some(Node::Atom(head)) if head == "model") { let reference = match items.get(1) { Some(Node::Str(s) | Node::Atom(s)) => s.clone(), _ => bail!( "Model reference missing a filename in {}", source_footprint.display() ), }; if reference.starts_with("embedded://") || reference.starts_with("kicad-embed://") || reference.starts_with("${KICAD_EMBEDDED}") { plan.messages .push(format!("Preserved embedded model: {reference}")); return Ok(()); } if !enabled { plan.messages.push(format!("Model import disabled; external dependency remains unresolved for portability: {reference}")); return Ok(()); } let candidates = model_candidates( &reference, source_footprint, source_root, search_roots, variables, destination.project.as_deref(), )?; let source = if let Some(choice) = choices.get(&reference) { let canonical = choice .canonicalize() .with_context(|| format!("Chosen model is unavailable: {}", choice.display()))?; if !candidates .iter() .any(|candidate| candidate.canonicalize().ok().as_ref() == Some(&canonical)) { bail!( "pending: chosen model is no longer a matching candidate: {}", choice.display() ); } canonical } else { match candidates.as_slice() { [] => { plan.messages.push(format!("Missing model; dependency unresolved, original reference preserved: {reference}")); return Ok(()); } [one] => one.clone(), many => bail!( "pending: ambiguous model {reference}; choose one of: {}", many.iter() .map(|p| p.display().to_string()) .collect::>() .join(" | ") ), } }; let bytes = std::fs::read(&source).with_context(|| format!("Read model {}", source.display()))?; // A footprint already in this destination must not acquire another hash // suffix each time its models are reviewed or the component is reimported. if source.parent().and_then(|p| p.canonicalize().ok()) == destination.models_dir.canonicalize().ok() && destination.models_dir.is_dir() { items[1] = Node::Str(destination.model_uri(&source)); plan.messages .push(format!("Reused model: {}", source.display())); return Ok(()); } let hash = digest(&bytes); let stem = source.file_stem().unwrap_or_default().to_string_lossy(); let safe_stem: String = stem .chars() .map(|c| { if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect(); let extension = source .extension() .unwrap_or_default() .to_string_lossy() .to_ascii_lowercase(); let mut target = destination .models_dir .join(format!("{safe_stem}_{}.{}", &hash[..12], extension)); if target.exists() && std::fs::read(&target) .with_context(|| format!("Read existing model {}", target.display()))? != bytes { target = destination .models_dir .join(format!("{safe_stem}_{hash}.{extension}")); if target.exists() && std::fs::read(&target)? != bytes { bail!("Model storage collision at {}", target.display()); } } let uri = destination.model_uri(&target); let reused = target.is_file(); if !reused && !plan.copies.iter().any(|(path, _)| path == &target) { plan.copies.push((target.clone(), bytes)); } items[1] = Node::Str(uri.clone()); plan.messages.push(format!( "{} model: {} -> {}{}", if reused { "Reused" } else { "Copied" }, source.display(), uri, if reference != source.to_string_lossy() { " (reference repaired)" } else { "" } )); return Ok(()); } for child in items { repair_node( child, source_footprint, source_root, destination, search_roots, variables, enabled, choices, plan, )?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::sexpr::parse; fn scratch() -> PathBuf { let path = std::env::temp_dir().join(format!( "uniloader-models-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&path).unwrap(); path } #[test] fn repairs_windows_paths_preserving_transforms() { let root = scratch(); std::fs::create_dir_all(root.join("bundle/3d")).unwrap(); std::fs::write(root.join("bundle/3d/part.STEP"), b"model").unwrap(); std::fs::write(root.join("board.kicad_pro"), b"{}").unwrap(); let dest = Destination::new( &root.join("global"), Some(&root.join("board.kicad_pro")), &root.join("config"), ) .unwrap(); let mut fp = parse(r#"(footprint "x" (model "C:\\old\\3d\\part.STEP" (offset (xyz 1 2 3)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 90))))"#).unwrap(); let plan = repair_models( &mut fp, &root.join("bundle/x.kicad_mod"), &root.join("bundle"), &dest, &[], &BTreeMap::new(), true, ) .unwrap(); assert_eq!(plan.copies.len(), 1); let rendered = fp.to_string(); assert!(rendered.contains("${KIPRJMOD}/libraries/models/part_")); assert!(rendered.contains("(xyz 0 0 90)")); assert!(rendered.contains("(xyz 1 2 3)")); std::fs::remove_dir_all(root).unwrap(); } #[test] fn collapses_identical_candidates_but_requires_choice_for_different_content() { let root = scratch(); for dir in ["a", "b"] { std::fs::create_dir(root.join(dir)).unwrap(); std::fs::write(root.join(dir).join("part.step"), b"same").unwrap(); } let candidates = model_candidates( "C:/obsolete/part.step", &root.join("x.kicad_mod"), &root, &[], &BTreeMap::new(), None, ) .unwrap(); assert_eq!(candidates.len(), 1); std::fs::write(root.join("b/part.step"), b"different").unwrap(); assert_eq!( model_candidates( "C:/obsolete/part.step", &root.join("x.kicad_mod"), &root, &[], &BTreeMap::new(), None ) .unwrap() .len(), 2 ); let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap(); let mut fp = parse(r#"(footprint "x" (model "C:/obsolete/part.step"))"#).unwrap(); assert!( repair_models( &mut fp, &root.join("x.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true ) .unwrap_err() .to_string() .starts_with("pending:") ); let choices = BTreeMap::from([("C:/obsolete/part.step".into(), root.join("b/part.step"))]); let plan = repair_models_with_choices( &mut fp, &root.join("x.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true, &choices, ) .unwrap(); assert_eq!(plan.copies[0].1, b"different"); std::fs::remove_dir_all(root).unwrap(); } #[test] fn external_project_model_uses_source_context_and_becomes_portable() { let root = scratch(); let source = root.join("source"); let target = root.join("target"); for project in [&source, &target] { std::fs::create_dir_all(project.join("libraries/models")).unwrap(); std::fs::create_dir_all(project.join("libraries/Parts.pretty")).unwrap(); std::fs::write(project.join("board.kicad_pro"), b"{}").unwrap(); } std::fs::write(source.join("libraries/models/chip.step"), b"SOURCE MODEL").unwrap(); // Same URI in another project is not the same dependency. std::fs::write( target.join("libraries/models/chip.step"), b"WRONG TARGET MODEL", ) .unwrap(); let dest = Destination::new( &root.join("global"), Some(&target.join("board.kicad_pro")), &root.join("config"), ) .unwrap(); let mut fp = parse(r#"(footprint "chip" (model "${KIPRJMOD}/libraries/models/chip.step" (offset (xyz 1 2 3))))"#).unwrap(); let source_fp = source.join("libraries/Parts.pretty/chip.kicad_mod"); let plan = repair_models( &mut fp, &source_fp, source_fp.parent().unwrap(), &dest, &[], &BTreeMap::new(), true, ) .unwrap(); assert_eq!(plan.copies.len(), 1); assert_eq!(plan.copies[0].1, b"SOURCE MODEL"); assert!(plan.copies[0].0.starts_with(&target)); assert!( fp.to_string() .contains("${KIPRJMOD}/libraries/models/chip_") ); assert!(fp.to_string().contains("(xyz 1 2 3)")); std::fs::remove_dir_all(root).unwrap(); } #[test] fn conflicting_model_storage_keeps_existing_bytes() { let root = scratch(); std::fs::write(root.join("chip.step"), b"NEW MODEL").unwrap(); let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap(); std::fs::create_dir_all(&dest.models_dir).unwrap(); let hash = digest(b"NEW MODEL"); let collision = dest.models_dir.join(format!("chip_{}.step", &hash[..12])); std::fs::write(&collision, b"EXISTING USER MODEL").unwrap(); let mut fp = parse(r#"(footprint "chip" (model "chip.step"))"#).unwrap(); let plan = repair_models( &mut fp, &root.join("chip.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true, ) .unwrap(); assert_ne!(plan.copies[0].0, collision); assert_eq!(std::fs::read(&collision).unwrap(), b"EXISTING USER MODEL"); assert_eq!(plan.copies[0].1, b"NEW MODEL"); // A second collision refuses to plan an overwrite. std::fs::write(&plan.copies[0].0, b"ANOTHER EXISTING MODEL").unwrap(); let mut original = parse(r#"(footprint "chip" (model "chip.step"))"#).unwrap(); assert!( repair_models( &mut original, &root.join("chip.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true ) .is_err() ); std::fs::remove_dir_all(root).unwrap(); } #[test] fn embedded_references_are_preserved_without_warnings() { let root = scratch(); let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap(); for reference in [ "embedded://chip.step", "kicad-embed://chip.step", "${KICAD_EMBEDDED}/chip.step", ] { let mut fp = Node::list(vec![ Node::symbol("footprint"), Node::string("chip"), Node::list(vec![Node::symbol("model"), Node::string(reference)]), ]); let before = fp.to_string(); let plan = repair_models( &mut fp, &root.join("chip.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true, ) .unwrap(); assert_eq!(fp.to_string(), before); assert!(plan.copies.is_empty()); assert!(plan.messages[0].contains("Preserved embedded")); } std::fs::remove_dir_all(root).unwrap(); } #[test] fn missing_models_remain_visible() { let root = scratch(); let dest = Destination::new(&root.join("global"), None, &root.join("config")).unwrap(); let mut fp = parse(r#"(footprint "x" (model "missing.wrl"))"#).unwrap(); let before = fp.to_string(); let plan = repair_models( &mut fp, &root.join("x.kicad_mod"), &root, &dest, &[], &BTreeMap::new(), true, ) .unwrap(); assert_eq!(fp.to_string(), before); assert!(plan.copies.is_empty()); assert!(plan.messages[0].contains("Missing model")); std::fs::remove_dir_all(root).unwrap(); } }