4 Commits
12 changed files with 613 additions and 22 deletions

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# 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
- Fix high idle watcher CPU usage caused by directory scans generating filesystem open events and triggering further scans.
- Ignore read/open access events before queuing watcher work, while retaining write-close events and filesystem overflow recovery.
- Batch queued filesystem events into one scan while preserving periodic reconciliation and partial-download retries.
- Add a Linux idle CPU regression check alongside the download, rename, duplicate and restart integration checks.
Validation: 59 automated tests, Clippy, formatting, and release build passed. Local Linux idle profiling confirmed the watcher no longer continuously rescans its download directory.
## 0.2.0 ## 0.2.0
- Import components into global or portable project libraries, creating and registering libraries when needed. - Import components into global or portable project libraries, creating and registering libraries when needed.
Generated
+1 -1
View File
@@ -2869,7 +2869,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "uniloader" name = "uniloader"
version = "0.2.0" version = "0.2.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "uniloader" name = "uniloader"
version = "0.2.0" version = "0.2.2"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
+17 -1
View File
@@ -8,7 +8,7 @@ folder throughout your graphical login session.
It supports symbols, footprints and referenced STEP/VRML models, with: 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
+1 -1
View File
@@ -129,7 +129,7 @@ project/
models/ models/
``` ```
- Add/merge UniLoader entries into the appropriate symbol and footprint tables. - Add/merge `UniLoader_Global` or `UniLoader_Local` entries into the appropriate symbol and footprint tables.
Preserve unrelated entries and never silently repoint an occupied nickname. Preserve unrelated entries and never silently repoint an occupied nickname.
- Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute - Project URIs use `${KIPRJMOD}/libraries/...`; global URIs use stable absolute
paths. Let users select the KiCad configuration/version directory if ambiguous. paths. Let users select the KiCad configuration/version directory if ambiguous.
+46 -1
View File
@@ -152,7 +152,9 @@ Use `--noninteractive` to avoid opening dialogs. A pending choice is not consent
Project imports create `libraries/UniLoader.kicad_sym`, 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
+10
View File
@@ -66,6 +66,13 @@ impl Destination {
} }
Ok(result) Ok(result)
} }
pub fn nickname(&self) -> &'static str {
if self.project.is_some() {
"UniLoader_Local"
} else {
"UniLoader_Global"
}
}
fn uri(&self, path: &Path) -> String { fn uri(&self, path: &Path) -> String {
if let Some(project) = &self.project if let Some(project) = &self.project
&& let Ok(relative) = path.strip_prefix(project) && let Ok(relative) = path.strip_prefix(project)
@@ -144,6 +151,9 @@ pub fn register_table(
.then(|| field_value(entry, "name").map(str::to_owned)) .then(|| field_value(entry, "name").map(str::to_owned))
.flatten() .flatten()
}) { }) {
if requested_nickname.starts_with("UniLoader_") && !name.starts_with(requested_nickname) {
bail!("Legacy managed library nickname {name}; run `uniloader doctor --global --fix` or `uniloader doctor --project /path/to/Board.kicad_pro --fix` before importing");
}
return Ok((format!("{table}\n"), name)); return Ok((format!("{table}\n"), name));
} }
let mut nickname = requested_nickname.to_owned(); let mut nickname = requested_nickname.to_owned();
+252
View File
@@ -0,0 +1,252 @@
//! Explicit, backed-up migration of managed library registrations and references.
use crate::{
config::Config,
destination::{self, Destination},
importer,
sexpr::{Node, parse},
storage::{Lock, Plan, Record},
};
use anyhow::{Context, Result, bail};
use std::{collections::BTreeMap, fs, path::Path};
type Renames = BTreeMap<String, String>;
fn table(dest: &Destination, kind: &str, vars: &Renames, plan: &mut Plan) -> Result<Renames> {
let (file, target) = if kind == "sym_lib_table" {
("sym-lib-table", &dest.symbol_path)
} else {
("fp-lib-table", &dest.pretty_dir)
};
let path = dest.table_dir.join(file);
if !path.exists() {
return Ok(Renames::new());
}
let original = fs::read_to_string(&path)?;
let mut node = parse(&original)?;
if node.head() != Some(kind) {
bail!("Invalid table {}", path.display());
}
// Keep exact old names so a later project migration also handles legacy
// collision suffixes without guessing which nickname used to be ours.
let history_path = crate::config::data_dir()
.join("nickname-migrations")
.join(format!(
"{}.json",
crate::storage::hash(path.to_string_lossy().as_bytes())
));
let mut history: Renames = if history_path.exists() {
serde_json::from_slice(&fs::read(&history_path)?)?
} else {
Renames::new()
};
let original_history = history.clone();
let mut changes = Renames::new();
let mut occupied = BTreeMap::new();
for entry in node.children().iter().filter(|n| n.head() == Some("lib")) {
let name = entry
.field("name")
.and_then(Node::value)
.context("Library entry missing name")?;
if occupied.insert(name.to_owned(), ()).is_some() {
bail!("Duplicate nickname {name} in {}", path.display());
}
}
for entry in node
.children_mut()
.iter_mut()
.filter(|n| n.head() == Some("lib"))
{
let name = entry
.field("name")
.and_then(Node::value)
.context("Library entry missing name")?
.to_owned();
// Only change entries that resolve to our managed file/directory.
let uri = entry
.field("uri")
.and_then(Node::value)
.context("Library entry missing URI")?;
let Ok(resolved) = destination::resolve_variables(uri, dest.project.as_deref(), vars)
else {
continue;
};
let resolved = std::path::PathBuf::from(resolved);
let resolved = if resolved.is_absolute() {
resolved
} else {
dest.table_dir.join(resolved)
};
let matches = resolved == *target
|| (target.exists() && resolved.canonicalize().ok() == target.canonicalize().ok());
if !matches || entry.field("type").and_then(Node::value) != Some("KiCad") {
continue;
}
let wanted = dest.nickname();
if name == wanted
|| name
.strip_prefix(wanted)
.is_some_and(|s| s.starts_with('_'))
{
changes.extend(
history
.iter()
.filter(|(old, new)| *new == &name && !occupied.contains_key(*old))
.map(|(old, new)| (old.clone(), new.clone())),
);
continue;
}
if occupied.contains_key(wanted) {
bail!(
"Nickname {wanted} is occupied in {}; resolve it before migration",
path.display()
);
}
occupied.insert(wanted.into(), ());
entry.field_mut("name").unwrap().children_mut()[1] = Node::string(wanted);
history.insert(name.clone(), wanted.into());
changes.insert(name, wanted.into());
}
if history != original_history {
plan.put(history_path, serde_json::to_vec_pretty(&history)?)?;
}
if node != parse(&original)? {
plan.put(path, format!("{node}\n").into_bytes())?;
}
Ok(changes)
}
fn rename(value: &mut Node, mapping: &Renames) {
if let Node::Atom(s) | Node::Str(s) = value
&& let Some((nick, item)) = s.split_once(':')
&& let Some(new) = mapping.get(nick)
{
*s = format!("{new}:{item}");
}
}
fn references(node: &mut Node, symbols: &Renames, footprints: &Renames, parent: Option<&str>) {
let head = node.head().map(str::to_owned);
if let Node::List(items) = node {
match head.as_deref() {
Some("lib_id") | Some("extends") => {
if let Some(v) = items.get_mut(1) {
rename(v, symbols);
}
}
Some("symbol") if parent == Some("lib_symbols") => {
if let Some(v) = items.get_mut(1) {
rename(v, symbols);
}
}
Some("footprint") => {
if let Some(v) = items.get_mut(1) {
rename(v, footprints);
}
}
Some("property") if items.get(1).and_then(Node::atom) == Some("Footprint") => {
if let Some(v) = items.get_mut(2) {
rename(v, footprints);
}
}
_ => {}
}
for child in items {
references(child, symbols, footprints, head.as_deref());
}
}
}
fn patch(path: &Path, symbols: &Renames, footprints: &Renames, plan: &mut Plan) -> Result<()> {
if !path.is_file() {
return Ok(());
}
let original = fs::read_to_string(path).with_context(|| format!("Read {}", path.display()))?;
let mut node = parse(&original).with_context(|| format!("Parse {}", path.display()))?;
let before = node.clone();
references(&mut node, symbols, footprints, None);
if node != before {
plan.put(path.to_owned(), format!("{node}\n").into_bytes())?;
}
Ok(())
}
// A local entry shadows the global mapping even if it belongs to another tool.
fn overlay(dest: &Destination, file: &str, global: &Renames, local: Renames) -> Result<Renames> {
let mut result = global.clone();
let path = dest.table_dir.join(file);
if path.exists() {
for entry in parse(&fs::read_to_string(path)?)?.children() {
if entry.head() == Some("lib")
&& let Some(name) = entry.field("name").and_then(Node::value)
{
result.remove(name);
}
}
}
result.extend(local);
Ok(result)
}
pub fn run(cfg: &Config, project: Option<&Path>, fix: bool) -> Result<()> {
let _lock = Lock::acquire()?;
let kicad = importer::kicad_config(cfg, project.is_none())?;
let global = Destination::new(&cfg.libraries.path, None, &kicad)?;
let mut plan = Plan::default();
let sym = table(
&global,
"sym_lib_table",
&cfg.libraries.variables,
&mut plan,
)?;
let fp = table(&global, "fp_lib_table", &cfg.libraries.variables, &mut plan)?;
patch(&global.symbol_path, &sym, &fp, &mut plan)?;
if let Some(project) = project {
let local = Destination::new(&cfg.libraries.path, Some(project), &kicad)?;
let local_sym = table(&local, "sym_lib_table", &cfg.libraries.variables, &mut plan)?;
let local_fp = table(&local, "fp_lib_table", &cfg.libraries.variables, &mut plan)?;
let sym = overlay(&local, "sym-lib-table", &sym, local_sym)?;
let fp = overlay(&local, "fp-lib-table", &fp, local_fp)?;
patch(&local.symbol_path, &sym, &fp, &mut plan)?;
for entry in walkdir::WalkDir::new(local.project.as_ref().unwrap()).follow_links(false) {
let entry = entry?;
if entry.file_type().is_file()
&& entry
.path()
.extension()
.is_some_and(|e| e == "kicad_sch" || e == "kicad_pcb")
{
patch(entry.path(), &sym, &fp, &mut plan)?;
}
}
}
for path in plan.writes.keys() {
println!(
"{}: {}",
if fix { "Repair" } else { "Would repair" },
path.display()
);
}
if plan.writes.is_empty() {
println!("No nickname repairs needed.");
} else if fix {
let mut record = Record::new(
global.root.clone(),
crate::storage::hash(b"doctor-nicknames"),
);
record.id = format!(
"doctor-{}",
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
record.status = "importing".into();
plan.commit(&mut record)?;
record.status = "completed".into();
record.message = "Library nickname migration completed".into();
record.save()?;
for backup in record.backups {
println!("Backup: {}", backup.display());
}
} else {
println!("Preview only; rerun with --fix to apply repairs.");
}
println!(
"Run doctor --project /path/to/Board.kicad_pro for each existing project. Close KiCad and stop the watcher before --fix; reopen KiCad afterwards."
);
Ok(())
}
+2 -2
View File
@@ -431,13 +431,13 @@ pub fn run(cfg: &Config, req: &Request, record: &mut Record, session: &mut Sessi
let (fp_table, fp_nickname) = destination::register_table( let (fp_table, fp_nickname) = destination::register_table(
text(&dest.table_dir.join("fp-lib-table"))?.as_deref(), text(&dest.table_dir.join("fp-lib-table"))?.as_deref(),
"fp_lib_table", "fp_lib_table",
"UniLoader", dest.nickname(),
&dest.footprint_uri(), &dest.footprint_uri(),
)?; )?;
let (sym_table, sym_nickname) = destination::register_table( let (sym_table, sym_nickname) = destination::register_table(
text(&dest.table_dir.join("sym-lib-table"))?.as_deref(), text(&dest.table_dir.join("sym-lib-table"))?.as_deref(),
"sym_lib_table", "sym_lib_table",
"UniLoader", dest.nickname(),
&dest.symbol_uri(), &dest.symbol_uri(),
)?; )?;
let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname) let mut library_index = index(cfg, &dest, &kicad, &vars, &fp_nickname)
+79 -4
View File
@@ -3,6 +3,7 @@
mod config; mod config;
mod desktop; mod desktop;
mod destination; mod destination;
mod doctor;
mod gui; mod gui;
mod importer; mod importer;
mod matching; mod matching;
@@ -17,7 +18,10 @@ use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use config::Config; use config::Config;
use importer::{Request, Session}; use importer::{Request, Session};
use notify::{RecursiveMode, Watcher}; use notify::{
Event, EventKind, RecursiveMode, Watcher,
event::{AccessKind, AccessMode},
};
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
fs, fs,
@@ -71,6 +75,15 @@ enum Commands {
#[arg(long)] #[arg(long)]
dry_run: bool, dry_run: bool,
}, },
/// Check 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,
} }
@@ -141,10 +154,25 @@ fn complete_zip(p: &Path) -> Result<bool> {
} }
Ok(supported) Ok(supported)
} }
fn event_requires_scan(event: &Event) -> bool {
// Reading a watched directory emits OPEN events on Linux, including from
// our own scans. Only a writer closing a file can make access relevant.
event.need_rescan()
|| !matches!(event.kind, EventKind::Access(_))
|| matches!(
event.kind,
EventKind::Access(AccessKind::Close(AccessMode::Write))
)
}
fn watch(cfg: Config, noninteractive: bool) -> Result<()> { fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
let _watch_lock = storage::Lock::watcher()?; let _watch_lock = storage::Lock::watcher()?;
let (events_tx, events_rx) = mpsc::channel(); let (events_tx, events_rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(events_tx)?; let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
// Filter before queuing so reads cannot keep the receive loop awake.
if event.as_ref().map_or(true, event_requires_scan) {
let _ = events_tx.send(event);
}
})?;
for dir in &cfg.watch.directories { for dir in &cfg.watch.directories {
if !dir.is_dir() { if !dir.is_dir() {
bail!( bail!(
@@ -223,8 +251,19 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
let mut last_scan = Instant::now(); let mut last_scan = Instant::now();
loop { loop {
match events_rx.recv_timeout(Duration::from_millis(500)) { match events_rx.recv_timeout(Duration::from_millis(500)) {
Ok(Ok(_)) => scan(&mut waiting, &seen), Ok(event) => {
Ok(Err(e)) => eprintln!("watch error: {e}"), let mut rescan = false;
// Bound draining so sustained downloads cannot starve retries.
for event in std::iter::once(event).chain(events_rx.try_iter().take(1023)) {
match event {
Ok(_) => rescan = true,
Err(e) => eprintln!("watch error: {e}"),
}
}
if rescan {
scan(&mut waiting, &seen);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"), Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"),
} }
@@ -290,6 +329,7 @@ fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
} }
} }
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
if matches!(cli.command, Some(Commands::Dialog)) { if matches!(cli.command, Some(Commands::Dialog)) {
@@ -362,6 +402,11 @@ fn main() -> Result<()> {
&mut Session::default(), &mut Session::default(),
) )
} }
Commands::Doctor {
global: _,
project,
fix,
} => doctor::run(&cfg, project.as_deref(), fix),
Commands::Discover => { Commands::Discover => {
let p = desktop::projects(); let p = desktop::projects();
if p.is_empty() { if p.is_empty() {
@@ -375,3 +420,33 @@ fn main() -> Result<()> {
} }
} }
} }
#[cfg(test)]
mod watch_tests {
use super::*;
use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind};
#[test]
fn scans_changes_and_write_closes_but_ignores_read_access() {
for kind in [
EventKind::Any,
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Any),
EventKind::Remove(RemoveKind::File),
EventKind::Access(AccessKind::Close(AccessMode::Write)),
] {
assert!(event_requires_scan(&Event::new(kind)), "{kind:?}");
}
for kind in [
AccessKind::Any,
AccessKind::Read,
AccessKind::Open(AccessMode::Read),
AccessKind::Open(AccessMode::Write),
AccessKind::Close(AccessMode::Read),
] {
let event = Event::new(EventKind::Access(kind));
assert!(!event_requires_scan(&event), "{kind:?}");
assert!(event_requires_scan(&event.set_flag(Flag::Rescan)));
}
}
}
+164 -11
View File
@@ -180,7 +180,7 @@ fn imports_three_provider_layouts_and_preserves_original_archives() {
assert!(source.is_file()); assert!(source.is_file());
assert_eq!(h.footprints().len(), 1); assert_eq!(h.footprints().len(), 1);
let lib = h.library(); let lib = h.library();
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package"); assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package");
assert!(lib.to_string().contains("µ Ω (quoted)")); assert!(lib.to_string().contains("µ Ω (quoted)"));
assert_eq!(lib.field("version").unwrap().value(), Some("20231120")); assert_eq!(lib.field("version").unwrap().value(), Some("20231120"));
} }
@@ -208,7 +208,11 @@ fn exact_cross_provider_reuse_and_repeat_import_are_idempotent() {
assert_eq!(h.footprints().len(), 1); assert_eq!(h.footprints().len(), 1);
let lib = h.library(); let lib = h.library();
assert_eq!(symbols(&lib).len(), 2); assert_eq!(symbols(&lib).len(), 2);
assert!(symbols(&lib).iter().all(|s| link(s) == "UniLoader:PkgA")); assert!(
symbols(&lib)
.iter()
.all(|s| link(s) == "UniLoader_Local:PkgA")
);
ok(&h.import(&a, &[])); ok(&h.import(&a, &[]));
ok(&h.import(&b, &["--force"])); ok(&h.import(&b, &["--force"]));
assert_eq!(symbols(&h.library()).len(), 2); assert_eq!(symbols(&h.library()).len(), 2);
@@ -257,7 +261,7 @@ fn uncertain_footprints_stay_pending_then_retry_with_separate_policy() {
.into_iter() .into_iter()
.find(|s| s.value() == Some("Second")) .find(|s| s.value() == Some("Second"))
.unwrap(); .unwrap();
assert!(link(second).starts_with("UniLoader:Package_")); assert!(link(second).starts_with("UniLoader_Local:Package_"));
} }
#[test] #[test]
@@ -361,8 +365,8 @@ fn multi_symbol_footprint_bindings_do_not_use_first_footprint_for_all() {
ok(&h.import(&source, &[])); ok(&h.import(&source, &[]));
let lib = h.library(); let lib = h.library();
for (name, expected) in [ for (name, expected) in [
("First", "UniLoader:PackageA"), ("First", "UniLoader_Local:PackageA"),
("Second", "UniLoader:PackageB"), ("Second", "UniLoader_Local:PackageB"),
] { ] {
let s = symbols(&lib) let s = symbols(&lib)
.into_iter() .into_iter()
@@ -485,7 +489,10 @@ fn portable_project_copies_exact_global_footprint_and_model() {
.unwrap() .unwrap()
.collect(); .collect();
assert_eq!(copied_models.len(), 1); assert_eq!(copied_models.len(), 1);
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:GlobalPackage"); assert_eq!(
link(symbols(&h.library())[0]),
"UniLoader_Local:GlobalPackage"
);
} }
fn records(h: &Harness) -> Vec<serde_json::Value> { fn records(h: &Harness) -> Vec<serde_json::Value> {
@@ -516,11 +523,14 @@ fn symbol_only_global_dependency_is_copied_into_portable_project() {
ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"])); ok(&h.cmd(&["import", global.to_str().unwrap(), "--global"]));
let source = h.bundle( let source = h.bundle(
"symbol-only", "symbol-only",
&[("b.kicad_sym", symbol("LocalPart", "UniLoader:Package"))], &[(
"b.kicad_sym",
symbol("LocalPart", "UniLoader_Global:Package"),
)],
); );
ok(&h.import(&source, &[])); ok(&h.import(&source, &[]));
assert_eq!(h.footprints().len(), 1); assert_eq!(h.footprints().len(), 1);
assert_eq!(link(symbols(&h.library())[0]), "UniLoader:Package"); assert_eq!(link(symbols(&h.library())[0]), "UniLoader_Local:Package");
assert!( assert!(
fs::read_to_string(&h.footprints()[0]) fs::read_to_string(&h.footprints()[0])
.unwrap() .unwrap()
@@ -603,7 +613,7 @@ fn project_library_and_model_paths_resolve_after_directory_relocation() {
assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL"); assert_eq!(fs::read(path).unwrap(), b"RELOCATED MODEL");
let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap()) let lib = parse(&fs::read_to_string(relocated.join("libraries/UniLoader.kicad_sym")).unwrap())
.unwrap(); .unwrap();
assert_eq!(link(symbols(&lib)[0]), "UniLoader:Package"); assert_eq!(link(symbols(&lib)[0]), "UniLoader_Local:Package");
} }
#[test] #[test]
@@ -620,7 +630,7 @@ fn disabled_footprint_import_reuses_registered_assets_and_rejects_dangling_links
append_config(&h, "[options]\nfootprints = false"); append_config(&h, "[options]\nfootprints = false");
let valid = h.bundle( let valid = h.bundle(
"valid", "valid",
&[("b.kicad_sym", symbol("Second", "UniLoader:Package"))], &[("b.kicad_sym", symbol("Second", "UniLoader_Local:Package"))],
); );
ok(&h.import(&valid, &[])); ok(&h.import(&valid, &[]));
let invalid = h.bundle( let invalid = h.bundle(
@@ -774,7 +784,7 @@ fn disabled_footprints_cannot_reference_an_unregistered_managed_directory() {
) )
.unwrap(); .unwrap();
assert!(table.children().iter().any(|n| n.head() == Some("lib") assert!(table.children().iter().any(|n| n.head() == Some("lib")
&& n.field("name").and_then(Node::value) == Some("UniLoader"))); && n.field("name").and_then(Node::value) == Some("UniLoader_Local")));
} else { } else {
assert!(output(&result).contains("pending")); assert!(output(&result).contains("pending"));
assert!( assert!(
@@ -816,3 +826,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\"")
);
}
+21
View File
@@ -124,6 +124,27 @@ fn watcher_handles_partial_and_renamed_downloads_without_blocking_or_reimporting
.unwrap_or_default() .unwrap_or_default()
.contains("Watching") .contains("Watching")
}); });
// Directory scans emit Linux inotify OPEN events themselves. Idle watching
// must block instead of repeatedly scanning in response to its own reads.
#[cfg(target_os = "linux")]
{
let runtime = || {
fs::read_to_string(format!("/proc/{}/schedstat", watcher.0.id()))
.unwrap()
.split_whitespace()
.next()
.unwrap()
.parse::<u64>()
.unwrap()
};
let before = runtime();
thread::sleep(Duration::from_secs(1));
let cpu = Duration::from_nanos(runtime() - before);
assert!(
cpu < Duration::from_millis(200),
"idle watcher spent {cpu:?} on CPU in one second"
);
}
let first = archive("First", 1); let first = archive("First", 1);
let second = archive("Independent", 2); let second = archive("Independent", 2);
let third = archive("Renamed", 3); let third = archive("Renamed", 3);