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