Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10d3adcfa | ||
|
|
3f522f95f8 | ||
|
|
d145a53141 |
No files matched your search
@@ -1,26 +1,5 @@
|
|||||||
# 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
|
## 0.2.1
|
||||||
|
|
||||||
- Fix high idle watcher CPU usage caused by directory scans generating filesystem open events and triggering further scans.
|
- Fix high idle watcher CPU usage caused by directory scans generating filesystem open events and triggering further scans.
|
||||||
|
|||||||
Generated
+1
-1
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uniloader"
|
name = "uniloader"
|
||||||
version = "0.2.3"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "uniloader"
|
name = "uniloader"
|
||||||
version = "0.2.3"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
|||||||
@@ -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 (`UniLoader_Global`) and project-local (`UniLoader_Local`) library nicknames, created and registered automatically.
|
- Global libraries or portable project-local libraries, 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,27 +59,6 @@ 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
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ project/
|
|||||||
models/
|
models/
|
||||||
```
|
```
|
||||||
|
|
||||||
- Add/merge `UniLoader_Global` or `UniLoader_Local` entries into the appropriate symbol and footprint tables.
|
- Add/merge UniLoader 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.
|
||||||
|
|||||||
+1
-84
@@ -152,9 +152,7 @@ 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 registration uses the nickname `UniLoader_Local`; global registration uses
|
Project paths use `${KIPRJMOD}`. Portable mode copies selected external footprints
|
||||||
`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
|
||||||
@@ -172,87 +170,6 @@ 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
|
||||||
|
|||||||
@@ -66,13 +66,6 @@ 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)
|
||||||
@@ -151,9 +144,6 @@ 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
@@ -1,350 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
//! 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
@@ -156,7 +156,7 @@ fn target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) fn variables(cfg: &Config, kicad: &Path) -> BTreeMap<String, String> {
|
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",
|
||||||
dest.nickname(),
|
"UniLoader",
|
||||||
&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",
|
||||||
dest.nickname(),
|
"UniLoader",
|
||||||
&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)
|
||||||
|
|||||||
-15
@@ -3,7 +3,6 @@
|
|||||||
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;
|
||||||
@@ -75,15 +74,6 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -402,11 +392,6 @@ 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() {
|
||||||
|
|||||||
+11
-350
@@ -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_Local:Package");
|
assert_eq!(link(symbols(&lib)[0]), "UniLoader: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,11 +208,7 @@ 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!(
|
assert!(symbols(&lib).iter().all(|s| link(s) == "UniLoader:PkgA"));
|
||||||
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);
|
||||||
@@ -261,7 +257,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_Local:Package_"));
|
assert!(link(second).starts_with("UniLoader:Package_"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -365,8 +361,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_Local:PackageA"),
|
("First", "UniLoader:PackageA"),
|
||||||
("Second", "UniLoader_Local:PackageB"),
|
("Second", "UniLoader:PackageB"),
|
||||||
] {
|
] {
|
||||||
let s = symbols(&lib)
|
let s = symbols(&lib)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -489,10 +485,7 @@ 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!(
|
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:GlobalPackage");
|
||||||
link(symbols(&h.library())[0]),
|
|
||||||
"UniLoader_Local:GlobalPackage"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn records(h: &Harness) -> Vec<serde_json::Value> {
|
fn records(h: &Harness) -> Vec<serde_json::Value> {
|
||||||
@@ -523,14 +516,11 @@ 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_Local:Package");
|
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:Package");
|
||||||
assert!(
|
assert!(
|
||||||
fs::read_to_string(&h.footprints()[0])
|
fs::read_to_string(&h.footprints()[0])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -613,7 +603,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_Local:Package");
|
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -630,7 +620,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_Local:Package"))],
|
&[("b.kicad_sym", symbol("Second", "UniLoader:Package"))],
|
||||||
);
|
);
|
||||||
ok(&h.import(&valid, &[]));
|
ok(&h.import(&valid, &[]));
|
||||||
let invalid = h.bundle(
|
let invalid = h.bundle(
|
||||||
@@ -784,7 +774,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_Local")));
|
&& n.field("name").and_then(Node::value) == Some("UniLoader")));
|
||||||
} else {
|
} else {
|
||||||
assert!(output(&result).contains("pending"));
|
assert!(output(&result).contains("pending"));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -826,332 +816,3 @@ 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());
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user