← Commit history

1.0.37: 1.0.37: kicad_uninstall_library (PR #20, from the adom-footprint thread): the inverse of kicad_install_library, removes a library row from sym-lib-table and/or fp-lib-table and, with deleteFiles:true, the installed copy under Documents/KiCad/<ver> only; refuses the shared Adom library and KiCad's stock libraries. Owner's additions on top: a row of type Table (how a fresh KiCad 10 holds ALL its stock libraries) is stock and never removed, and the managed-folder test is a path boundary, not a string prefix. Wiki #111: kicad_install_library answers kicadRunning, tableReloadNeeded and a _reloadHint when a running KiCad cannot see the new library yet, and kicad_open_footprint_editor tells three failures apart from the disk (library_not_registered, footprint_not_in_library with availableFootprints, library_not_loaded_by_running_kicad with the exact rescan call) instead of a generic did-not-load. Verb battery: install_library_bundle is a real test now (installs a generated bundle, uninstalls it again); 118 verbs, one stated skip.

John Lauer ·7c4accf848 ·1mo ago ·parent 14e12de
5 files changed +260−6
rust/Cargo.lock+3−3
@@ -244,7 +244,7 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.36"+version = "1.0.37" dependencies = [  "kicad-core",  "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.36"+version = "1.0.37" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.36"+version = "1.0.37" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.36"+version = "1.0.37" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_install.rs+33−2
@@ -1,4 +1,4 @@-//! Verb group "install": kicad_install_library, kicad_install_symbol,+//! Verb group "install": kicad_install_library, kicad_uninstall_library, kicad_install_symbol, //! kicad_install_footprint, kicad_install_library_bundle, kicad_adom_library_heal, //! kicad_make_part_project, kicad_export_part. Same arg names and response shapes //! as the Python handlers (`handlers/install_library.py`, `handlers/install_symbol.py`,@@ -38,6 +38,21 @@ pub static VERBS: &[Verb] = &[             "a .kicad_sym KiCad's own parser refuses is not copied or registered (errorCode kicad_refuses_library, wiki #71); without kicad-cli there is no verdict and the install proceeds",         ],     },+    Verb {+        name: "kicad_uninstall_library",+        summary: "Remove a library from the user's sym-/fp-lib-table: the inverse of kicad_install_library. Optionally deletes the copy the install made.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"libraryName\": \"MyParts\", \"libraryType\"?: \"symbol|footprint|both\" (default both), \"deleteFiles\"?: false, \"kicadVersion\"?: \"10.0\"}",+        example: "kicad_uninstall_library {\"libraryName\":\"Adom_Check\",\"deleteFiles\":true}",+        hint: "Removes the named row from the selected KiCad version's lib table(s) and answers removed[] (table + uri). deleteFiles:true also deletes the installed copy, but ONLY under Documents/KiCad/<ver>/symbols|footprints (plus 3dmodels/<name>.3dshapes): a library registered in place from anywhere else is unregistered and its files are listed in filesKept. kicadRunning/tableReloadNeeded tell you when the running KiCad still holds the old table.",+        related: &["kicad_install_library", "kicad_show_library", "kicad_list_versions", "kicad_adom_library_heal"],+        pitfalls: &[+            "refuses the shared 'Adom' library (adom_library_protected) and KiCad's own stock libraries (stock_library_protected): it only removes libraries that were added to the user tables",+            "each KiCad version has its OWN lib tables: uninstall under every version it was installed for",+            "a RUNNING KiCad holds its tables in memory and can write the row back when it saves them: uninstall with KiCad closed, or tell the user to restart it",+            "library names are case-sensitive; library_not_registered means the name is not in that version's table",+        ],+    },     Verb {         name: "kicad_install_symbol",         summary: "Merge the symbol(s) of a .kicad_sym into the single global Adom library (Adom.kicad_sym), creating and registering it on first use.",@@ -133,7 +148,23 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>     Some(match command {         "kicad_install_library" => {             let info = state.kicad_info();-            install::install_library(&lib_ctx(&info), info.installed, args)+            let mut out = install::install_library(&lib_ctx(&info), info.installed, args);+            // Wiki #111: a RUNNING KiCad holds its lib tables in memory, so a row written to disk now is+            // invisible to it, and the very next open of a part from this library fails. Say so here,+            // where the cause is known, instead of letting the open verb fail without one.+            if out.get("success").and_then(Value::as_bool).unwrap_or(false) {+                let running = crate::verbs_show::kicad_running();+                out["kicadRunning"] = serde_json::json!(running);+                out["tableReloadNeeded"] = serde_json::json!(running);+                if running {+                    out["_reloadHint"] = serde_json::json!("KiCad is RUNNING and read its library tables when it started: this library is on disk and in the table, but the running KiCad does not know it yet, so opening a part from it will fail with [no footprint loaded] / [no symbol loaded]. Next: kicad_rescan_libraries {\"footprints\":[\"<Lib>:<Name>\"],\"force\":true,\"confirmRestart\":true} (restarts KiCad, windows put back where they were; ask the user first if they have work open), or have the user press OK in Preferences > Manage Footprint/Symbol Libraries. Headless verbs (kicad_lint_library, exports, kicad-cli) already see it.");+                }+            }+            out+        }+        "kicad_uninstall_library" => {+            let info = state.kicad_info();+            install::uninstall_library(&lib_ctx(&info), crate::verbs_show::kicad_running(), args)         }         "kicad_install_symbol" => {             let info = state.kicad_info();
rust/crates/kicad-bridge/src/verbs_windows.rs+26
@@ -2020,6 +2020,32 @@ pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {         "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),         "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",     });+    // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running+    // KiCad may not have: no such library row, no such footprint in the library, or both are there and+    // this KiCad simply started before the library was registered.+    if !library_name.is_empty() {+        let ctx = lib_ctx(&state.kicad_info());+        let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));+        match libs.first() {+            None => {+                v["errorCode"] = json!("library_not_registered");+                v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));+                v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");+            }+            Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {+                v["errorCode"] = json!("footprint_not_in_library");+                v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));+                v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());+                v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");+            }+            Some((_, info)) => {+                v["errorCode"] = json!("library_not_loaded_by_running_kicad");+                v["libraryOnDisk"] = json!(info.path);+                v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));+                v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));+            }+        }+    }     merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));     v }
rust/crates/kicad-core/src/install.rs+197
@@ -2018,6 +2018,133 @@ pub fn export_part(ctx: &LibCtx, args: &Value) -> Value {     }) } +// ---- uninstall_library ---------------------------------------------------------------------++/// The exact inverse of `install_library`: remove a library's row from the user's+/// sym-lib-table and/or fp-lib-table and, only when asked, delete the files the install copied.+///+/// Safety rules (each refusal is a named errorCode with the next call in the hint):+/// - the shared `Adom` library is never removed here (every Adom part lives in it);+/// - a row whose uri is a KiCad stock path (`${KICAD*_..._DIR}`) is never removed: that is+///   KiCad's own library set, not something the bridge installed;+/// - `deleteFiles` only ever deletes inside `Documents/KiCad/<ver>/{symbols,footprints,3dmodels}`,+///   the folders the install verbs write to. A library registered in place from anywhere else+///   is unregistered and its files are left alone (`filesKept` says why).+pub fn uninstall_library(ctx: &LibCtx, kicad_running: bool, args: &Value) -> Value {+    let name = s_arg(args, "libraryName");+    if name.is_empty() {+        return json!({"success": false, "errorCode": "library_name_required",+            "error": "libraryName is required",+            "Hint": "kicad_uninstall_library {\"libraryName\":\"MyParts\"}; the name is the row name in Preferences > Manage Symbol/Footprint Libraries (kicad_install_library returned it as data.name)."});+    }+    let which = s_arg(args, "libraryType").to_lowercase();+    if !matches!(which.as_str(), "" | "both" | "symbol" | "footprint") {+        return json!({"success": false, "errorCode": "bad_library_type",+            "error": format!("libraryType must be symbol, footprint or both (default), got '{which}'"),+            "Hint": "omit libraryType to remove the name from both tables"});+    }+    if name.eq_ignore_ascii_case("Adom") {+        return json!({"success": false, "errorCode": "adom_library_protected",+            "error": "the shared 'Adom' library is not removed by this verb: every part installed with kicad_install_symbol / kicad_install_footprint lives in it",+            "Hint": "remove one part with the Symbol/Footprint Editor, or repair the library with kicad_adom_library_heal"});+    }+    let delete_files = args.get("deleteFiles").and_then(Value::as_bool).unwrap_or(false);+    let user_dir = ctx.user_dir.clone();+    let mut removed: Vec<Value> = Vec::new();+    let mut files_deleted: Vec<String> = Vec::new();+    let mut files_kept: Vec<Value> = Vec::new();+    let mut stock: Vec<String> = Vec::new();++    let tables: Vec<(&str, Option<PathBuf>)> = vec![("symbol", ctx.sym_lib_table()), ("footprint", ctx.fp_lib_table())];+    for (kind, table_path) in tables {+        if !(which.is_empty() || which == "both" || which == kind) {+            continue;+        }+        let Some(table_path) = table_path else { continue };+        if !table_path.is_file() {+            continue;+        }+        let mut table = match LibTable::parse_file(&table_path) {+            Ok(t) => t,+            Err(e) => return json!({"success": false, "errorCode": "lib_table_unreadable",+                "error": format!("cannot parse {}: {e}", table_path.display()),+                "Hint": "kicad_adom_library_heal repairs a damaged lib table"}),+        };+        let Some(entry) = table.get(&name).cloned() else { continue };+        // A fresh KiCad 10 install holds ALL its stock libraries in one row of type "Table" (named+        // "KiCad", uri = the template table, an absolute path with no ${KICAD..} in it). Removing that+        // row would take every stock library away, so a nested-table row is stock by definition.+        if entry.lib_type.eq_ignore_ascii_case("Table") {+            stock.push(format!("{kind}: nested table {}", entry.uri));+            continue;+        }+        if entry.uri.contains("${KICAD") && entry.uri.contains("_DIR}") && !entry.uri.contains("3RD_PARTY") {+            stock.push(format!("{kind}: {}", entry.uri));+            continue;+        }+        table.remove_library(&name);+        if let Err(e) = table.write_file(&table_path) {+            return json!({"success": false, "errorCode": "lib_table_write_failed",+                "error": format!("cannot write {}: {e}", table_path.display()),+                "Hint": "close KiCad's Manage Libraries dialog if it is open, then retry"});+        }+        removed.push(json!({"table": if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" }, "uri": entry.uri}));++        let resolved = crate::libraries::expand_kicad_uri(&entry.uri, ctx).unwrap_or_else(|| entry.uri.clone());+        let target = PathBuf::from(&resolved);+        if !delete_files {+            continue;+        }+        let managed = user_dir.as_ref().map(|u| {+            let sub = u.join(if kind == "symbol" { "symbols" } else { "footprints" });+            // a path component boundary, not a bare string prefix: "footprints-old/x" is NOT under "footprints"+            let (t, base) = (crate::libraries::norm_path(&target), format!("{}/", crate::libraries::norm_path(&sub).trim_end_matches('/')));+            t.starts_with(&base) && !t.split('/').any(|c| c == "..")+        }).unwrap_or(false);+        if !managed {+            files_kept.push(json!({"path": resolved, "why": "outside Documents/KiCad/<ver>/symbols|footprints: registered in place, not a copy the bridge made"}));+            continue;+        }+        let gone = if target.is_dir() { std::fs::remove_dir_all(&target).is_ok() } else if target.is_file() { std::fs::remove_file(&target).is_ok() } else { false };+        if gone {+            files_deleted.push(resolved.clone());+        }+        if kind == "footprint" {+            if let Some(u) = &user_dir {+                let models = u.join("3dmodels").join(format!("{name}.3dshapes"));+                if models.is_dir() && std::fs::remove_dir_all(&models).is_ok() {+                    files_deleted.push(models.to_string_lossy().replace('\\', "/"));+                }+            }+        }+    }++    if removed.is_empty() {+        if !stock.is_empty() {+            return json!({"success": false, "errorCode": "stock_library_protected", "libraryName": name, "stock": stock,+                "error": format!("'{name}' is one of KiCad's own stock libraries; this verb only removes libraries that were added to the user tables"),+                "Hint": "disable a stock library in Preferences > Manage Libraries instead (the Active checkbox)"});+        }+        return json!({"success": false, "errorCode": "library_not_registered", "libraryName": name,+            "error": format!("no library named '{name}' in the {} lib table(s) of KiCad {}", if which.is_empty() || which == "both" { "symbol or footprint" } else { which.as_str() }, ctx.version),+            "Hint": "names are case-sensitive and per KiCad version: kicad_list_versions, then pass kicadVersion; kicad_show_library lists what is registered"});+    }+    let mut out = json!({"success": true, "libraryName": name, "kicadVersion": ctx.version,+        "removed": removed, "filesDeleted": files_deleted, "filesKept": files_kept,+        "deleteFiles": delete_files, "kicadRunning": kicad_running, "tableReloadNeeded": kicad_running});+    if !stock.is_empty() {+        out["stockRowsLeft"] = json!(stock);+    }+    out["Hint"] = json!(if kicad_running {+        "Removed from the table on disk. KiCad is RUNNING and holds its lib tables in memory: the library stays listed (and usable) in this session until KiCad restarts or the user presses OK in Preferences > Manage Libraries. If KiCad then saves its tables it can write the row back: prefer uninstalling with KiCad closed."+    } else if delete_files {+        "Removed from the lib table(s) and the installed copy deleted. kicad_install_library puts it back."+    } else {+        "Removed from the lib table(s); the library files were left in place. Pass deleteFiles:true to also delete the copy under Documents/KiCad/<ver>/ (never files outside it)."+    });+    out+}+ // --------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------@@ -2554,4 +2681,74 @@ mod tests {         assert!(w.contains("(model \"C:/new.step\" (offset"), "{w}");         assert!(!w.contains("old.step"));     }++    // ---- uninstall_library ----++    fn uninstall_ctx(root: &Path) -> LibCtx {+        let cfg = root.join("cfg"); let user = root.join("user");+        std::fs::create_dir_all(&cfg).unwrap();+        std::fs::create_dir_all(user.join("footprints").join("Mine.pretty")).unwrap();+        std::fs::create_dir_all(user.join("3dmodels").join("Mine.3dshapes")).unwrap();+        std::fs::write(user.join("footprints").join("Mine.pretty").join("a.kicad_mod"), "(footprint \"a\")").unwrap();+        let fp_uri = user.join("footprints").join("Mine.pretty").to_string_lossy().replace('\\', "/");+        std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n  (version 7)\n  (lib (name \"Mine\")(type \"KiCad\")(uri \"{fp_uri}\")(options \"\")(descr \"\"))\n  (lib (name \"Elsewhere\")(type \"KiCad\")(uri \"{}/outside.pretty\")(options \"\")(descr \"\"))\n  (lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${{KICAD10_FOOTPRINT_DIR}}/Resistor_SMD.pretty\")(options \"\")(descr \"\"))\n)\n", root.to_string_lossy().replace('\\', "/"))).unwrap();+        std::fs::write(cfg.join("sym-lib-table"), "(sym_lib_table\n  (version 7)\n)\n").unwrap();+        std::fs::create_dir_all(root.join("outside.pretty")).unwrap();+        LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() }+    }++    #[test]+    fn uninstall_removes_row_and_only_managed_files() {+        let root = unique_temp_dir("kb-uninstall-a");+        let ctx = uninstall_ctx(&root);+        let r = uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "deleteFiles": true}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["removed"].as_array().unwrap().len(), 1);+        assert!(!ctx.user_dir.as_ref().unwrap().join("footprints").join("Mine.pretty").exists());+        assert!(!ctx.user_dir.as_ref().unwrap().join("3dmodels").join("Mine.3dshapes").exists());+        let table = LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap();+        assert!(!table.has_library("Mine") && table.has_library("Elsewhere") && table.has_library("Resistor_SMD"));++        // registered in place, outside the managed folders: unregistered, files kept+        let r = uninstall_library(&ctx, true, &json!({"libraryName": "Elsewhere", "deleteFiles": true}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert!(root.join("outside.pretty").exists());+        assert_eq!(r["filesKept"].as_array().unwrap().len(), 1);+        assert_eq!(r["tableReloadNeeded"], json!(true));+        let _ = std::fs::remove_dir_all(&root);+    }++    #[test]+    fn uninstall_never_removes_the_nested_stock_table_and_respects_the_folder_boundary() {+        let root = unique_temp_dir("kb-uninstall-c");+        let ctx = uninstall_ctx(&root);+        let cfg = ctx.config_dir.clone().unwrap();+        let user = ctx.user_dir.clone().unwrap();+        // a fresh KiCad 10 box: ONE row of type Table carries every stock library+        let sibling = user.join("footprints-old").join("Sib.pretty");+        std::fs::create_dir_all(&sibling).unwrap();+        let sib_uri = sibling.to_string_lossy().replace('\\', "/");+        std::fs::write(cfg.join("fp-lib-table"), format!("(fp_lib_table\n  (version 7)\n  (lib (name \"KiCad\")(type \"Table\")(uri \"C:/Program Files/KiCad/10.0/share/kicad/template/fp-lib-table\")(options \"\")(descr \"KiCad Default Libraries\"))\n  (lib (name \"Sib\")(type \"KiCad\")(uri \"{sib_uri}\")(options \"\")(descr \"\"))\n)\n")).unwrap();+        let r = uninstall_library(&ctx, false, &json!({"libraryName": "KiCad", "deleteFiles": true}));+        assert_eq!(r["errorCode"], json!("stock_library_protected"), "{r}");+        assert!(LibTable::parse_file(&cfg.join("fp-lib-table")).unwrap().has_library("KiCad"));+        // "footprints-old" shares a string prefix with "footprints" but is not inside it: files stay+        let r = uninstall_library(&ctx, false, &json!({"libraryName": "Sib", "deleteFiles": true}));+        assert_eq!(r["success"], json!(true), "{r}");+        assert!(sibling.is_dir() && r["filesDeleted"].as_array().unwrap().is_empty());+        let _ = std::fs::remove_dir_all(&root);+    }++    #[test]+    fn uninstall_refuses_adom_stock_and_unknown() {+        let root = unique_temp_dir("kb-uninstall-b");+        let ctx = uninstall_ctx(&root);+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Adom"}))["errorCode"], json!("adom_library_protected"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Resistor_SMD"}))["errorCode"], json!("stock_library_protected"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Nope"}))["errorCode"], json!("library_not_registered"));+        assert_eq!(uninstall_library(&ctx, false, &json!({}))["errorCode"], json!("library_name_required"));+        assert_eq!(uninstall_library(&ctx, false, &json!({"libraryName": "Mine", "libraryType": "banana"}))["errorCode"], json!("bad_library_type"));+        assert!(LibTable::parse_file(&ctx.fp_lib_table().unwrap()).unwrap().has_library("Resistor_SMD"));+        let _ = std::fs::remove_dir_all(&root);+    } }