6 Commits
13 changed files with 1373 additions and 23 deletions

No files matched your search

+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## 0.2.3
- Expand doctor from nickname migration to managed library health checks and repair.
- Recreate missing global/project library roots, footprint/model directories, valid empty symbol libraries, tables and managed registrations.
- Repair stale owned registration paths, wrong KiCad types and disabled owned entries, while preserving unrelated registrations.
- Validate managed files, check registered paths and project/component references, and repair model paths using conservative import matching.
- Report unresolved missing components, models and search paths with a nonzero exit status after safe repairs; never invent deleted component content or overwrite corrupt libraries.
- Preserve preview, backups, nickname migration history and idempotent repairs.
Validation: 67 automated tests, Clippy, formatting, and release build passed. Live KiCad integration requires manual verification.
## 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.
- Ignore read/open access events before queuing watcher work, while retaining write-close events and filesystem overflow recovery.
- Batch queued filesystem events into one scan while preserving periodic reconciliation and partial-download retries.
- Add a Linux idle CPU regression check alongside the download, rename, duplicate and restart integration checks.
Validation: 59 automated tests, Clippy, formatting, and release build passed. Local Linux idle profiling confirmed the watcher no longer continuously rescans its download directory.
## 0.2.0 ## 0.2.0
- Import components into global or portable project libraries, creating and registering libraries when needed. - Import components into global or portable project libraries, creating and registering libraries when needed.
Generated
+1 -1
View File
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "uniloader" name = "uniloader"
version = "0.2.0" version = "0.2.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "uniloader" name = "uniloader"
version = "0.2.0" version = "0.2.3"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
+22 -1
View File
@@ -8,7 +8,7 @@ folder throughout your graphical login session.
It supports symbols, footprints and referenced STEP/VRML models, with: 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. - A destination dialog offering detected KiCad project suggestions, Global and Browse.
- Conservative exact footprint reuse and comparison dialogs for uncertain matches. - Conservative exact footprint reuse and comparison dialogs for uncertain matches.
- Broken model-path repair, explicit ambiguous model choices and missing-model warnings. - Broken model-path repair, explicit ambiguous model choices and missing-model warnings.
@@ -59,6 +59,27 @@ No-argument startup also runs the watcher. `--noninteractive` leaves unresolved
choices pending; `--force` on manual imports deliberately revisits a completed choices pending; `--force` on manual imports deliberately revisits a completed
source. Check an imported component in KiCad before using it in a board. source. Check an imported component in KiCad before using it in a board.
## Check and repair libraries (v0.2.3)
Close KiCad and stop the watcher, then preview and apply repairs:
```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. Doctor recreates missing managed
folders, empty symbol libraries and table entries, repairs owned registration
paths and nicknames, and checks component and model references. It repairs model
paths when a unique matching file is available. Deleted components require
restoring or reimporting with `--force`; doctor reports unresolved issues and
exits nonzero, including after applying safe repairs. Changed files are backed
up; see the [setup guide](docs/SETUP.md#library-health-and-repair-v023).
Reopen KiCad afterwards.
## Scope ## Scope
This is practical Linux development tooling. Project discovery currently uses This is practical Linux development tooling. Project discovery currently uses
+1 -1
View File
@@ -129,7 +129,7 @@ project/
models/ 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. Preserve unrelated entries and never silently repoint an occupied nickname.
- Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute - Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute
paths. Let users select the KiCad configuration/version directory if ambiguous. paths. Let users select the KiCad configuration/version directory if ambiguous.
+84 -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`, Project imports create `libraries/UniLoader.kicad_sym`,
`libraries/UniLoader.pretty/`, and model storage as needed, and merge registration `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. 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 and available referenced models into the project. Global imports use the
configured library root and register entries in the selected KiCad configuration. configured library root and register entries in the selected KiCad configuration.
Existing unrelated table entries are preserved. Existing schematic and PCB Existing unrelated table entries are preserved. Existing schematic and PCB
@@ -170,6 +172,87 @@ assets. Source archives are preserved by default. Incomplete or invalid ZIPs
are retried for the configured period, then remain recoverable rather than are retried for the configured period, then remain recoverable rather than
stopping subsequent downloads. stopping subsequent downloads.
## Library health and repair (v0.2.3)
Doctor includes the v0.2.2 nickname migration and now checks and rebuilds managed
library infrastructure, including when the global library root has been deleted.
Before applying repairs, 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 directories and files it would create or repair.
`--global` checks the configured global tables, managed symbol library and managed
footprints/models. `--project` checks those plus the selected project's libraries,
tables, 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 checked separately. Set
`libraries.kicad_config` to the correct version directory when needed: doctor
requires an explicit or uniquely detected KiCad configuration for both scopes.
Doctor recreates missing managed roots, `UniLoader.pretty/` and `models/`
directories, valid empty `UniLoader.kicad_sym` files, and missing symbol/footprint
tables or managed entries. Existing malformed managed libraries, tables, duplicate
aliases, and wrong file/directory types block the repair plan before it is applied;
corrupt content is not replaced with an empty library.
Doctor identifies managed registrations by their resolved library paths. Stale
paths can also be repaired when the registration carries the UniLoader nickname
and `Imported by UniLoader` description. It points owned entries at the configured
global root or portable project paths, restores the KiCad type, and enables owned
entries that were disabled. It preserves unrelated registrations and metadata,
and refuses an occupied target nickname. An unrelated entry using our nickname
without ownership evidence is reported for manual resolution.
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 stay the same. Managed
footprints are parsed and their model paths repaired using the same conservative
candidate matching as import: a unique match or identical-content candidates
can be copied/reused in managed model storage. Missing or ambiguous models remain
unchanged and are reported; transforms and embedded model references are kept.
Changed S-expression files are reserialized, so whitespace may change.
Doctor checks active KiCad registrations for unresolved path variables, missing
paths, wrong types and readability; validates managed symbol/footprint files; and
checks managed symbol bindings and project symbol/footprint references against
the effective global/project registrations and actual components. Missing
configured footprint/model search roots are reported. Third-party registrations
are checked but not repointed, recreated or removed. Disabled third-party entries
and non-KiCad plugins are excluded from reference resolution. It does not audit
the watcher service or recover component content from download history.
Recreating an empty library restores its structure, not deleted symbols,
footprints or models. Restore those from your backups or deliberately reimport
the original component with `uniloader import SOURCE --global --force` (or
`--project FILE --force`). A normal import may skip a previously completed source.
Doctor reports remaining missing components and models rather than fabricating
placeholders. It exits nonzero for unresolved findings, including when `--fix`
has successfully applied the safe portion of the plan. Preview also exits nonzero
for unresolved findings. No unresolved findings means only that the checked
scope passed, not that missing historical components were recovered.
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 ## 3D models and broken paths
STEP (`.step`/`.stp`) and VRML (`.wrl`) models are copied when referenced by an STEP (`.step`/`.stp`) and VRML (`.wrl`) models are copied when referenced by an
+10
View File
@@ -66,6 +66,13 @@ impl Destination {
} }
Ok(result) Ok(result)
} }
pub fn nickname(&self) -> &'static str {
if self.project.is_some() {
"UniLoader_Local"
} else {
"UniLoader_Global"
}
}
fn uri(&self, path: &Path) -> String { fn uri(&self, path: &Path) -> String {
if let Some(project) = &self.project if let Some(project) = &self.project
&& let Ok(relative) = path.strip_prefix(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)) .then(|| field_value(entry, "name").map(str::to_owned))
.flatten() .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)); return Ok((format!("{table}\n"), name));
} }
let mut nickname = requested_nickname.to_owned(); let mut nickname = requested_nickname.to_owned();
+350
View File
@@ -0,0 +1,350 @@
//! Backed-up repair and health checks for managed KiCad libraries.
mod checks;
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);
let original = if path.exists() {
fs::read_to_string(&path)?
} else {
format!("({kind} (version 7))")
};
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());
}
}
let mut managed = false;
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();
let uri = entry
.field("uri")
.and_then(Node::value)
.context("Library entry missing URI")?;
let resolved = destination::resolve_variables(uri, dest.project.as_deref(), vars)
.ok()
.map(std::path::PathBuf::from)
.map(|p| {
if p.is_absolute() {
p
} else {
dest.table_dir.join(p)
}
});
let matches = resolved.as_ref().is_some_and(|p| {
p == target || (target.exists() && p.canonicalize().ok() == target.canonicalize().ok())
});
// A stale path can be rebuilt only with explicit UniLoader ownership.
let owned = entry.field("descr").and_then(Node::value) == Some("Imported by UniLoader")
&& (name == "UniLoader" || name.starts_with("UniLoader_"));
if !matches && !owned {
continue;
}
if managed {
bail!(
"Multiple managed registrations in {}; resolve duplicate aliases before repair",
path.display()
);
}
managed = true;
let new_uri = if kind == "sym_lib_table" {
dest.symbol_uri()
} else {
dest.footprint_uri()
};
entry.field_mut("uri").unwrap().children_mut()[1] = Node::string(new_uri);
if let Some(field) = entry.field_mut("type") {
*field = parse("(type \"KiCad\")")?;
} else {
entry.children_mut().push(parse("(type \"KiCad\")")?);
}
entry
.children_mut()
.retain(|n| n.head() != Some("disabled"));
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 !managed {
if occupied.contains_key(dest.nickname()) {
bail!(
"Nickname {} is occupied in {}; refusing to repoint an unrelated library",
dest.nickname(),
path.display()
);
}
let uri = if kind == "sym_lib_table" {
dest.symbol_uri()
} else {
dest.footprint_uri()
};
node = parse(
&destination::register_table(Some(&node.to_string()), kind, dest.nickname(), &uri)?.0,
)?;
changes.extend(
history
.iter()
.filter(|(old, new)| {
new.as_str() == dest.nickname() && !occupied.contains_key(*old)
})
.map(|(old, new)| (old.clone(), new.clone())),
);
}
if history != original_history {
plan.put(history_path, serde_json::to_vec_pretty(&history)?)?;
}
if !path.exists() || 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<()> {
let Some(original) = checks::text(path, plan)? else {
return Ok(());
};
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.writes
.insert(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, true)?;
let vars = importer::variables(cfg, &kicad);
let global = Destination::new(&cfg.libraries.path, None, &kicad)?;
let mut plan = Plan::default();
let mut issues = Vec::new();
checks::structure(&global, &mut plan)?;
let sym = table(&global, "sym_lib_table", &vars, &mut plan)?;
let fp = table(&global, "fp_lib_table", &vars, &mut plan)?;
patch(&global.symbol_path, &sym, &fp, &mut plan)?;
checks::footprints(cfg, &global, &vars, &mut plan, &mut issues)?;
let mut local_destination = None;
if let Some(project) = project {
let local = Destination::new(&cfg.libraries.path, Some(project), &kicad)?;
checks::structure(&local, &mut plan)?;
let local_sym = table(&local, "sym_lib_table", &vars, &mut plan)?;
let local_fp = table(&local, "fp_lib_table", &vars, &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)?;
checks::footprints(cfg, &local, &vars, &mut plan, &mut issues)?;
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)?;
}
}
local_destination = Some(local);
}
checks::audit(
&global,
local_destination.as_ref(),
&vars,
&plan,
&mut issues,
)?;
for (label, paths) in [
("footprint search", &cfg.libraries.search_paths),
("model search", &cfg.libraries.model_search_paths),
] {
for path in paths {
if !path.exists() {
issues.push(format!(
"Missing configured {label} path: {}",
path.display()
));
}
}
}
for directory in &plan.directories {
println!(
"{} directory: {}",
if fix { "Create" } else { "Would create" },
directory.display()
);
}
for message in &plan.messages {
println!("{message}");
}
for path in plan.writes.keys() {
println!(
"{}: {}",
if fix { "Repair" } else { "Would repair" },
path.display()
);
}
issues.sort();
issues.dedup();
if plan.writes.is_empty() && plan.directories.is_empty() {
println!("No automatic repairs needed.");
} else if fix {
let mut record = Record::new(global.root.clone(), crate::storage::hash(b"doctor-health"));
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 = format!(
"Doctor repairs completed; {} unresolved issue(s)",
issues.len()
);
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."
);
for issue in &issues {
eprintln!("Unresolved: {issue}");
}
if !issues.is_empty() {
bail!(
"Doctor found {} unresolved issue(s); safe repairs {}. Restore deleted components from backups or reimport with --force where needed",
issues.len(),
if fix { "applied" } else { "previewed only" }
);
}
println!("No unresolved issues found in the checked scope.");
Ok(())
}
+421
View File
@@ -0,0 +1,421 @@
//! Inspect the staged repair state, so preview and fix run the same checks.
use crate::{
config::Config,
destination::{self, Destination},
models,
sexpr::{Node, parse},
storage::Plan,
};
use anyhow::{Context, Result, bail};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
type Registry = BTreeMap<String, PathBuf>;
type SymbolCache = BTreeMap<PathBuf, std::collections::BTreeSet<String>>;
pub(super) fn text(path: &Path, plan: &Plan) -> Result<Option<String>> {
if let Some(bytes) = plan.writes.get(path) {
return Ok(Some(String::from_utf8(bytes.clone())?));
}
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Read {}", path.display())),
}
}
pub(super) fn structure(dest: &Destination, plan: &mut Plan) -> Result<()> {
for path in [
&dest.root,
&dest.table_dir,
&dest.pretty_dir,
&dest.models_dir,
] {
for ancestor in path.ancestors() {
match ancestor.symlink_metadata() {
Ok(_) if !ancestor.is_dir() => bail!(
"Expected a managed directory or directory ancestor (check broken symlinks): {}",
ancestor.display()
),
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(e)
.with_context(|| format!("Inspect directory {}", ancestor.display()));
}
_ => {}
}
}
if !path.is_dir() {
plan.directories.push(path.clone());
} else {
fs::read_dir(path).with_context(|| format!("Read directory {}", path.display()))?;
}
}
if dest.symbol_path.symlink_metadata().is_ok() && !dest.symbol_path.is_file() {
bail!(
"Expected a managed symbol library file: {}",
dest.symbol_path.display()
);
}
if !dest.symbol_path.exists() {
plan.put(
dest.symbol_path.clone(),
b"(kicad_symbol_lib (version 20211014) (generator \"uniloader\"))\n".to_vec(),
)?;
plan.messages.push(format!("Recreate empty symbol library: {}. Deleted symbols require restore or reimport --force.", dest.symbol_path.display()));
} else {
let node = parse(&fs::read_to_string(&dest.symbol_path)?)?;
if node.head() != Some("kicad_symbol_lib")
|| node
.field("version")
.and_then(Node::value)
.and_then(|v| v.parse::<u64>().ok())
.is_none()
{
bail!(
"Invalid managed symbol library {}; restore it from backup (not overwritten)",
dest.symbol_path.display()
);
}
}
Ok(())
}
pub(super) fn footprints(
cfg: &Config,
dest: &Destination,
vars: &RegistryStrings,
plan: &mut Plan,
issues: &mut Vec<String>,
) -> Result<()> {
if !dest.pretty_dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(&dest.pretty_dir)? {
let path = entry?.path();
if !path.extension().is_some_and(|e| e == "kicad_mod") {
continue;
}
let original = text(&path, plan)?.context("Missing footprint")?;
let mut node = parse(&original).with_context(|| format!("Parse {}", path.display()))?;
if !matches!(node.head(), Some("footprint" | "module")) {
bail!("Invalid managed footprint {}", path.display());
}
let before = node.clone();
let repaired = models::repair_models_with_choices(
&mut node,
&path,
&dest.root,
dest,
&cfg.libraries.model_search_paths,
vars,
true,
&BTreeMap::new(),
);
match repaired {
Ok(models) => {
for message in models
.messages
.into_iter()
.filter(|m| m.contains("unresolved"))
{
issues.push(format!("{}: {message}", path.display()));
}
for (target, bytes) in models.copies {
plan.put(target, bytes)?;
}
if node != before {
plan.writes.insert(path, format!("{node}\n").into_bytes());
}
}
Err(error) => issues.push(format!("{}: {error:#}", path.display())),
}
}
Ok(())
}
type RegistryStrings = BTreeMap<String, String>;
fn registrations(
dest: &Destination,
file: &str,
kind: &str,
vars: &RegistryStrings,
plan: &Plan,
effective: &mut Registry,
issues: &mut Vec<String>,
) -> Result<()> {
let path = dest.table_dir.join(file);
let Some(raw) = text(&path, plan)? else {
return Ok(());
};
let node = parse(&raw)?;
if node.head() != Some(kind) {
bail!("Invalid library table {}", path.display());
}
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")?;
effective.remove(name);
if entry.field("disabled").is_some() {
continue;
}
if entry.field("type").and_then(Node::value) != Some("KiCad") {
continue;
}
let uri = entry
.field("uri")
.and_then(Node::value)
.context("Library entry missing URI")?;
match destination::resolve_variables(uri, dest.project.as_deref(), vars) {
Ok(resolved) => {
let resolved = PathBuf::from(resolved);
let resolved = if resolved.is_absolute() {
resolved
} else {
dest.table_dir.join(resolved)
};
let valid = if kind == "sym_lib_table" {
resolved.is_file() || plan.writes.contains_key(&resolved)
} else {
resolved.is_dir() || plan.directories.contains(&resolved)
};
if !valid {
issues.push(format!("Registered library {name} in {} has missing/wrong-type path: {} (unmanaged paths are not created)", path.display(), resolved.display()));
} else if kind == "sym_lib_table" && resolved.is_file() {
fs::File::open(&resolved)
.with_context(|| format!("Read {}", resolved.display()))?;
} else if kind == "fp_lib_table" && resolved.is_dir() {
fs::read_dir(&resolved)
.with_context(|| format!("Read {}", resolved.display()))?;
}
effective.insert(name.into(), resolved);
}
Err(e) => issues.push(format!("Library {name} in {}: {e:#}", path.display())),
}
}
Ok(())
}
fn reference(
value: &str,
symbols: bool,
file: &Path,
registry: &Registry,
plan: &Plan,
issues: &mut Vec<String>,
cache: &mut SymbolCache,
) -> Result<()> {
if value.is_empty() {
return Ok(());
}
let Some((nickname, item)) = value.split_once(':') else {
issues.push(format!(
"{}: unqualified library reference {value}",
file.display()
));
return Ok(());
};
let Some(library) = registry.get(nickname) else {
issues.push(format!(
"{}: reference {value} has no enabled KiCad library registration",
file.display()
));
return Ok(());
};
if item.is_empty() || item.contains(['/', '\\', ':']) || item == "." || item == ".." {
issues.push(format!(
"{}: invalid library reference {value}",
file.display()
));
return Ok(());
}
let found = if symbols {
if !cache.contains_key(library) {
let mut names = std::collections::BTreeSet::new();
if let Some(raw) = text(library, plan)? {
match parse(&raw) {
Ok(lib) if lib.head() == Some("kicad_symbol_lib") => {
names.extend(
lib.children()
.iter()
.filter(|n| n.head() == Some("symbol"))
.filter_map(Node::value)
.map(str::to_owned),
);
}
_ => issues.push(format!(
"Invalid registered symbol library {}",
library.display()
)),
}
}
cache.insert(library.clone(), names);
}
cache[library].contains(item)
} else {
let path = library.join(format!("{item}.kicad_mod"));
path.is_file() || plan.writes.contains_key(&path)
};
if !found {
issues.push(format!(
"{}: missing {} {value}; restore or reimport the component",
file.display(),
if symbols { "symbol" } else { "footprint" }
));
}
Ok(())
}
fn links(
node: &Node,
file: &Path,
sym: &Registry,
fp: &Registry,
plan: &Plan,
issues: &mut Vec<String>,
cache: &mut SymbolCache,
) -> Result<()> {
match node.head() {
Some("lib_id") => {
if let Some(value) = node.value() {
reference(value, true, file, sym, plan, issues, cache)?;
}
}
Some("footprint") if node.value().is_some_and(|s| s.contains(':')) => {
reference(node.value().unwrap(), false, file, fp, plan, issues, cache)?;
}
Some("property") if node.value() == Some("Footprint") => {
if let Some(value) = node.children().get(2).and_then(Node::atom) {
reference(value, false, file, fp, plan, issues, cache)?;
}
}
_ => {}
}
for child in node.children() {
links(child, file, sym, fp, plan, issues, cache)?;
}
Ok(())
}
fn inspect(
path: &Path,
sym: &Registry,
fp: &Registry,
plan: &Plan,
issues: &mut Vec<String>,
cache: &mut SymbolCache,
) -> Result<()> {
if let Some(raw) = text(path, plan)? {
let node = parse(&raw).with_context(|| format!("Parse {}", path.display()))?;
let expected = match path.extension().and_then(|e| e.to_str()) {
Some("kicad_sym") => Some("kicad_symbol_lib"),
Some("kicad_sch") => Some("kicad_sch"),
Some("kicad_pcb") => Some("kicad_pcb"),
_ => None,
};
if let Some(expected) = expected
&& node.head() != Some(expected)
{
bail!(
"Invalid KiCad file {}: expected {}",
path.display(),
expected
);
}
links(&node, path, sym, fp, plan, issues, cache)?;
if node.head() == Some("kicad_symbol_lib") {
let mut names = std::collections::BTreeSet::new();
for symbol in node
.children()
.iter()
.filter(|n| n.head() == Some("symbol"))
{
let name = symbol.value().context("Symbol missing name")?;
if !names.insert(name) {
issues.push(format!("{}: duplicate symbol {name}", path.display()));
}
}
for symbol in node
.children()
.iter()
.filter(|n| n.head() == Some("symbol"))
{
if let Some(parent) = symbol.field("extends").and_then(Node::value)
&& !names.contains(parent)
{
issues.push(format!(
"{}: symbol {} extends missing parent {parent}",
path.display(),
symbol.value().unwrap_or("?")
));
}
}
}
}
Ok(())
}
pub(super) fn audit(
global: &Destination,
local: Option<&Destination>,
vars: &RegistryStrings,
plan: &Plan,
issues: &mut Vec<String>,
) -> Result<()> {
let mut cache = SymbolCache::new();
let mut sym = Registry::new();
let mut fp = Registry::new();
registrations(
global,
"sym-lib-table",
"sym_lib_table",
vars,
plan,
&mut sym,
issues,
)?;
registrations(
global,
"fp-lib-table",
"fp_lib_table",
vars,
plan,
&mut fp,
issues,
)?;
inspect(&global.symbol_path, &sym, &fp, plan, issues, &mut cache)?;
if let Some(local) = local {
registrations(
local,
"sym-lib-table",
"sym_lib_table",
vars,
plan,
&mut sym,
issues,
)?;
registrations(
local,
"fp-lib-table",
"fp_lib_table",
vars,
plan,
&mut fp,
issues,
)?;
inspect(&local.symbol_path, &sym, &fp, plan, issues, &mut cache)?;
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")
{
inspect(entry.path(), &sym, &fp, plan, issues, &mut cache)?;
}
}
}
Ok(())
}
+3 -3
View File
@@ -156,7 +156,7 @@ fn target(
} }
} }
} }
fn variables(cfg: &Config, kicad: &Path) -> BTreeMap<String, String> { pub(crate) fn variables(cfg: &Config, kicad: &Path) -> BTreeMap<String, String> {
let mut vars = cfg.libraries.variables.clone(); let mut vars = cfg.libraries.variables.clone();
if let Some(version) = kicad if let Some(version) = kicad
.file_name() .file_name()
@@ -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( let (fp_table, fp_nickname) = destination::register_table(
text(&dest.table_dir.join("fp-lib-table"))?.as_deref(), text(&dest.table_dir.join("fp-lib-table"))?.as_deref(),
"fp_lib_table", "fp_lib_table",
"UniLoader", dest.nickname(),
&dest.footprint_uri(), &dest.footprint_uri(),
)?; )?;
let (sym_table, sym_nickname) = destination::register_table( let (sym_table, sym_nickname) = destination::register_table(
text(&dest.table_dir.join("sym-lib-table"))?.as_deref(), text(&dest.table_dir.join("sym-lib-table"))?.as_deref(),
"sym_lib_table", "sym_lib_table",
"UniLoader", dest.nickname(),
&dest.symbol_uri(), &dest.symbol_uri(),
)?; )?;
let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname) let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname)
+79 -4
View File
@@ -3,6 +3,7 @@
mod config; mod config;
mod desktop; mod desktop;
mod destination; mod destination;
mod doctor;
mod gui; mod gui;
mod importer; mod importer;
mod matching; mod matching;
@@ -17,7 +18,10 @@ use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use config::Config; use config::Config;
use importer::{Request, Session}; use importer::{Request, Session};
use notify::{RecursiveMode, Watcher}; use notify::{
Event, EventKind, RecursiveMode, Watcher,
event::{AccessKind, AccessMode},
};
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
fs, fs,
@@ -71,6 +75,15 @@ enum Commands {
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
}, },
/// Check libraries and paths; --fix recreates missing managed libraries and repairs 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. /// Show project suggestions from running KiCad processes.
Discover, Discover,
} }
@@ -141,10 +154,25 @@ fn complete_zip(p: &Path) -> Result<bool> {
} }
Ok(supported) Ok(supported)
} }
fn event_requires_scan(event: &Event) -> bool {
// Reading a watched directory emits OPEN events on Linux, including from
// our own scans. Only a writer closing a file can make access relevant.
event.need_rescan()
|| !matches!(event.kind, EventKind::Access(_))
|| matches!(
event.kind,
EventKind::Access(AccessKind::Close(AccessMode::Write))
)
}
fn watch(cfg: Config, noninteractive: bool) -> Result<()> { fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
let _watch_lock = storage::Lock::watcher()?; let _watch_lock = storage::Lock::watcher()?;
let (events_tx, events_rx) = mpsc::channel(); let (events_tx, events_rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(events_tx)?; let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
// Filter before queuing so reads cannot keep the receive loop awake.
if event.as_ref().map_or(true, event_requires_scan) {
let _ = events_tx.send(event);
}
})?;
for dir in &cfg.watch.directories { for dir in &cfg.watch.directories {
if !dir.is_dir() { if !dir.is_dir() {
bail!( bail!(
@@ -223,8 +251,19 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
let mut last_scan = Instant::now(); let mut last_scan = Instant::now();
loop { loop {
match events_rx.recv_timeout(Duration::from_millis(500)) { match events_rx.recv_timeout(Duration::from_millis(500)) {
Ok(Ok(_)) => scan(&mut waiting, &seen), Ok(event) => {
Ok(Err(e)) => eprintln!("watch error: {e}"), let mut rescan = false;
// Bound draining so sustained downloads cannot starve retries.
for event in std::iter::once(event).chain(events_rx.try_iter().take(1023)) {
match event {
Ok(_) => rescan = true,
Err(e) => eprintln!("watch error: {e}"),
}
}
if rescan {
scan(&mut waiting, &seen);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"), Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"),
} }
@@ -290,6 +329,7 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
} }
} }
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
if matches!(cli.command, Some(Commands::Dialog)) { if matches!(cli.command, Some(Commands::Dialog)) {
@@ -362,6 +402,11 @@ fn main() -> Result<()> {
&mut Session::default(), &mut Session::default(),
) )
} }
Commands::Doctor {
global: _,
project,
fix,
} => doctor::run(&cfg, project.as_deref(), fix),
Commands::Discover => { Commands::Discover => {
let p = desktop::projects(); let p = desktop::projects();
if p.is_empty() { if p.is_empty() {
@@ -375,3 +420,33 @@ fn main() -> Result<()> {
} }
} }
} }
#[cfg(test)]
mod watch_tests {
use super::*;
use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind};
#[test]
fn scans_changes_and_write_closes_but_ignores_read_access() {
for kind in [
EventKind::Any,
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Any),
EventKind::Remove(RemoveKind::File),
EventKind::Access(AccessKind::Close(AccessMode::Write)),
] {
assert!(event_requires_scan(&Event::new(kind)), "{kind:?}");
}
for kind in [
AccessKind::Any,
AccessKind::Read,
AccessKind::Open(AccessMode::Read),
AccessKind::Open(AccessMode::Write),
AccessKind::Close(AccessMode::Read),
] {
let event = Event::new(EventKind::Access(kind));
assert!(!event_requires_scan(&event), "{kind:?}");
assert!(event_requires_scan(&event.set_flag(Flag::Rescan)));
}
}
}
+350 -11
View File
@@ -180,7 +180,7 @@ fn imports_three_provider_layouts_and_preserves_original_archives() {
assert!(source.is_file()); assert!(source.is_file());
assert_eq!(h.footprints().len(), 1); assert_eq!(h.footprints().len(), 1);
let lib = h.library(); 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!(lib.to_string().contains("µ Ω (quoted)"));
assert_eq!(lib.field("version").unwrap().value(), Some("20231120")); 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); assert_eq!(h.footprints().len(), 1);
let lib = h.library(); let lib = h.library();
assert_eq!(symbols(&lib).len(), 2); 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(&a, &[]));
ok(&h.import(&b, &["--force"])); ok(&h.import(&b, &["--force"]));
assert_eq!(symbols(&h.library()).len(), 2); assert_eq!(symbols(&h.library()).len(), 2);
@@ -257,7 +261,7 @@ fn uncertain_footprints_stay_pending_then_retry_with_separate_policy() {
.into_iter() .into_iter()
.find(|s| s.value() == Some("Second")) .find(|s| s.value() == Some("Second"))
.unwrap(); .unwrap();
assert!(link(second).starts_with("UniLoader:Package_")); assert!(link(second).starts_with("UniLoader_Local:Package_"));
} }
#[test] #[test]
@@ -361,8 +365,8 @@ fn multi_symbol_footprint_bindings_do_not_use_first_footprint_for_all() {
ok(&h.import(&source, &[])); ok(&h.import(&source, &[]));
let lib = h.library(); let lib = h.library();
for (name, expected) in [ for (name, expected) in [
("First", "UniLoader:PackageA"), ("First", "UniLoader_Local:PackageA"),
("Second", "UniLoader:PackageB"), ("Second", "UniLoader_Local:PackageB"),
] { ] {
let s = symbols(&lib) let s = symbols(&lib)
.into_iter() .into_iter()
@@ -485,7 +489,10 @@ fn portable_project_copies_exact_global_footprint_and_model() {
.unwrap() .unwrap()
.collect(); .collect();
assert_eq!(copied_models.len(), 1); 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> { 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"])); ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"]));
let source = h.bundle( let source = h.bundle(
"symbol-only", "symbol-only",
&[("b.kicad_sym", symbol("LocalPart", "UniLoader:Package"))], &[(
"b.kicad_sym",
symbol("LocalPart", "UniLoader_Global:Package"),
)],
); );
ok(&h.import(&source, &[])); ok(&h.import(&source, &[]));
assert_eq!(h.footprints().len(), 1); 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!( assert!(
fs::read_to_string(&h.footprints()[0]) fs::read_to_string(&h.footprints()[0])
.unwrap() .unwrap()
@@ -603,7 +613,7 @@ fn project_library_and_model_paths_resolve_after_directory_relocation() {
assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL"); assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL");
let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap()) let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap())
.unwrap(); .unwrap();
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package"); assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package");
} }
#[test] #[test]
@@ -620,7 +630,7 @@ fn disabled_footprint_import_reuses_registered_assets_and_rejects_dangling_links
append_config(&h, "[options]\nfootprints = false"); append_config(&h, "[options]\nfootprints = false");
let valid = h.bundle( let valid = h.bundle(
"valid", "valid",
&[("b.kicad_sym", symbol("Second", "UniLoader:Package"))], &[("b.kicad_sym", symbol("Second", "UniLoader_Local:Package"))],
); );
ok(&h.import(&valid, &[])); ok(&h.import(&valid, &[]));
let invalid = h.bundle( let invalid = h.bundle(
@@ -774,7 +784,7 @@ fn disabled_footprints_cannot_reference_an_unregistered_managed_directory() {
) )
.unwrap(); .unwrap();
assert!(table.children().iter().any(|n| n.head() == Some("lib") 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 { } else {
assert!(output(&result).contains("pending")); assert!(output(&result).contains("pending"));
assert!( assert!(
@@ -816,3 +826,332 @@ fn qualified_existing_reference_is_not_silently_retargeted_by_bundle_basename()
assert!(output(&result).contains("pending")); 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 automatic 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();
let checked = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]);
assert!(!checked.status.success());
assert!(output(&checked).contains("unmanaged paths are not created"));
assert!(
fs::read_to_string(&sch)
.unwrap()
.contains("\"UniLoader_2:Part\"")
);
}
#[test]
fn doctor_bootstraps_empty_libraries_and_preview_creates_nothing() {
let h = Harness::new("separate");
let preview = h.cmd(&["doctor", "--global"]);
ok(&preview);
assert!(output(&preview).contains("Would create directory"));
assert!(!h.root.join("global").exists());
assert!(!h.root.join("kicad-config").exists());
ok(&h.cmd(&["doctor", "--global", "--fix"]));
for path in [
"global",
"global/UniLoader.pretty",
"global/models",
"kicad-config",
] {
assert!(h.root.join(path).is_dir());
}
let lib =
parse(&fs::read_to_string(h.root.join("global/UniLoader.kicad_sym")).unwrap()).unwrap();
assert_eq!(lib.head(), Some("kicad_symbol_lib"));
assert!(symbols(&lib).is_empty());
for file in ["fp-lib-table", "sym-lib-table"] {
assert!(
fs::read_to_string(h.root.join("kicad-config").join(file))
.unwrap()
.contains("UniLoader_Global")
);
}
let again = h.cmd(&["doctor", "--global", "--fix"]);
ok(&again);
assert!(output(&again).contains("No automatic repairs needed"));
}
#[test]
fn doctor_rebuilds_deleted_global_and_project_libraries_and_reports_lost_parts() {
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, &[]));
let sch = h.root.join("project/board.kicad_sch");
fs::write(&sch, r#"(kicad_sch (symbol (lib_id "UniLoader_Global:Part") (property "Footprint" "UniLoader_Local:Package")))"#).unwrap();
fs::remove_dir_all(h.root.join("global")).unwrap();
fs::remove_dir_all(h.root.join("kicad-config")).unwrap();
fs::remove_dir_all(h.root.join("project/libraries")).unwrap();
fs::remove_file(h.root.join("project/fp-lib-table")).unwrap();
let preview = h.cmd(&["doctor", "--project", h.project.to_str().unwrap()]);
assert!(!preview.status.success());
assert!(output(&preview).contains("missing symbol UniLoader_Global:Part"));
assert!(!h.root.join("global").exists());
let fixed = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]);
assert!(!fixed.status.success());
assert!(output(&fixed).contains("safe repairs applied"));
for root in ["global", "project/libraries"] {
assert!(h.root.join(root).join("UniLoader.pretty").is_dir());
assert!(h.root.join(root).join("models").is_dir());
let lib =
parse(&fs::read_to_string(h.root.join(root).join("UniLoader.kicad_sym")).unwrap())
.unwrap();
assert_eq!(lib.head(), Some("kicad_symbol_lib"));
assert!(symbols(&lib).is_empty());
}
ok(&h.cmd(&["import", source.to_str().unwrap(), "--global", "--force"]));
ok(&h.import(&source, &["--force"]));
ok(&h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]));
}
#[test]
fn doctor_repairs_owned_stale_paths_disabled_entries_and_types() {
let h = Harness::new("separate");
ok(&h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]));
for dir in ["kicad-config", "project"] {
for file in ["sym-lib-table", "fp-lib-table"] {
let path = h.root.join(dir).join(file);
let mut table = parse(&fs::read_to_string(&path).unwrap()).unwrap();
let entry = table.field_mut("lib").unwrap();
entry.field_mut("uri").unwrap().children_mut()[1] =
Node::string("${DELETED_ROOT}/obsolete");
entry.field_mut("type").unwrap().children_mut()[1] = Node::string("Legacy");
entry.children_mut().push(parse("(disabled)").unwrap());
entry
.children_mut()
.push(parse("(future_metadata \"keep\")").unwrap());
fs::write(path, table.to_string()).unwrap();
}
}
let preview = h.cmd(&["doctor", "--project", h.project.to_str().unwrap()]);
ok(&preview);
assert!(
fs::read_to_string(h.root.join("project/fp-lib-table"))
.unwrap()
.contains("DELETED_ROOT")
);
ok(&h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]));
for dir in ["kicad-config", "project"] {
for file in ["sym-lib-table", "fp-lib-table"] {
let text = fs::read_to_string(h.root.join(dir).join(file)).unwrap();
assert!(!text.contains("DELETED_ROOT"));
assert!(!text.contains("disabled"));
assert!(text.contains("KiCad"));
assert!(text.contains("future_metadata"));
if dir == "project" {
assert!(text.contains("${KIPRJMOD}/libraries/"));
}
}
}
}
#[test]
fn doctor_detects_deleted_footprints_and_repairs_models_without_guessing() {
let h = Harness::new("separate");
ok(&h.cmd(&["doctor", "--global", "--fix"]));
let fp = h.root.join("global/UniLoader.pretty/Package.kicad_mod");
let original = footprint(
"Package",
1.0,
r#"(model "C:\obsolete\part.step" (offset (xyz 1 2 3)))"#,
);
fs::write(&fp, &original).unwrap();
fs::write(h.root.join("global/models/part.step"), "RECOVERED MODEL").unwrap();
ok(&h.cmd(&["doctor", "--global"]));
assert_eq!(fs::read_to_string(&fp).unwrap(), original);
ok(&h.cmd(&["doctor", "--global", "--fix"]));
let repaired = parse(&fs::read_to_string(&fp).unwrap()).unwrap();
assert_eq!(
repaired
.field("model")
.unwrap()
.field("offset")
.unwrap()
.field("xyz")
.unwrap()
.children()[1]
.atom(),
Some("1")
);
assert!(Path::new(repaired.field("model").unwrap().value().unwrap()).is_file());
// Competing model files are reported; no arbitrary candidate is selected.
fs::write(&fp, &original).unwrap();
fs::create_dir_all(h.root.join("global/other")).unwrap();
fs::write(h.root.join("global/other/part.step"), "DIFFERENT MODEL").unwrap();
let ambiguous = h.cmd(&["doctor", "--global", "--fix"]);
assert!(!ambiguous.status.success());
assert!(output(&ambiguous).contains("ambiguous model"));
assert_eq!(fs::read_to_string(&fp).unwrap(), original);
fs::remove_dir_all(h.root.join("global/other")).unwrap();
fs::remove_file(h.root.join("global/models/part.step")).unwrap();
let missing = h.cmd(&["doctor", "--global", "--fix"]);
assert!(!missing.status.success());
assert!(output(&missing).contains("Missing model"));
assert_eq!(fs::read_to_string(&fp).unwrap(), original);
fs::write(
h.root.join("global/UniLoader.kicad_sym"),
symbol("Part", "UniLoader_Global:Package"),
)
.unwrap();
fs::remove_dir_all(h.root.join("global/UniLoader.pretty")).unwrap();
let deleted = h.cmd(&["doctor", "--global", "--fix"]);
assert!(!deleted.status.success());
assert!(output(&deleted).contains("missing footprint UniLoader_Global:Package"));
assert!(h.root.join("global/UniLoader.pretty").is_dir());
}
#[test]
fn doctor_does_not_overwrite_corrupt_libraries_or_partially_apply_a_failed_plan() {
let h = Harness::new("separate");
fs::create_dir_all(h.root.join("project/libraries")).unwrap();
let bad = h.root.join("project/libraries/UniLoader.kicad_sym");
fs::write(&bad, "(kicad_symbol_lib (broken").unwrap();
let result = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]);
assert!(!result.status.success());
assert_eq!(
fs::read_to_string(bad).unwrap(),
"(kicad_symbol_lib (broken"
);
assert!(!h.root.join("global").exists());
assert!(!h.root.join("kicad-config").exists());
}
+21
View File
@@ -124,6 +124,27 @@ fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting
.unwrap_or_default() .unwrap_or_default()
.contains("Watching") .contains("Watching")
}); });
// Directory scans emit Linux inotify OPEN events themselves. Idle watching
// must block instead of repeatedly scanning in response to its own reads.
#[cfg(target_os = "linux")]
{
let runtime = || {
fs::read_to_string(format!("/proc/{}/schedstat", watcher.0.id()))
.unwrap()
.split_whitespace()
.next()
.unwrap()
.parse::<u64>()
.unwrap()
};
let before = runtime();
thread::sleep(Duration::from_secs(1));
let cpu = Duration::from_nanos(runtime() - before);
assert!(
cpu < Duration::from_millis(200),
"idle watcher spent {cpu:?} on CPU in one second"
);
}
let first = archive("First", 1); let first = archive("First", 1);
let second = archive("Independent", 2); let second = archive("Independent", 2);
let third = archive("Renamed", 3); let third = archive("Renamed", 3);