← Commit history
rust/crates/kicad-bridge/src/catalog.rs+5−5
@@ -153,11 +153,11 @@ pub static VERBS: &[Verb] = &[     },     Verb {         name: "kicad_uninstall",-        summary: "Remove only this bridge's own artifacts (scratch dir, backups it made). Never KiCad, never user data.",-        mechanism: Mechanism::Local, risk: "write", timeout_sec: 120,-        input: "{\"cascade\"?: bool}", example: "kicad_uninstall {}",-        hint: "ab calls this during bridge_uninstall; it is idempotent and non-interactive.",-        related: &[], pitfalls: &[],+        summary: "Remove everything this bridge created: plugin payload, Adom symbol/footprint/3D libraries and their lib-table rows, Mesa fallback DLLs, discovery files, demo and staging projects. Never KiCad itself, never user projects.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{\"dryRun\"?: bool, \"testResidue\"?: bool, \"cascade\"?: bool}", example: "kicad_uninstall {\"dryRun\": true}",+        hint: "ab calls this during bridge_uninstall (cascade). Idempotent and non-interactive; dryRun previews. testResidue removes only what the verb runner leaves behind (fixture dir, demo project, ADOM_VERBTEST symbol and footprint, AdomVerbTest rows, stale discovery files).",+        related: &["kicad_adom_library_status"], pitfalls: &["A running KiCad may hold the plugin's .pyc open; re-run after KiCad exits if anything failed."],     }, ]; 
rust/crates/kicad-bridge/src/verbs.rs+1−15
@@ -41,7 +41,7 @@ pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Cal         "kicad_export_svg" => export(state, args, if is_sch(args) { "sch" } else { "pcb" }, "svg", "SVG"),         "kicad_export_step" => export(state, args, "pcb", "step", "STEP"),         "kicad_export_bom_csv" => export(state, args, "sch", "bom", "BOM CSV"),-        "kicad_uninstall" => uninstall(),+        "kicad_uninstall" => { let info = state.kicad_info(); kicad_core::uninstall::handle(&info.installs, args) }         _ => fail("not_implemented", format!("{command} is catalogued but not implemented in this phase"), "Use the Python bridge for this verb until the phase that ports it lands."),     };     if let Some(o) = out.as_object_mut() {@@ -255,17 +255,3 @@ fn export(state: &mut State, args: &Value, doc: &str, export_type: &str, label:     }) } -fn uninstall() -> Value {-    let mut cleaned: Vec<String> = Vec::new();-    for name in ["adom-kicad-drc", "adom-kicad-erc"] {-        if let Ok(rd) = std::fs::read_dir(std::env::temp_dir()) {-            for e in rd.flatten() {-                let n = e.file_name().to_string_lossy().to_string();-                if n.starts_with(name) && n.ends_with(".json") && std::fs::remove_file(e.path()).is_ok() {-                    cleaned.push(n);-                }-            }-        }-    }-    json!({"success": true, "cleaned": cleaned, "_hint": "Only bridge scratch files are removed. KiCad, its settings and user libraries are untouched."})-}
rust/crates/kicad-core/src/lib.rs+1
@@ -16,3 +16,4 @@ pub mod bridge_log; pub mod install; #[cfg(feature = "ipc")] pub mod ipc;+pub mod uninstall;
rust/crates/kicad-core/src/uninstall.rsadded+276
@@ -0,0 +1,276 @@+//! kicad_uninstall: remove everything this bridge ever created (Bridge SDK uninstall+//! contract, kicad-bridge issue #18). Idempotent, non-interactive, best effort per item,+//! scoped to our own artifacts. Port of handlers/uninstall.py; `testResidue` is the+//! subset the verb runner leaves behind.++use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::libraries::LibTable;+use kicad_platform::native;++const MESA_DLLS: [&str; 3] = ["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];++struct Report {+    dry: bool,+    removed: Vec<String>,+    failed: Vec<Value>,+    absent: usize,+}++impl Report {+    fn rm_file(&mut self, p: &Path, label: &str) {+        if !p.exists() {+            self.absent += 1;+            return;+        }+        if !self.dry {+            if let Err(e) = std::fs::remove_file(p) {+                self.failed.push(json!({"item": format!("{label}: {}", p.display()), "error": e.to_string()}));+                return;+            }+        }+        self.removed.push(format!("{label}: {}", p.display()));+    }+    fn rm_tree(&mut self, p: &Path, label: &str) {+        if !p.exists() {+            self.absent += 1;+            return;+        }+        if !self.dry {+            if let Err(e) = remove_tree_force(p) {+                self.failed.push(json!({"item": format!("{label}: {}", p.display()), "error": e}));+                return;+            }+        }+        self.removed.push(format!("{label}: {}", p.display()));+    }+    fn remove_row(&mut self, table_path: &Path, nickname: &str) {+        if !table_path.exists() {+            self.absent += 1;+            return;+        }+        match LibTable::parse_file(table_path) {+            Ok(mut t) => {+                if t.has_library(nickname) {+                    if !self.dry {+                        t.remove_library(nickname);+                        if let Err(e) = t.write_file(table_path) {+                            self.failed.push(json!({"item": format!("'{nickname}' row in {}", table_path.display()), "error": e}));+                            return;+                        }+                    }+                    self.removed.push(format!("'{nickname}' row in {}", table_path.display()));+                } else {+                    self.absent += 1;+                }+            }+            Err(e) => self.failed.push(json!({"item": format!("'{nickname}' row in {}", table_path.display()), "error": e})),+        }+    }+}++/// rmtree that clears read-only bits on the way (the Python's onerror chmod).+fn remove_tree_force(p: &Path) -> Result<(), String> {+    if let Err(first) = std::fs::remove_dir_all(p) {+        if let Ok(rd) = std::fs::read_dir(p) {+            for e in rd.flatten() {+                if let Ok(md) = e.metadata() {+                    let mut perm = md.permissions();+                    if perm.readonly() {+                        #[allow(clippy::permissions_set_readonly_false)]+                        perm.set_readonly(false);+                        let _ = std::fs::set_permissions(e.path(), perm);+                    }+                }+            }+        }+        std::fs::remove_dir_all(p).map_err(|e| format!("{first}; retry: {e}"))?;+    }+    Ok(())+}++fn subdirs(root: Option<PathBuf>) -> Vec<PathBuf> {+    let Some(root) = root else { return Vec::new() };+    let Ok(rd) = std::fs::read_dir(&root) else { return Vec::new() };+    rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.path()).collect()+}++fn glob_prefix(dir: &Path, prefix: &str, suffix: &str) -> Vec<PathBuf> {+    let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };+    rd.flatten()+        .filter(|e| {+            let n = e.file_name().to_string_lossy().to_string();+            n.starts_with(prefix) && n.ends_with(suffix)+        })+        .map(|e| e.path())+        .collect()+}++/// Every KiCad bin dir this bridge may have dropped the Mesa fallback into.+fn bin_dirs(installs: &[crate::detect::KicadInstall]) -> Vec<PathBuf> {+    let mut out: Vec<PathBuf> = Vec::new();+    for i in installs {+        let b = PathBuf::from(&i.bin_dir);+        if b.is_dir() && !out.iter().any(|x| x.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) {+            out.push(b);+        }+    }+    out+}++pub fn handle(installs: &[crate::detect::KicadInstall], args: &Value) -> Value {+    let dry = args.get("dryRun").and_then(Value::as_bool).unwrap_or(false);+    let test_residue = args.get("testResidue").and_then(Value::as_bool).unwrap_or(false);+    let mut r = Report { dry, removed: Vec::new(), failed: Vec::new(), absent: 0 };+    let plat = native();+    if test_residue {+        return remove_test_residue(&mut r);+    }+    for ver_dir in subdirs(plat.user_root()) {+        let site = ver_dir.join("3rdparty").join("Python311").join("site-packages");+        r.rm_file(&site.join("adom_bridge.py"), "plugin payload");+        r.rm_file(&site.join("usercustomize.py"), "plugin loader");+        let pyc = site.join("__pycache__");+        if pyc.is_dir() {+            for f in glob_prefix(&pyc, "adom_bridge", ".pyc").into_iter().chain(glob_prefix(&pyc, "usercustomize", ".pyc")) {+                r.rm_file(&f, "plugin pycache");+            }+        }+        r.rm_file(&ver_dir.join("symbols").join("Adom.kicad_sym"), "Adom symbol library");+        r.rm_tree(&ver_dir.join("footprints").join("Adom.pretty"), "Adom footprint library");+        r.rm_tree(&ver_dir.join("3dmodels").join("Adom"), "Adom 3D model library");+    }+    for cfg in subdirs(plat.config_root()) {+        for table in ["sym-lib-table", "fp-lib-table"] {+            r.remove_row(&cfg.join(table), "Adom");+        }+    }+    for b in bin_dirs(installs) {+        for dll in MESA_DLLS {+            r.rm_file(&b.join(dll), "mesa fallback");+            r.rm_file(&b.join(format!("{dll}.local")), "mesa fallback marker");+        }+        for loc in glob_prefix(&b, "", ".exe.local") {+            r.rm_file(&loc, "mesa dotlocal");+        }+    }+    for f in glob_prefix(&std::env::temp_dir(), "adom-kicad-bridge-", ".json") {+        r.rm_file(&f, "plugin discovery file");+    }+    r.rm_tree(Path::new("C:/tmp/adom-desktop-screenshots"), "screenshot cache");+    if let Some(docs) = plat.documents_dir() {+        r.rm_tree(&docs.join("adom-kicad-demo"), "demo project");+        r.rm_tree(&docs.join("adom-part-exports"), "one-off part exports");+        r.rm_tree(&docs.join("adom-part-staging"), "part install staging");+    }+    let ok = r.failed.is_empty();+    json!({+        "success": ok,+        "cascade": args.get("cascade").and_then(Value::as_bool).unwrap_or(false),+        "dryRun": dry,+        "removed": r.removed,+        "failed": r.failed,+        "alreadyClean": r.removed.is_empty() && ok,+        "summary": format!("{} {} item(s), {} failed, {} already absent", if dry { "would remove" } else { "removed" }, r.removed.len(), r.failed.len(), r.absent),+        "_hint": "Bridge-created artifacts only: KiCad itself, user projects, and other bridges are untouched. Idempotent: safe to re-run. A running KiCad may hold the plugin's .pyc open; re-run after KiCad exits if anything failed. Bridge removes its own bridges-cache during the cascade.",+    })+}++fn remove_test_residue(r: &mut Report) -> Value {+    let plat = native();+    if let Some(docs) = plat.documents_dir() {+        r.rm_tree(&docs.join("adom-kicad-verbtest"), "verbtest fixture dir");+        r.rm_tree(&docs.join("adom-kicad-demo"), "demo project");+    }+    for ver_dir in subdirs(plat.user_root()) {+        r.rm_file(&ver_dir.join("footprints").join("Adom.pretty").join("ADOM_VERBTEST.kicad_mod"), "test footprint");+        let sym = ver_dir.join("symbols").join("Adom.kicad_sym");+        if sym.exists() {+            match std::fs::read_to_string(&sym) {+                Ok(text) => match crate::libraries::extract_top_level_symbols(&text) {+                    Ok(blocks) => {+                        let hits: Vec<&String> = blocks.iter().filter(|(n, _)| n == "ADOM_VERBTEST").map(|(_, b)| b).collect();+                        if !hits.is_empty() {+                            if !r.dry {+                                let mut out = text.clone();+                                for b in &hits {+                                    out = out.replacen(b.as_str(), "", 1);+                                }+                                if let Err(e) = std::fs::write(&sym, out) {+                                    r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e.to_string()}));+                                    continue;+                                }+                            }+                            r.removed.push(format!("ADOM_VERBTEST symbol in {}", sym.display()));+                        }+                    }+                    Err(e) => r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e})),+                },+                Err(e) => r.failed.push(json!({"item": format!("ADOM_VERBTEST in {}", sym.display()), "error": e.to_string()})),+            }+        }+    }+    for cfg in subdirs(plat.config_root()) {+        for table in ["sym-lib-table", "fp-lib-table"] {+            let tp = cfg.join(table);+            if tp.exists() {+                r.remove_row(&tp, "AdomVerbTest");+            }+        }+    }+    for f in glob_prefix(&std::env::temp_dir(), "adom-kicad-bridge-", ".json") {+        let stem = f.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+        match stem.rsplit('-').next().and_then(|p| p.parse::<u32>().ok()) {+            None => r.rm_file(&f, "legacy discovery file"),+            Some(pid) => {+                if plat.process_alive(pid) == Some(false) {+                    r.rm_file(&f, "stale discovery file");+                }+            }+        }+    }+    let ok = r.failed.is_empty();+    json!({+        "success": ok, "testResidue": true, "dryRun": r.dry,+        "removed": r.removed, "failed": r.failed,+        "alreadyClean": r.removed.is_empty() && ok,+        "summary": format!("{} {} test-residue item(s), {} failed", if r.dry { "would remove" } else { "removed" }, r.removed.len(), r.failed.len()),+        "_hint": "Suite residue only: plugin, Adom libraries, KiCad and user files untouched. Run after every verb-suite pass; idempotent.",+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn dry_run_reports_without_touching() {+        let tmp = std::env::temp_dir().join(format!("kb-uninstall-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&tmp);+        let demo = tmp.join("adom-kicad-demo");+        std::fs::create_dir_all(&demo).unwrap();+        std::fs::write(demo.join("x.txt"), "x").unwrap();+        let mut r = Report { dry: true, removed: vec![], failed: vec![], absent: 0 };+        r.rm_tree(&demo, "demo project");+        r.rm_file(&tmp.join("nope"), "absent");+        assert_eq!(r.removed.len(), 1);+        assert_eq!(r.absent, 1);+        assert!(demo.is_dir(), "dry run must not delete");+        let mut r = Report { dry: false, removed: vec![], failed: vec![], absent: 0 };+        r.rm_tree(&demo, "demo project");+        assert!(!demo.exists());+        let _ = std::fs::remove_dir_all(&tmp);+    }++    #[test]+    fn stale_discovery_pid_parsing() {+        let f = PathBuf::from("adom-kicad-bridge-12345.json");+        let stem = f.file_stem().unwrap().to_string_lossy().to_string();+        assert_eq!(stem.rsplit('-').next().unwrap().parse::<u32>().ok(), Some(12345));+        let f = PathBuf::from("adom-kicad-bridge-legacy.json");+        let stem = f.file_stem().unwrap().to_string_lossy().to_string();+        assert_eq!(stem.rsplit('-').next().unwrap().parse::<u32>().ok(), None);+    }+}
rust/crates/kicad-platform/src/lib.rs+8
@@ -64,6 +64,14 @@ pub trait Platform: Sync + Send {     fn user_dir(&self, version: &str) -> Option<PathBuf>;     /// Make a child process invisible and detached from any console.     fn quiet_command(&self, cmd: &mut Command);+    /// Parent of every per-version settings dir (all KiCad versions the user ever ran).+    fn config_root(&self) -> Option<PathBuf>;+    /// Parent of every per-version documents dir (Documents/KiCad).+    fn user_root(&self) -> Option<PathBuf>;+    /// The user's Documents folder (where the bridge keeps demo and staging projects).+    fn documents_dir(&self) -> Option<PathBuf>;+    /// Is a process with this id alive? None when the OS layer cannot tell.+    fn process_alive(&self, pid: u32) -> Option<bool>; }  /// Standard "this build cannot do that here" reply.
rust/crates/kicad-platform/src/linux.rs+13
@@ -64,4 +64,17 @@ impl Platform for Native {     fn quiet_command(&self, cmd: &mut Command) {         cmd.stdin(std::process::Stdio::null());     }+    fn config_root(&self) -> Option<PathBuf> {+        let base = std::env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;+        Some(base.join("kicad"))+    }+    fn user_root(&self) -> Option<PathBuf> {+        Some(home()?.join("Documents/KiCad"))+    }+    fn documents_dir(&self) -> Option<PathBuf> {+        Some(home()?.join("Documents"))+    }+    fn process_alive(&self, pid: u32) -> Option<bool> {+        Some(std::path::Path::new(&format!("/proc/{pid}")).exists())+    } }
rust/crates/kicad-platform/src/macos.rs+15
@@ -53,4 +53,19 @@ impl Platform for Native {     fn quiet_command(&self, cmd: &mut Command) {         cmd.stdin(std::process::Stdio::null());     }+    fn config_root(&self) -> Option<PathBuf> {+        Some(home()?.join("Library/Preferences/kicad"))+    }+    fn user_root(&self) -> Option<PathBuf> {+        Some(home()?.join("Documents/KiCad"))+    }+    fn documents_dir(&self) -> Option<PathBuf> {+        Some(home()?.join("Documents"))+    }+    fn process_alive(&self, pid: u32) -> Option<bool> {+        let mut cmd = Command::new("kill");+        cmd.args(["-0", &pid.to_string()]);+        self.quiet_command(&mut cmd);+        Some(cmd.output().ok()?.status.success())+    } }
rust/crates/kicad-platform/src/windows.rs+19
@@ -110,4 +110,23 @@ impl Platform for Native {         cmd.stdin(std::process::Stdio::null());         cmd.creation_flags(CREATE_NO_WINDOW);     }+    fn config_root(&self) -> Option<PathBuf> {+        Some(appdata()?.join("kicad"))+    }+    fn user_root(&self) -> Option<PathBuf> {+        Some(userprofile()?.join("Documents").join("KiCad"))+    }+    fn documents_dir(&self) -> Option<PathBuf> {+        Some(userprofile()?.join("Documents"))+    }+    fn process_alive(&self, pid: u32) -> Option<bool> {+        // Phase 3 replaces this with OpenProcess through the windows crate; until then a+        // quiet tasklist query (no console window) answers the question.+        let mut cmd = Command::new("tasklist");+        cmd.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]);+        self.quiet_command(&mut cmd);+        let out = cmd.output().ok()?;+        let text = String::from_utf8_lossy(&out.stdout);+        Some(text.contains(&format!("\"{pid}\"")))+    } }