Release v0.2.2: merge dev into main

This commit is contained in:
h3cx committed 2026-10-18 14:27:22 +02:00
commit 3372e08d5d
11 files changed
+519 -18

No files matched your search

+10
View File
@@ -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.
Generated
+1 -1
View File
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uniloader"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "uniloader"
version = "0.2.1"
version = "0.2.2"
edition = "2024"
rust-version = "1.95"
+17 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+46 -1
View File
@@ -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
+10
View File
@@ -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();
+252
View File
@@ -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<String, String>;
fn table(dest: &Destination, kind: &str, vars: &Renames, plan: &mut Plan) -> Result<Renames> {
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<Renames> {
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(())
}
+2 -2
View File
@@ -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)
+15
View File
@@ -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<PathBuf>,
#[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() {
+164 -11
View File
@@ -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<serde_json::Value> {
@@ -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\"")
);
}