Compare commits
3
Commits
3f522f95f8
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4586aebdfb | ||
|
|
8ac15838bf | ||
|
|
5e33a1d9dd |
No files matched your search
@@ -1,5 +1,35 @@
|
||||
# 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
|
||||
|
||||
- Import components into global or portable project libraries, creating and registering libraries when needed.
|
||||
|
||||
Generated
+1
-1
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uniloader"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "uniloader"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ folder throughout your graphical login session.
|
||||
|
||||
It supports symbols, footprints and referenced STEP/VRML models, with:
|
||||
|
||||
- Global libraries or portable project-local libraries, created and registered automatically.
|
||||
- Global (`UniLoader_Global`) and project-local (`UniLoader_Local`) library nicknames, created and registered automatically.
|
||||
- A destination dialog offering detected KiCad project suggestions, Global and Browse.
|
||||
- Conservative exact footprint reuse and comparison dialogs for uncertain matches.
|
||||
- Broken model-path repair, explicit ambiguous model choices and missing-model warnings.
|
||||
@@ -59,6 +59,27 @@ No-argument startup also runs the watcher. `--noninteractive` leaves unresolved
|
||||
choices pending; `--force` on manual imports deliberately revisits a completed
|
||||
source. Check an imported component in KiCad before using it in a board.
|
||||
|
||||
## 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
|
||||
|
||||
This is practical Linux development tooling. Project discovery currently uses
|
||||
|
||||
@@ -129,7 +129,7 @@ project/
|
||||
models/
|
||||
```
|
||||
|
||||
- Add/merge UniLoader entries into the appropriate symbol and footprint tables.
|
||||
- Add/merge `UniLoader_Global` or `UniLoader_Local` entries into the appropriate symbol and footprint tables.
|
||||
Preserve unrelated entries and never silently repoint an occupied nickname.
|
||||
- Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute
|
||||
paths. Let users select the KiCad configuration/version directory if ambiguous.
|
||||
|
||||
+84
-1
@@ -152,7 +152,9 @@ Use `--noninteractive` to avoid opening dialogs. A pending choice is not consent
|
||||
Project imports create `libraries/UniLoader.kicad_sym`,
|
||||
`libraries/UniLoader.pretty/`, and model storage as needed, and merge registration
|
||||
entries into `sym-lib-table` and `fp-lib-table` beside the `.kicad_pro` file.
|
||||
Project paths use `${KIPRJMOD}`. Portable mode copies selected external footprints
|
||||
Project registration uses the nickname `UniLoader_Local`; global registration uses
|
||||
`UniLoader_Global`. Library filenames remain `UniLoader.kicad_sym` and
|
||||
`UniLoader.pretty` in both scopes. Project paths use `${KIPRJMOD}`. Portable mode copies selected external footprints
|
||||
and available referenced models into the project. Global imports use the
|
||||
configured library root and register entries in the selected KiCad configuration.
|
||||
Existing unrelated table entries are preserved. Existing schematic and PCB
|
||||
@@ -170,6 +172,87 @@ assets. Source archives are preserved by default. Incomplete or invalid ZIPs
|
||||
are retried for the configured period, then remain recoverable rather than
|
||||
stopping subsequent downloads.
|
||||
|
||||
## 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
|
||||
|
||||
STEP (`.step`/`.stp`) and VRML (`.wrl`) models are copied when referenced by an
|
||||
|
||||
@@ -66,6 +66,13 @@ impl Destination {
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub fn nickname(&self) -> &'static str {
|
||||
if self.project.is_some() {
|
||||
"UniLoader_Local"
|
||||
} else {
|
||||
"UniLoader_Global"
|
||||
}
|
||||
}
|
||||
fn uri(&self, path: &Path) -> String {
|
||||
if let Some(project) = &self.project
|
||||
&& let Ok(relative) = path.strip_prefix(project)
|
||||
@@ -144,6 +151,9 @@ pub fn register_table(
|
||||
.then(|| field_value(entry, "name").map(str::to_owned))
|
||||
.flatten()
|
||||
}) {
|
||||
if requested_nickname.starts_with("UniLoader_") && !name.starts_with(requested_nickname) {
|
||||
bail!("Legacy managed library nickname {name}; run `uniloader doctor --global --fix` or `uniloader doctor --project /path/to/Board.kicad_pro --fix` before importing");
|
||||
}
|
||||
return Ok((format!("{table}\n"), name));
|
||||
}
|
||||
let mut nickname = requested_nickname.to_owned();
|
||||
|
||||
+350
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
if let Some(version) = kicad
|
||||
.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(
|
||||
text(&dest.table_dir.join("fp-lib-table"))?.as_deref(),
|
||||
"fp_lib_table",
|
||||
"UniLoader",
|
||||
dest.nickname(),
|
||||
&dest.footprint_uri(),
|
||||
)?;
|
||||
let (sym_table, sym_nickname) = destination::register_table(
|
||||
text(&dest.table_dir.join("sym-lib-table"))?.as_deref(),
|
||||
"sym_lib_table",
|
||||
"UniLoader",
|
||||
dest.nickname(),
|
||||
&dest.symbol_uri(),
|
||||
)?;
|
||||
let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname)
|
||||
|
||||
+79
-4
@@ -3,6 +3,7 @@
|
||||
mod config;
|
||||
mod desktop;
|
||||
mod destination;
|
||||
mod doctor;
|
||||
mod gui;
|
||||
mod importer;
|
||||
mod matching;
|
||||
@@ -17,7 +18,10 @@ use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use config::Config;
|
||||
use importer::{Request, Session};
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use notify::{
|
||||
Event, EventKind, RecursiveMode, Watcher,
|
||||
event::{AccessKind, AccessMode},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
@@ -71,6 +75,15 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
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.
|
||||
Discover,
|
||||
}
|
||||
@@ -141,10 +154,25 @@ fn complete_zip(p: &Path) -> Result<bool> {
|
||||
}
|
||||
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<()> {
|
||||
let _watch_lock = storage::Lock::watcher()?;
|
||||
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 {
|
||||
if !dir.is_dir() {
|
||||
bail!(
|
||||
@@ -223,8 +251,19 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
||||
let mut last_scan = Instant::now();
|
||||
loop {
|
||||
match events_rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(Ok(_)) => scan(&mut waiting, &seen),
|
||||
Ok(Err(e)) => eprintln!("watch error: {e}"),
|
||||
Ok(event) => {
|
||||
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::Disconnected) => bail!("filesystem watcher disconnected"),
|
||||
}
|
||||
@@ -290,6 +329,7 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
if matches!(cli.command, Some(Commands::Dialog)) {
|
||||
@@ -362,6 +402,11 @@ fn main() -> Result<()> {
|
||||
&mut Session::default(),
|
||||
)
|
||||
}
|
||||
Commands::Doctor {
|
||||
global: _,
|
||||
project,
|
||||
fix,
|
||||
} => doctor::run(&cfg, project.as_deref(), fix),
|
||||
Commands::Discover => {
|
||||
let p = desktop::projects();
|
||||
if p.is_empty() {
|
||||
@@ -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
@@ -180,7 +180,7 @@ fn imports_three_provider_layouts_and_preserves_original_archives() {
|
||||
assert!(source.is_file());
|
||||
assert_eq!(h.footprints().len(), 1);
|
||||
let lib = h.library();
|
||||
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package");
|
||||
assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package");
|
||||
assert!(lib.to_string().contains("µ Ω (quoted)"));
|
||||
assert_eq!(lib.field("version").unwrap().value(), Some("20231120"));
|
||||
}
|
||||
@@ -208,7 +208,11 @@ fn exact_cross_provider_reuse_and_repeat_import_are_idempotent() {
|
||||
assert_eq!(h.footprints().len(), 1);
|
||||
let lib = h.library();
|
||||
assert_eq!(symbols(&lib).len(), 2);
|
||||
assert!(symbols(&lib).iter().all(|s| link(s) == "UniLoader:PkgA"));
|
||||
assert!(
|
||||
symbols(&lib)
|
||||
.iter()
|
||||
.all(|s| link(s) == "UniLoader_Local:PkgA")
|
||||
);
|
||||
ok(&h.import(&a, &[]));
|
||||
ok(&h.import(&b, &["--force"]));
|
||||
assert_eq!(symbols(&h.library()).len(), 2);
|
||||
@@ -257,7 +261,7 @@ fn uncertain_footprints_stay_pending_then_retry_with_separate_policy() {
|
||||
.into_iter()
|
||||
.find(|s| s.value() == Some("Second"))
|
||||
.unwrap();
|
||||
assert!(link(second).starts_with("UniLoader:Package_"));
|
||||
assert!(link(second).starts_with("UniLoader_Local:Package_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -361,8 +365,8 @@ fn multi_symbol_footprint_bindings_do_not_use_first_footprint_for_all() {
|
||||
ok(&h.import(&source, &[]));
|
||||
let lib = h.library();
|
||||
for (name, expected) in [
|
||||
("First", "UniLoader:PackageA"),
|
||||
("Second", "UniLoader:PackageB"),
|
||||
("First", "UniLoader_Local:PackageA"),
|
||||
("Second", "UniLoader_Local:PackageB"),
|
||||
] {
|
||||
let s = symbols(&lib)
|
||||
.into_iter()
|
||||
@@ -485,7 +489,10 @@ fn portable_project_copies_exact_global_footprint_and_model() {
|
||||
.unwrap()
|
||||
.collect();
|
||||
assert_eq!(copied_models.len(), 1);
|
||||
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:GlobalPackage");
|
||||
assert_eq!(
|
||||
link(symbols(&h.library())[0]),
|
||||
"UniLoader_Local:GlobalPackage"
|
||||
);
|
||||
}
|
||||
|
||||
fn records(h: &Harness) -> Vec<serde_json::Value> {
|
||||
@@ -516,11 +523,14 @@ fn symbol_only_global_dependency_is_copied_into_portable_project() {
|
||||
ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"]));
|
||||
let source = h.bundle(
|
||||
"symbol-only",
|
||||
&[("b.kicad_sym", symbol("LocalPart", "UniLoader:Package"))],
|
||||
&[(
|
||||
"b.kicad_sym",
|
||||
symbol("LocalPart", "UniLoader_Global:Package"),
|
||||
)],
|
||||
);
|
||||
ok(&h.import(&source, &[]));
|
||||
assert_eq!(h.footprints().len(), 1);
|
||||
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:Package");
|
||||
assert_eq!(link(symbols(&h.library())[0]), "UniLoader_Local:Package");
|
||||
assert!(
|
||||
fs::read_to_string(&h.footprints()[0])
|
||||
.unwrap()
|
||||
@@ -603,7 +613,7 @@ fn project_library_and_model_paths_resolve_after_directory_relocation() {
|
||||
assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL");
|
||||
let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package");
|
||||
assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -620,7 +630,7 @@ fn disabled_footprint_import_reuses_registered_assets_and_rejects_dangling_links
|
||||
append_config(&h, "[options]\nfootprints = false");
|
||||
let valid = h.bundle(
|
||||
"valid",
|
||||
&[("b.kicad_sym", symbol("Second", "UniLoader:Package"))],
|
||||
&[("b.kicad_sym", symbol("Second", "UniLoader_Local:Package"))],
|
||||
);
|
||||
ok(&h.import(&valid, &[]));
|
||||
let invalid = h.bundle(
|
||||
@@ -774,7 +784,7 @@ fn disabled_footprints_cannot_reference_an_unregistered_managed_directory() {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(table.children().iter().any(|n| n.head() == Some("lib")
|
||||
&& n.field("name").and_then(Node::value) == Some("UniLoader")));
|
||||
&& n.field("name").and_then(Node::value) == Some("UniLoader_Local")));
|
||||
} else {
|
||||
assert!(output(&result).contains("pending"));
|
||||
assert!(
|
||||
@@ -816,3 +826,332 @@ fn qualified_existing_reference_is_not_silently_retargeted_by_bundle_basename()
|
||||
assert!(output(&result).contains("pending"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_migrates_both_scopes_and_project_references_with_backups() {
|
||||
let h = Harness::new("separate");
|
||||
let source = h.bundle(
|
||||
"source",
|
||||
&[
|
||||
("a.kicad_sym", symbol("Part", "Package")),
|
||||
("Package.kicad_mod", footprint("Package", 1.0, "")),
|
||||
],
|
||||
);
|
||||
ok(&h.cmd(&["import", source.to_str().unwrap(), "--global"]));
|
||||
ok(&h.import(&source, &[]));
|
||||
// Recreate v0.2.1 registrations and stored footprint bindings.
|
||||
let paths = [
|
||||
"kicad-config/sym-lib-table",
|
||||
"kicad-config/fp-lib-table",
|
||||
"global/UniLoader.kicad_sym",
|
||||
"project/sym-lib-table",
|
||||
"project/fp-lib-table",
|
||||
"project/libraries/UniLoader.kicad_sym",
|
||||
];
|
||||
for path in paths {
|
||||
let path = h.root.join(path);
|
||||
let text = fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.replace("UniLoader_Global", "UniLoader")
|
||||
.replace("UniLoader_Local", "UniLoader");
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
let sch = h.root.join("project/board.kicad_sch");
|
||||
fs::write(&sch, r#"(kicad_sch (lib_symbols (symbol "UniLoader:Part" (property "Footprint" "UniLoader:Package"))) (symbol (lib_id "UniLoader:Part") (property "Footprint" "UniLoader:Package") (property "Description" "UniLoader:keep")))"#).unwrap();
|
||||
let pcb = h.root.join("project/board.kicad_pcb");
|
||||
fs::write(
|
||||
&pcb,
|
||||
r#"(kicad_pcb (footprint "UniLoader:Package" (property "Value" "keep")))"#,
|
||||
)
|
||||
.unwrap();
|
||||
let before = fs::read(&sch).unwrap();
|
||||
let args = ["doctor", "--project", h.project.to_str().unwrap()];
|
||||
let preview = h.cmd(&args);
|
||||
ok(&preview);
|
||||
assert!(output(&preview).contains("Would repair"));
|
||||
assert_eq!(fs::read(&sch).unwrap(), before);
|
||||
// Imports must not silently retain a legacy registration.
|
||||
let blocked = h.import(&source, &["--force"]);
|
||||
assert!(!blocked.status.success());
|
||||
assert!(output(&blocked).contains("doctor"));
|
||||
let fixed = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]);
|
||||
ok(&fixed);
|
||||
assert!(output(&fixed).contains("Backup:"));
|
||||
assert!(
|
||||
walkdir::WalkDir::new(h.root.join("xdg-data/uniloader/backups"))
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| entry.file_type().is_file() && fs::read(entry.path()).unwrap() == before)
|
||||
);
|
||||
assert!(
|
||||
fs::read_to_string(&sch)
|
||||
.unwrap()
|
||||
.contains("UniLoader_Local:Part")
|
||||
);
|
||||
assert!(fs::read_to_string(&sch).unwrap().contains("UniLoader:keep"));
|
||||
assert!(
|
||||
fs::read_to_string(&pcb)
|
||||
.unwrap()
|
||||
.contains("UniLoader_Local:Package")
|
||||
);
|
||||
assert_eq!(link(symbols(&h.library())[0]), "UniLoader_Local:Package");
|
||||
let global =
|
||||
parse(&fs::read_to_string(h.root.join("global/UniLoader.kicad_sym")).unwrap()).unwrap();
|
||||
assert_eq!(link(symbols(&global)[0]), "UniLoader_Global:Package");
|
||||
let again = h.cmd(&["doctor", "--project", h.project.to_str().unwrap(), "--fix"]);
|
||||
ok(&again);
|
||||
assert!(output(&again).contains("No 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());
|
||||
}
|
||||
@@ -124,6 +124,27 @@ fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting
|
||||
.unwrap_or_default()
|
||||
.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 second = archive("Independent", 2);
|
||||
let third = archive("Renamed", 3);
|
||||
|
||||
Reference in new issue
Block a user