master
John Lauer 1.0.1: 1.0.1: Freerouting on the user's terms. kicad_freerouting {status|install|uninstall} installs Freerouting's own… 471d9cf 1mo ago
//! 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");
    r.rm_tree(&plat.freerouting_dir(), "Freerouting engine (kicad_freerouting install)");
    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);
    }
}