From 8ac15838bf2aa478ac8631241db1f69c4863975b Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sun, 18 Oct 2026 14:27:20 +0200 Subject: [PATCH] Release v0.2.2: separate library nicknames and add doctor migration --- CHANGELOG.md | 10 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 18 +++- SPEC.md | 2 +- docs/SETUP.md | 47 ++++++++- src/destination.rs | 10 ++ src/doctor.rs | 252 ++++++++++++++++++++++++++++++++++++++++++++ src/importer.rs | 4 +- src/main.rs | 15 +++ tests/import_cli.rs | 175 ++++++++++++++++++++++++++++-- 11 files changed, 519 insertions(+), 18 deletions(-) create mode 100644 src/doctor.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 908f82e..66e9643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.2.2 + +- Use `UniLoader_Global` and `UniLoader_Local` nicknames for global and project libraries, preventing project entries from shadowing global managed libraries. +- Keep existing asset filenames and portable paths unchanged. +- Add `doctor --global` and `doctor --project FILE` to preview nickname repairs; `--fix` backs up and migrates registrations, managed symbol bindings, and project schematic/PCB references. +- Preserve unrelated registrations, reject occupied target nicknames before writes, and retain exact legacy nickname mappings for later project migrations. +- Stop imports using legacy managed nicknames with instructions to run doctor. + +Validation: 62 automated tests, Clippy, formatting, and release build passed. Live KiCad integration requires manual verification. + ## 0.2.1 - Fix high idle watcher CPU usage caused by directory scans generating filesystem open events and triggering further scans. diff --git a/Cargo.lock b/Cargo.lock index 9b80488..9467a96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uniloader" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0f4be3d..902d797 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uniloader" -version = "0.2.1" +version = "0.2.2" edition = "2024" rust-version = "1.95" diff --git a/README.md b/README.md index 40d65b1..83520f9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ folder throughout your graphical login session. It supports symbols, footprints and referenced STEP/VRML models, with: -- Global libraries or portable project-local libraries, created and registered automatically. +- Global (`UniLoader_Global`) and project-local (`UniLoader_Local`) library nicknames, created and registered automatically. - A destination dialog offering detected KiCad project suggestions, Global and Browse. - Conservative exact footprint reuse and comparison dialogs for uncertain matches. - Broken model-path repair, explicit ambiguous model choices and missing-model warnings. @@ -59,6 +59,22 @@ No-argument startup also runs the watcher. `--noninteractive` leaves unresolved choices pending; `--force` on manual imports deliberately revisits a completed source. Check an imported component in KiCad before using it in a board. +## Upgrade existing libraries to v0.2.2 + +Close KiCad and stop the watcher, then preview and apply the nickname migration: + +```sh +uniloader doctor --global +uniloader doctor --global --fix +uniloader doctor --project /path/to/Board/Board.kicad_pro +uniloader doctor --project /path/to/Board/Board.kicad_pro --fix +``` + +Repeat the project commands for each existing project. Project repair also checks +and repairs the configured global libraries. Existing library filenames stay the +same. Changed files are backed up; see the [setup guide](docs/SETUP.md#nickname-migration-v022) +for migration scope and recovery. Reopen KiCad afterwards. + ## Scope This is practical Linux development tooling. Project discovery currently uses diff --git a/SPEC.md b/SPEC.md index 414e9e0..649caca 100644 --- a/SPEC.md +++ b/SPEC.md @@ -129,7 +129,7 @@ project/ models/ ``` -- Add/merge UniLoader entries into the appropriate symbol and footprint tables. +- Add/merge `UniLoader_Global` or `UniLoader_Local` entries into the appropriate symbol and footprint tables. Preserve unrelated entries and never silently repoint an occupied nickname. - Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute paths. Let users select the KiCad configuration/version directory if ambiguous. diff --git a/docs/SETUP.md b/docs/SETUP.md index c2e5950..06c18c3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -152,7 +152,9 @@ Use `--noninteractive` to avoid opening dialogs. A pending choice is not consent Project imports create `libraries/UniLoader.kicad_sym`, `libraries/UniLoader.pretty/`, and model storage as needed, and merge registration entries into `sym-lib-table` and `fp-lib-table` beside the `.kicad_pro` file. -Project paths use `${KIPRJMOD}`. Portable mode copies selected external footprints +Project registration uses the nickname `UniLoader_Local`; global registration uses +`UniLoader_Global`. Library filenames remain `UniLoader.kicad_sym` and +`UniLoader.pretty` in both scopes. Project paths use `${KIPRJMOD}`. Portable mode copies selected external footprints and available referenced models into the project. Global imports use the configured library root and register entries in the selected KiCad configuration. Existing unrelated table entries are preserved. Existing schematic and PCB @@ -170,6 +172,49 @@ assets. Source archives are preserved by default. Incomplete or invalid ZIPs are retried for the configured period, then remain recoverable rather than stopping subsequent downloads. +## Nickname migration (v0.2.2) + +Before upgrading existing libraries, close KiCad and stop the watcher: + +```sh +systemctl --user stop uniloader.service +uniloader doctor --global +uniloader doctor --global --fix +uniloader doctor --project /path/to/Board/Board.kicad_pro +uniloader doctor --project /path/to/Board/Board.kicad_pro --fix +systemctl --user start uniloader.service +``` + +Without `--fix`, doctor previews the files it would change. `--global` checks the +configured global tables and managed symbol library. `--project` checks those +plus the selected project's tables, managed symbol library, and `.kicad_sch` and +`.kicad_pcb` files recursively inside its directory (including subsheets). Run it +for every existing project; projects elsewhere are not automatically discovered +or modified. External subsheets must be migrated separately. Set +`libraries.kicad_config` to the correct version directory when needed. + +Doctor identifies managed registrations by their resolved library paths, preserves +unrelated registrations and metadata, and refuses an occupied target nickname. +It updates symbol footprint properties, schematic library IDs and cached symbol +IDs, and placed PCB footprint IDs according to the original project-over-global +resolution. An old ambiguous ID keeps that original resolution; doctor cannot +recover a different intended library. Asset filenames and model paths stay the +same. Changed S-expression files are reserialized, so whitespace may change. + +Repairs use the import writer lock, atomic file replacement, and the normal +backup/history mechanism. The command prints backup paths under +`${XDG_DATA_HOME:-~/.local/share}/uniloader/backups/`; `uniloader pending --all` +also lists changed files and backups. Exact old nicknames (including collision +suffixes) are retained under `uniloader/nickname-migrations/` in the same data +root so later project repairs can update global references. Keep this migration +history until all projects are repaired. Repeated repairs leave unchanged files +alone. All files are parsed and collisions checked before writing, but a disk +failure can still interrupt a multi-file repair; restore the listed backups if +needed. Reopen KiCad after repair to reload its cached tables. + +Imports into a destination still using an old managed nickname stop with a doctor +instruction. Repair that destination before resuming imports. + ## 3D models and broken paths STEP (`.step`/`.stp`) and VRML (`.wrl`) models are copied when referenced by an diff --git a/src/destination.rs b/src/destination.rs index 8ac580f..cd27a48 100644 --- a/src/destination.rs +++ b/src/destination.rs @@ -66,6 +66,13 @@ impl Destination { } Ok(result) } + pub fn nickname(&self) -> &'static str { + if self.project.is_some() { + "UniLoader_Local" + } else { + "UniLoader_Global" + } + } fn uri(&self, path: &Path) -> String { if let Some(project) = &self.project && let Ok(relative) = path.strip_prefix(project) @@ -144,6 +151,9 @@ pub fn register_table( .then(|| field_value(entry, "name").map(str::to_owned)) .flatten() }) { + if requested_nickname.starts_with("UniLoader_") && !name.starts_with(requested_nickname) { + bail!("Legacy managed library nickname {name}; run `uniloader doctor --global --fix` or `uniloader doctor --project /path/to/Board.kicad_pro --fix` before importing"); + } return Ok((format!("{table}\n"), name)); } let mut nickname = requested_nickname.to_owned(); diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..8bbcf04 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,252 @@ +//! Explicit, backed-up migration of managed library registrations and references. +use crate::{ + config::Config, + destination::{self, Destination}, + importer, + sexpr::{Node, parse}, + storage::{Lock, Plan, Record}, +}; +use anyhow::{Context, Result, bail}; +use std::{collections::BTreeMap, fs, path::Path}; + +type Renames = BTreeMap; + +fn table(dest: &Destination, kind: &str, vars: &Renames, plan: &mut Plan) -> Result { + let (file, target) = if kind == "sym_lib_table" { + ("sym-lib-table", &dest.symbol_path) + } else { + ("fp-lib-table", &dest.pretty_dir) + }; + let path = dest.table_dir.join(file); + if !path.exists() { + return Ok(Renames::new()); + } + let original = fs::read_to_string(&path)?; + let mut node = parse(&original)?; + if node.head() != Some(kind) { + bail!("Invalid table {}", path.display()); + } + // Keep exact old names so a later project migration also handles legacy + // collision suffixes without guessing which nickname used to be ours. + let history_path = crate::config::data_dir() + .join("nickname-migrations") + .join(format!( + "{}.json", + crate::storage::hash(path.to_string_lossy().as_bytes()) + )); + let mut history: Renames = if history_path.exists() { + serde_json::from_slice(&fs::read(&history_path)?)? + } else { + Renames::new() + }; + let original_history = history.clone(); + let mut changes = Renames::new(); + let mut occupied = BTreeMap::new(); + for entry in node.children().iter().filter(|n| n.head() == Some("lib")) { + let name = entry + .field("name") + .and_then(Node::value) + .context("Library entry missing name")?; + if occupied.insert(name.to_owned(), ()).is_some() { + bail!("Duplicate nickname {name} in {}", path.display()); + } + } + for entry in node + .children_mut() + .iter_mut() + .filter(|n| n.head() == Some("lib")) + { + let name = entry + .field("name") + .and_then(Node::value) + .context("Library entry missing name")? + .to_owned(); + // Only change entries that resolve to our managed file/directory. + let uri = entry + .field("uri") + .and_then(Node::value) + .context("Library entry missing URI")?; + let Ok(resolved) = destination::resolve_variables(uri, dest.project.as_deref(), vars) + else { + continue; + }; + let resolved = std::path::PathBuf::from(resolved); + let resolved = if resolved.is_absolute() { + resolved + } else { + dest.table_dir.join(resolved) + }; + let matches = resolved == *target + || (target.exists() && resolved.canonicalize().ok() == target.canonicalize().ok()); + if !matches || entry.field("type").and_then(Node::value) != Some("KiCad") { + continue; + } + let wanted = dest.nickname(); + if name == wanted + || name + .strip_prefix(wanted) + .is_some_and(|s| s.starts_with('_')) + { + changes.extend( + history + .iter() + .filter(|(old, new)| *new == &name && !occupied.contains_key(*old)) + .map(|(old, new)| (old.clone(), new.clone())), + ); + continue; + } + if occupied.contains_key(wanted) { + bail!( + "Nickname {wanted} is occupied in {}; resolve it before migration", + path.display() + ); + } + occupied.insert(wanted.into(), ()); + entry.field_mut("name").unwrap().children_mut()[1] = Node::string(wanted); + history.insert(name.clone(), wanted.into()); + changes.insert(name, wanted.into()); + } + if history != original_history { + plan.put(history_path, serde_json::to_vec_pretty(&history)?)?; + } + if node != parse(&original)? { + plan.put(path, format!("{node}\n").into_bytes())?; + } + Ok(changes) +} + +fn rename(value: &mut Node, mapping: &Renames) { + if let Node::Atom(s) | Node::Str(s) = value + && let Some((nick, item)) = s.split_once(':') + && let Some(new) = mapping.get(nick) + { + *s = format!("{new}:{item}"); + } +} +fn references(node: &mut Node, symbols: &Renames, footprints: &Renames, parent: Option<&str>) { + let head = node.head().map(str::to_owned); + if let Node::List(items) = node { + match head.as_deref() { + Some("lib_id") | Some("extends") => { + if let Some(v) = items.get_mut(1) { + rename(v, symbols); + } + } + Some("symbol") if parent == Some("lib_symbols") => { + if let Some(v) = items.get_mut(1) { + rename(v, symbols); + } + } + Some("footprint") => { + if let Some(v) = items.get_mut(1) { + rename(v, footprints); + } + } + Some("property") if items.get(1).and_then(Node::atom) == Some("Footprint") => { + if let Some(v) = items.get_mut(2) { + rename(v, footprints); + } + } + _ => {} + } + for child in items { + references(child, symbols, footprints, head.as_deref()); + } + } +} +fn patch(path: &Path, symbols: &Renames, footprints: &Renames, plan: &mut Plan) -> Result<()> { + if !path.is_file() { + return Ok(()); + } + let original = fs::read_to_string(path).with_context(|| format!("Read {}", path.display()))?; + let mut node = parse(&original).with_context(|| format!("Parse {}", path.display()))?; + let before = node.clone(); + references(&mut node, symbols, footprints, None); + if node != before { + plan.put(path.to_owned(), format!("{node}\n").into_bytes())?; + } + Ok(()) +} +// A local entry shadows the global mapping even if it belongs to another tool. +fn overlay(dest: &Destination, file: &str, global: &Renames, local: Renames) -> Result { + let mut result = global.clone(); + let path = dest.table_dir.join(file); + if path.exists() { + for entry in parse(&fs::read_to_string(path)?)?.children() { + if entry.head() == Some("lib") + && let Some(name) = entry.field("name").and_then(Node::value) + { + result.remove(name); + } + } + } + result.extend(local); + Ok(result) +} + +pub fn run(cfg: &Config, project: Option<&Path>, fix: bool) -> Result<()> { + let _lock = Lock::acquire()?; + let kicad = importer::kicad_config(cfg, project.is_none())?; + let global = Destination::new(&cfg.libraries.path, None, &kicad)?; + let mut plan = Plan::default(); + let sym = table( + &global, + "sym_lib_table", + &cfg.libraries.variables, + &mut plan, + )?; + let fp = table(&global, "fp_lib_table", &cfg.libraries.variables, &mut plan)?; + patch(&global.symbol_path, &sym, &fp, &mut plan)?; + if let Some(project) = project { + let local = Destination::new(&cfg.libraries.path, Some(project), &kicad)?; + let local_sym = table(&local, "sym_lib_table", &cfg.libraries.variables, &mut plan)?; + let local_fp = table(&local, "fp_lib_table", &cfg.libraries.variables, &mut plan)?; + let sym = overlay(&local, "sym-lib-table", &sym, local_sym)?; + let fp = overlay(&local, "fp-lib-table", &fp, local_fp)?; + patch(&local.symbol_path, &sym, &fp, &mut plan)?; + for entry in walkdir::WalkDir::new(local.project.as_ref().unwrap()).follow_links(false) { + let entry = entry?; + if entry.file_type().is_file() + && entry + .path() + .extension() + .is_some_and(|e| e == "kicad_sch" || e == "kicad_pcb") + { + patch(entry.path(), &sym, &fp, &mut plan)?; + } + } + } + for path in plan.writes.keys() { + println!( + "{}: {}", + if fix { "Repair" } else { "Would repair" }, + path.display() + ); + } + if plan.writes.is_empty() { + println!("No nickname repairs needed."); + } else if fix { + let mut record = Record::new( + global.root.clone(), + crate::storage::hash(b"doctor-nicknames"), + ); + record.id = format!( + "doctor-{}", + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + ); + record.status = "importing".into(); + plan.commit(&mut record)?; + record.status = "completed".into(); + record.message = "Library nickname migration completed".into(); + record.save()?; + for backup in record.backups { + println!("Backup: {}", backup.display()); + } + } else { + println!("Preview only; rerun with --fix to apply repairs."); + } + println!( + "Run doctor --project /path/to/Board.kicad_pro for each existing project. Close KiCad and stop the watcher before --fix; reopen KiCad afterwards." + ); + Ok(()) +} diff --git a/src/importer.rs b/src/importer.rs index d43e08d..8890f25 100644 --- a/src/importer.rs +++ b/src/importer.rs @@ -431,13 +431,13 @@ pub fn run(cfg: &Config, req: &Request, record: &mut Record, session: &mut Sessi let (fp_table, fp_nickname) = destination::register_table( text(&dest.table_dir.join("fp-lib-table"))?.as_deref(), "fp_lib_table", - "UniLoader", + dest.nickname(), &dest.footprint_uri(), )?; let (sym_table, sym_nickname) = destination::register_table( text(&dest.table_dir.join("sym-lib-table"))?.as_deref(), "sym_lib_table", - "UniLoader", + dest.nickname(), &dest.symbol_uri(), )?; let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname) diff --git a/src/main.rs b/src/main.rs index f20d728..ffc55ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod desktop; mod destination; +mod doctor; mod gui; mod importer; mod matching; @@ -74,6 +75,15 @@ enum Commands { #[arg(long)] dry_run: bool, }, + /// Check managed nicknames; --fix backs up and migrates libraries/references. + Doctor { + #[arg(long, required_unless_present = "project", conflicts_with = "project")] + global: bool, + #[arg(long)] + project: Option, + #[arg(long)] + fix: bool, + }, /// Show project suggestions from running KiCad processes. Discover, } @@ -392,6 +402,11 @@ fn main() -> Result<()> { &mut Session::default(), ) } + Commands::Doctor { + global: _, + project, + fix, + } => doctor::run(&cfg, project.as_deref(), fix), Commands::Discover => { let p = desktop::projects(); if p.is_empty() { diff --git a/tests/import_cli.rs b/tests/import_cli.rs index 173aa46..970c3f9 100644 --- a/tests/import_cli.rs +++ b/tests/import_cli.rs @@ -180,7 +180,7 @@ fn imports_three_provider_layouts_and_preserves_original_archives() { assert!(source.is_file()); assert_eq!(h.footprints().len(), 1); let lib = h.library(); - assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package"); + assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package"); assert!(lib.to_string().contains("µ Ω (quoted)")); assert_eq!(lib.field("version").unwrap().value(), Some("20231120")); } @@ -208,7 +208,11 @@ fn exact_cross_provider_reuse_and_repeat_import_are_idempotent() { assert_eq!(h.footprints().len(), 1); let lib = h.library(); assert_eq!(symbols(&lib).len(), 2); - assert!(symbols(&lib).iter().all(|s| link(s) == "UniLoader:PkgA")); + assert!( + symbols(&lib) + .iter() + .all(|s| link(s) == "UniLoader_Local:PkgA") + ); ok(&h.import(&a, &[])); ok(&h.import(&b, &["--force"])); assert_eq!(symbols(&h.library()).len(), 2); @@ -257,7 +261,7 @@ fn uncertain_footprints_stay_pending_then_retry_with_separate_policy() { .into_iter() .find(|s| s.value() == Some("Second")) .unwrap(); - assert!(link(second).starts_with("UniLoader:Package_")); + assert!(link(second).starts_with("UniLoader_Local:Package_")); } #[test] @@ -361,8 +365,8 @@ fn multi_symbol_footprint_bindings_do_not_use_first_footprint_for_all() { ok(&h.import(&source, &[])); let lib = h.library(); for (name, expected) in [ - ("First", "UniLoader:PackageA"), - ("Second", "UniLoader:PackageB"), + ("First", "UniLoader_Local:PackageA"), + ("Second", "UniLoader_Local:PackageB"), ] { let s = symbols(&lib) .into_iter() @@ -485,7 +489,10 @@ fn portable_project_copies_exact_global_footprint_and_model() { .unwrap() .collect(); assert_eq!(copied_models.len(), 1); - assert_eq!(link(symbols(&h.library())[0]), "UniLoader:GlobalPackage"); + assert_eq!( + link(symbols(&h.library())[0]), + "UniLoader_Local:GlobalPackage" + ); } fn records(h: &Harness) -> Vec { @@ -516,11 +523,14 @@ fn symbol_only_global_dependency_is_copied_into_portable_project() { ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"])); let source = h.bundle( "symbol-only", - &[("b.kicad_sym", symbol("LocalPart", "UniLoader:Package"))], + &[( + "b.kicad_sym", + symbol("LocalPart", "UniLoader_Global:Package"), + )], ); ok(&h.import(&source, &[])); assert_eq!(h.footprints().len(), 1); - assert_eq!(link(symbols(&h.library())[0]), "UniLoader:Package"); + assert_eq!(link(symbols(&h.library())[0]), "UniLoader_Local:Package"); assert!( fs::read_to_string(&h.footprints()[0]) .unwrap() @@ -603,7 +613,7 @@ fn project_library_and_model_paths_resolve_after_directory_relocation() { assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL"); let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap()) .unwrap(); - assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package"); + assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package"); } #[test] @@ -620,7 +630,7 @@ fn disabled_footprint_import_reuses_registered_assets_and_rejects_dangling_links append_config(&h, "[options]\nfootprints = false"); let valid = h.bundle( "valid", - &[("b.kicad_sym", symbol("Second", "UniLoader:Package"))], + &[("b.kicad_sym", symbol("Second", "UniLoader_Local:Package"))], ); ok(&h.import(&valid, &[])); let invalid = h.bundle( @@ -774,7 +784,7 @@ fn disabled_footprints_cannot_reference_an_unregistered_managed_directory() { ) .unwrap(); assert!(table.children().iter().any(|n| n.head() == Some("lib") - && n.field("name").and_then(Node::value) == Some("UniLoader"))); + && n.field("name").and_then(Node::value) == Some("UniLoader_Local"))); } else { assert!(output(&result).contains("pending")); assert!( @@ -816,3 +826,146 @@ fn qualified_existing_reference_is_not_silently_retargeted_by_bundle_basename() assert!(output(&result).contains("pending")); } } + +#[test] +fn doctor_migrates_both_scopes_and_project_references_with_backups() { + let h = Harness::new("separate"); + let source = h.bundle( + "source", + &[ + ("a.kicad_sym", symbol("Part", "Package")), + ("Package.kicad_mod", footprint("Package", 1.0, "")), + ], + ); + ok(&h.cmd(&["import", source.to_str().unwrap(), "--global"])); + ok(&h.import(&source, &[])); + // Recreate v0.2.1 registrations and stored footprint bindings. + let paths = [ + "kicad-config/sym-lib-table", + "kicad-config/fp-lib-table", + "global/UniLoader.kicad_sym", + "project/sym-lib-table", + "project/fp-lib-table", + "project/libraries/UniLoader.kicad_sym", + ]; + for path in paths { + let path = h.root.join(path); + let text = fs::read_to_string(&path) + .unwrap() + .replace("UniLoader_Global", "UniLoader") + .replace("UniLoader_Local", "UniLoader"); + fs::write(path, text).unwrap(); + } + let sch = h.root.join("project/board.kicad_sch"); + fs::write(&sch, r#"(kicad_sch (lib_symbols (symbol "UniLoader:Part" (property "Footprint" "UniLoader:Package"))) (symbol (lib_id "UniLoader:Part") (property "Footprint" "UniLoader:Package") (property "Description" "UniLoader:keep")))"#).unwrap(); + let pcb = h.root.join("project/board.kicad_pcb"); + fs::write( + &pcb, + r#"(kicad_pcb (footprint "UniLoader:Package" (property "Value" "keep")))"#, + ) + .unwrap(); + let before = fs::read(&sch).unwrap(); + let args = ["doctor", "--project", h.project.to_str().unwrap()]; + let preview = h.cmd(&args); + ok(&preview); + assert!(output(&preview).contains("Would repair")); + assert_eq!(fs::read(&sch).unwrap(), before); + // Imports must not silently retain a legacy registration. + let blocked = h.import(&source, &["--force"]); + assert!(!blocked.status.success()); + assert!(output(&blocked).contains("doctor")); + let fixed = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]); + ok(&fixed); + assert!(output(&fixed).contains("Backup:")); + assert!( + walkdir::WalkDir::new(h.root.join("xdg-data/uniloader/backups")) + .into_iter() + .filter_map(Result::ok) + .any(|entry| entry.file_type().is_file() && fs::read(entry.path()).unwrap() == before) + ); + assert!( + fs::read_to_string(&sch) + .unwrap() + .contains("UniLoader_Local:Part") + ); + assert!(fs::read_to_string(&sch).unwrap().contains("UniLoader:keep")); + assert!( + fs::read_to_string(&pcb) + .unwrap() + .contains("UniLoader_Local:Package") + ); + assert_eq!(link(symbols(&h.library())[0]), "UniLoader_Local:Package"); + let global = + parse(&fs::read_to_string(h.root.join("global/UniLoader.kicad_sym")).unwrap()).unwrap(); + assert_eq!(link(symbols(&global)[0]), "UniLoader_Global:Package"); + let again = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]); + ok(&again); + assert!(output(&again).contains("No nickname repairs needed")); + ok(&h.import(&source, &["--force"])); +} + +#[test] +fn doctor_refuses_occupied_nickname_before_writing_anything() { + let h = Harness::new("separate"); + fs::create_dir_all(h.root.join("kicad-config")).unwrap(); + let table = h.root.join("kicad-config/fp-lib-table"); + let text = format!( + r#"(fp_lib_table (lib (name "UniLoader") (type "KiCad") (uri "{}")) (lib (name "UniLoader_Global") (type "KiCad") (uri "/unrelated.pretty") (options "keep")))"#, + h.root.join("global/UniLoader.pretty").display() + ); + fs::write(&table, &text).unwrap(); + let result = h.cmd(&["doctor", "--global", "--fix"]); + assert!(!result.status.success()); + assert!(output(&result).contains("occupied")); + assert_eq!(fs::read_to_string(table).unwrap(), text); +} + +#[test] +fn doctor_updates_global_references_in_projects_after_global_migration() { + let h = Harness::new("separate"); + let source = h.bundle( + "source", + &[ + ("a.kicad_sym", symbol("Part", "Package")), + ("Package.kicad_mod", footprint("Package", 1.0, "")), + ], + ); + ok(&h.cmd(&["import", source.to_str().unwrap(), "--global"])); + for path in [ + "kicad-config/fp-lib-table", + "kicad-config/sym-lib-table", + "global/UniLoader.kicad_sym", + ] { + let path = h.root.join(path); + fs::write( + &path, + fs::read_to_string(&path) + .unwrap() + .replace("UniLoader_Global", "UniLoader_2"), + ) + .unwrap(); + } + ok(&h.cmd(&["doctor", "--global", "--fix"])); + let sch = h.root.join("project/board.kicad_sch"); + let text = r#"(kicad_sch (symbol (lib_id "UniLoader_2:Part") (property "Footprint" "UniLoader_2:Package")))"#; + fs::write(&sch, text).unwrap(); + ok(&h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"])); + assert!( + fs::read_to_string(&sch) + .unwrap() + .contains("UniLoader_Global:Part") + ); + // An unrelated local registration must retain its existing resolution. + fs::write(&sch, text).unwrap(); + fs::write( + h.root.join("project/sym-lib-table"), + r#"(sym_lib_table (lib (name "UniLoader_2") (type "KiCad") (uri "/unrelated.kicad_sym")))"#, + ) + .unwrap(); + ok(&h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"])); + assert!( + fs::read_to_string(&sch) + .unwrap() + .contains("\"UniLoader_2:Part\"") + ); +}