Skip to main content

lean_ctx/uninstall/
mod.rs

1mod agents;
2mod binary;
3mod parsers;
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use agents::{
9    remove_hook_files, remove_mcp_configs, remove_plan_mode_settings, remove_project_agent_files,
10    remove_rules_files, remove_shell_hook,
11};
12
13pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
14    if dry_run {
15        return;
16    }
17    if path.exists() {
18        let bak = bak_path_for(path);
19        let _ = fs::copy(path, &bak);
20    }
21}
22
23pub fn bak_path_for(path: &Path) -> PathBuf {
24    let filename = path.file_name().unwrap_or_default().to_string_lossy();
25    path.with_file_name(format!("{filename}.lean-ctx.bak"))
26}
27
28fn cleanup_bak(path: &Path) {
29    let bak = bak_path_for(path);
30    if bak.exists() {
31        let _ = fs::remove_file(&bak);
32    }
33}
34
35pub(super) fn shorten(path: &Path, home: &Path) -> String {
36    match path.strip_prefix(home) {
37        Ok(rel) => format!("~/{}", rel.display()),
38        Err(_) => path.display().to_string(),
39    }
40}
41
42pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
43    #[cfg(target_os = "macos")]
44    {
45        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
46    }
47    #[cfg(target_os = "linux")]
48    {
49        let user_dirs = [
50            home.join(".config/Code/User"),
51            home.join(".config/Code - Insiders/User"),
52            home.join(".vscode-server/data/User"),
53        ];
54        let user_dir = user_dirs
55            .iter()
56            .find(|p| p.exists())
57            .cloned()
58            .unwrap_or_else(|| user_dirs[0].clone());
59        return user_dir.join("github-copilot-instructions.md");
60    }
61    #[cfg(target_os = "windows")]
62    {
63        if let Ok(appdata) = std::env::var("APPDATA") {
64            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
65        }
66    }
67    #[allow(unreachable_code)]
68    home.join(".config/Code/User/github-copilot-instructions.md")
69}
70
71/// Write `content` to `path` only if not in dry-run mode.
72pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
73    if dry_run {
74        return Ok(());
75    }
76    fs::write(path, content)?;
77    // If we successfully wrote the cleaned file, the backup is no longer needed.
78    cleanup_bak(path);
79    Ok(())
80}
81
82/// Remove `path` only if not in dry-run mode.
83pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
84    if dry_run {
85        return Ok(());
86    }
87    fs::remove_file(path)?;
88    // If we successfully removed the file, also remove its backup.
89    cleanup_bak(path);
90    Ok(())
91}
92
93// ---------------------------------------------------------------------------
94// Help
95// ---------------------------------------------------------------------------
96
97/// Print usage for `lean-ctx uninstall`.
98///
99/// This MUST stay side-effect free: `lean-ctx uninstall --help` previously fell
100/// through to [`run`] and removed everything, so help is now short-circuited in
101/// the CLI dispatch before any removal happens.
102pub fn print_help() {
103    println!(
104        "\
105lean-ctx uninstall — remove lean-ctx cleanly
106
107USAGE:
108    lean-ctx uninstall [OPTIONS]
109
110OPTIONS:
111    --dry-run        Preview every change without modifying anything
112    --keep-config    Preserve MCP configs and rules (for a later reinstall)
113    --keep-binary    Leave the lean-ctx binary in place
114    -h, --help       Show this help and exit (does NOT uninstall)
115
116WHAT IT REMOVES:
117    • Running processes (daemon, proxy) and autostart entries
118    • Shell hooks and proxy environment from your shell rc files
119    • MCP server configs and rules from every detected AI tool/IDE
120    • Skill directories and project integration files
121    • The data directory and the lean-ctx binary
122
123    Modified files are backed up as <file>.lean-ctx.bak before removal.
124
125EXAMPLES:
126    lean-ctx uninstall --dry-run     # see exactly what would change
127    lean-ctx uninstall               # full clean removal"
128    );
129}
130
131// ---------------------------------------------------------------------------
132// Main entry
133// ---------------------------------------------------------------------------
134
135pub fn run(dry_run: bool, keep_config: bool, keep_binary: bool) {
136    let Some(home) = dirs::home_dir() else {
137        tracing::warn!("Could not determine home directory");
138        return;
139    };
140
141    let mode_label = if keep_config {
142        "uninstall --keep-config"
143    } else {
144        "uninstall"
145    };
146
147    if dry_run {
148        println!("\n  lean-ctx {mode_label} --dry-run\n  ──────────────────────────────────\n");
149        println!("  Preview mode — no files will be modified.\n");
150    } else {
151        println!("\n  lean-ctx {mode_label}\n  ──────────────────────────────────\n");
152    }
153
154    if keep_config {
155        println!("  Mode: keep-config (MCP configs and rules preserved for reinstall)\n");
156    }
157
158    // Stop everything first so nothing respawns or holds the files/data we remove next.
159    binary::stop_processes(dry_run);
160
161    let mut removed_any = false;
162
163    removed_any |= remove_shell_hook(&home, dry_run);
164    if dry_run {
165        crate::proxy_setup::preview_proxy_cleanup(&home);
166    } else {
167        crate::proxy_setup::uninstall_proxy_env(&home, false);
168    }
169
170    if keep_config {
171        println!("  · Skipped: MCP configs (--keep-config)");
172        println!("  · Skipped: Rules files (--keep-config)");
173    } else {
174        removed_any |= remove_mcp_configs(&home, dry_run);
175        removed_any |= remove_rules_files(&home, dry_run);
176        if !dry_run {
177            try_claude_mcp_remove();
178        }
179    }
180
181    removed_any |= remove_hook_files(&home, dry_run);
182    removed_any |= remove_plan_mode_settings(&home, dry_run);
183    removed_any |= remove_skill_dirs(&home, dry_run);
184    removed_any |= remove_project_agent_files(dry_run);
185
186    if dry_run {
187        println!("  Would remove proxy autostart (LaunchAgent/systemd)");
188        println!("  Would remove daemon autostart (LaunchAgent/systemd)");
189        println!("  Would remove auto-update schedule (LaunchAgent/systemd/Task)");
190    } else {
191        crate::proxy_autostart::uninstall(true);
192        crate::daemon_autostart::uninstall(true);
193        // The 6-hourly self-update agent (com.leanctx.autoupdate) is a *separate*
194        // autostart entry from daemon/proxy. Without this it survives uninstall and
195        // keeps relaunching the now-deleted binary every 6h. remove_schedule() is the
196        // same idempotent routine used elsewhere (macOS/Linux/Windows aware).
197        let had_schedule = crate::core::update_scheduler::schedule_status().enabled;
198        match crate::core::update_scheduler::remove_schedule() {
199            Ok(()) if had_schedule => {
200                println!("  ✓ Auto-update schedule removed");
201                removed_any = true;
202            }
203            Ok(()) => {}
204            Err(e) => tracing::warn!("Failed to remove auto-update schedule: {e}"),
205        }
206    }
207
208    if !dry_run {
209        cleanup_bak_files(&home);
210    }
211
212    removed_any |= remove_data_dir(&home, dry_run);
213
214    // Last filesystem step: every file-removal pass above has run, so
215    // installer-created directories that are empty now stay empty.
216    if !dry_run {
217        sweep_empty_installer_dirs(&home);
218    }
219
220    // Remove the binary itself last: once it's gone we can't re-exec, and on Unix the
221    // running process keeps working until exit.
222    removed_any |= binary::remove_binaries(&home, dry_run, keep_binary);
223
224    println!();
225
226    if removed_any {
227        println!("  ──────────────────────────────────");
228        if dry_run {
229            println!(
230                "  The above changes WOULD be applied.\n  Run `lean-ctx {mode_label}` to execute.\n"
231            );
232        } else if keep_config {
233            println!(
234                "  Runtime data removed. MCP configs preserved for reinstall.\n  \
235                 Reinstall with: cargo install lean-ctx\n"
236            );
237        } else {
238            println!(
239                "  lean-ctx fully removed. Restart your shell to drop stale aliases.\n  \
240                 Verify with: command -v lean-ctx   # should print nothing\n"
241            );
242        }
243    } else {
244        println!("  Nothing to remove — lean-ctx was not configured.\n");
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Marked block removal (for AGENTS.md, SharedMarkdown)
250// ---------------------------------------------------------------------------
251
252pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
253    let s = content.find(start);
254    let e = content.find(end);
255    match (s, e) {
256        (Some(si), Some(ei)) if ei >= si => {
257            let after_end = ei + end.len();
258            let before = &content[..si];
259            let after = &content[after_end..];
260            let mut out = String::new();
261            out.push_str(before.trim_end_matches('\n'));
262            out.push('\n');
263            if !after.trim().is_empty() {
264                out.push('\n');
265                out.push_str(after.trim_start_matches('\n'));
266            }
267            out
268        }
269        _ => content.to_string(),
270    }
271}
272
273// ---------------------------------------------------------------------------
274// Skill directories: lean-ctx SKILL.md + scripts
275// ---------------------------------------------------------------------------
276
277fn remove_skill_dirs(home: &Path, dry_run: bool) -> bool {
278    let claude_state = crate::core::editor_registry::claude_state_dir(home);
279    let codebuddy_state = crate::core::editor_registry::codebuddy_state_dir(home);
280    let mut skill_dirs: Vec<(&str, PathBuf)> = vec![
281        ("Claude Code", claude_state.join("skills/lean-ctx")),
282        ("CodeBuddy", codebuddy_state.join("skills/lean-ctx")),
283        ("Cursor", home.join(".cursor/skills/lean-ctx")),
284        (
285            "Codex CLI",
286            crate::core::home::resolve_codex_dir()
287                .unwrap_or_else(|| home.join(".codex"))
288                .join("skills/lean-ctx"),
289        ),
290        ("Grok", home.join(".grok/skills/lean-ctx")),
291        ("Copilot", home.join(".copilot/skills/lean-ctx")),
292        ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
293    ];
294
295    // If CLAUDE_CONFIG_DIR differs from ~/.claude, also clean default path
296    let default_claude_skill = home.join(".claude/skills/lean-ctx");
297    if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
298        skill_dirs.push(("Claude Code (default)", default_claude_skill));
299    }
300
301    // If CODEBUDDY_CONFIG_DIR differs from ~/.codebuddy, also clean default path
302    let default_codebuddy_skill = home.join(".codebuddy/skills/lean-ctx");
303    if !skill_dirs
304        .iter()
305        .any(|(_, p)| *p == default_codebuddy_skill)
306    {
307        skill_dirs.push(("CodeBuddy (default)", default_codebuddy_skill));
308    }
309
310    let mut removed = false;
311    for (name, dir) in &skill_dirs {
312        if !dir.exists() {
313            continue;
314        }
315        if dry_run {
316            println!("  Would remove {name} skill directory");
317            removed = true;
318        } else if let Err(e) = fs::remove_dir_all(dir) {
319            tracing::warn!("Failed to remove {name} skill dir: {e}");
320        } else {
321            println!("  ✓ {name} skill directory removed");
322            removed = true;
323        }
324    }
325    removed
326}
327
328// ---------------------------------------------------------------------------
329// Data directory
330// ---------------------------------------------------------------------------
331
332/// Every lean-ctx directory an uninstall must delete, de-duplicated and
333/// order-preserving.
334///
335/// Historically this used `dirs::data_dir()` / `dirs::data_local_dir()`, which on
336/// macOS both resolve to `~/Library/Application Support` — so the *real* runtime
337/// dirs (`~/.local/share`, `~/.local/state`, `~/.cache`) were never removed and a
338/// "full" uninstall left >150 MB of data + cache behind. We now resolve through the
339/// exact same [`core::paths`](crate::core::paths) functions the daemon/proxy use,
340/// so every XDG category (config/data/state/cache, honoring `LEAN_CTX_*_DIR` and
341/// `XDG_*` overrides) is covered, plus the legacy single-dir and macOS
342/// Application Support locations for older installs.
343fn data_dirs_to_remove(home: &Path) -> Vec<PathBuf> {
344    let mut dirs = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
345
346    let push = |dirs: &mut Vec<PathBuf>, p: PathBuf| {
347        if !dirs.contains(&p) {
348            dirs.push(p);
349        }
350    };
351
352    // Canonical XDG categories actually written at runtime.
353    for resolved in [
354        crate::core::paths::config_dir(),
355        crate::core::paths::data_dir(),
356        crate::core::paths::state_dir(),
357        crate::core::paths::cache_dir(),
358    ]
359    .into_iter()
360    .flatten()
361    {
362        push(&mut dirs, resolved);
363    }
364
365    // Older installs (and Windows %LOCALAPPDATA%) may have used the platform dir.
366    for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
367        .into_iter()
368        .flatten()
369    {
370        push(&mut dirs, platform_dir.join("lean-ctx"));
371    }
372
373    dirs
374}
375
376fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
377    let mut removed = false;
378
379    let dirs_to_remove = data_dirs_to_remove(home);
380
381    for data_dir in &dirs_to_remove {
382        if !data_dir.exists() {
383            continue;
384        }
385        let short = shorten(data_dir, home);
386        if dry_run {
387            println!("  Would remove data directory ({short})");
388            removed = true;
389            continue;
390        }
391        match fs::remove_dir_all(data_dir) {
392            Ok(()) => {
393                println!("  ✓ Data directory removed ({short})");
394                removed = true;
395            }
396            Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
397        }
398    }
399
400    // Project-local .lean-ctx/ and .lean-ctx-id in CWD
401    if let Ok(cwd) = std::env::current_dir() {
402        let project_dir = cwd.join(".lean-ctx");
403        let project_id = cwd.join(".lean-ctx-id");
404        for p in [&project_dir, &project_id] {
405            if p.exists() {
406                if dry_run {
407                    println!("  Would remove {}", p.display());
408                    removed = true;
409                } else if p.is_dir() {
410                    if fs::remove_dir_all(p).is_ok() {
411                        println!("  ✓ Removed {}", p.display());
412                        removed = true;
413                    }
414                } else if fs::remove_file(p).is_ok() {
415                    println!("  ✓ Removed {}", p.display());
416                    removed = true;
417                }
418            }
419        }
420    }
421
422    if !removed {
423        println!("  · No data directory found");
424    }
425    removed
426}
427
428fn try_claude_mcp_remove() {
429    let result = std::process::Command::new("claude")
430        .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
431        .stdout(std::process::Stdio::null())
432        .stderr(std::process::Stdio::null())
433        .status();
434    match result {
435        Ok(s) if s.success() => println!("  ✓ Removed lean-ctx from Claude MCP registry"),
436        _ => {} // claude CLI not available or already removed
437    }
438}
439
440// ---------------------------------------------------------------------------
441// .bak cleanup: remove orphaned backup files after successful surgical removal
442// ---------------------------------------------------------------------------
443
444/// Every directory the installer may have written backups or files into:
445/// agent config roots, their well-known subdirectories, and the project-local
446/// config dirs in CWD.
447fn scan_dirs(home: &Path) -> Vec<PathBuf> {
448    let base_dirs: Vec<PathBuf> = vec![
449        home.join(".cursor"),
450        home.join(".claude"),
451        crate::core::editor_registry::claude_state_dir(home),
452        home.join(".codebuddy"),
453        crate::core::editor_registry::codebuddy_state_dir(home),
454        crate::core::editor_registry::zed_config_dir(home),
455        home.join(".gemini"),
456        home.join(".gemini/antigravity"),
457        home.join(".gemini/antigravity-cli"),
458        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
459        home.join(".codeium"),
460        home.join(".codeium/windsurf"),
461        home.join(".config/opencode"),
462        home.join(".config/amp"),
463        home.join(".config/crush"),
464        home.join(".config/zed"),
465        home.join(".qwen"),
466        home.join(".trae"),
467        home.join(".aws/amazonq"),
468        home.join(".kiro"),
469        home.join(".kiro/settings"),
470        home.join(".ampcoder"),
471        home.join(".pi"),
472        home.join(".pi/agent"),
473        home.join(".hermes"),
474        home.join(".grok"),
475        home.join(".verdent"),
476        home.join(".cline"),
477        home.join(".roo"),
478        home.join(".continue"),
479        home.join(".jb-rules"),
480        home.join(".openclaw"),
481        home.join(".augment"),
482        home.join(".qoder"),
483        home.join(".qoderwork"),
484        home.join(".aider"),
485        home.join(".emacs.d"),
486        home.join(".copilot"),
487        home.join(".github"),
488        home.join(".config/mcphub"),
489        home.join(".config/sublime-text"),
490    ];
491
492    // Installers write into well-known subdirectories (hook scripts, rules
493    // files, steering docs, …). read_dir below is non-recursive, so backups in
494    // those subdirectories were previously missed (GL #558).
495    const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
496    let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
497    for dir in base_dirs {
498        for sub in KNOWN_SUBDIRS {
499            let p = dir.join(sub);
500            if p.is_dir() {
501                dirs_to_scan.push(p);
502            }
503        }
504        dirs_to_scan.push(dir);
505    }
506
507    // Project-local config dirs in CWD get the same backup treatment as HOME:
508    // setup writes (and uninstall removes) rules/hooks there too.
509    if let Ok(cwd) = std::env::current_dir() {
510        for rel in [
511            ".cursor/rules",
512            ".claude",
513            ".claude/rules",
514            ".claude/hooks",
515            ".codebuddy",
516            ".codebuddy/rules",
517            ".codebuddy/hooks",
518            ".kiro/steering",
519            ".github",
520            ".github/hooks",
521            ".vscode",
522        ] {
523            let p = cwd.join(rel);
524            if p.is_dir() {
525                dirs_to_scan.push(p);
526            }
527        }
528    }
529
530    dirs_to_scan
531}
532
533fn cleanup_bak_files(home: &Path) {
534    let dirs_to_scan = scan_dirs(home);
535    let mut cleaned = 0;
536    for dir in &dirs_to_scan {
537        if !dir.exists() {
538            continue;
539        }
540        if let Ok(entries) = fs::read_dir(dir) {
541            for entry in entries.flatten() {
542                let name = entry.file_name();
543                let name_str = name.to_string_lossy();
544                if name_str.ends_with(".lean-ctx.tmp") {
545                    let _ = fs::remove_file(entry.path());
546                    cleaned += 1;
547                    continue;
548                }
549                // Backups of our own hook scripts / rules files
550                // (lean-ctx-rewrite.sh.bak, lean-ctx.mdc.bak, …): the originals
551                // are lean-ctx-owned and already removed at this point, so the
552                // backups are pure leftovers.
553                if name_str.ends_with(".bak")
554                    && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
555                {
556                    let _ = fs::remove_file(entry.path());
557                    cleaned += 1;
558                    continue;
559                }
560                if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
561                    let _ = fs::remove_file(entry.path());
562                    cleaned += 1;
563                    continue;
564                }
565                if name_str.ends_with(".lean-ctx.bak") {
566                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
567                    let original = entry.path().with_file_name(original_name);
568                    if original.exists() {
569                        match fs::read_to_string(&original) {
570                            Ok(c) if !c.contains("lean-ctx") => {
571                                let _ = fs::remove_file(entry.path());
572                                cleaned += 1;
573                            }
574                            _ => {}
575                        }
576                    } else {
577                        let _ = fs::remove_file(entry.path());
578                        cleaned += 1;
579                    }
580                    continue;
581                }
582                // Plain .bak files next to known config files (created by
583                // config_io). Removed whether or not the original still exists:
584                // when uninstall deletes a config file that only contained
585                // lean-ctx content, its backup would otherwise be orphaned.
586                if name_str.ends_with(".bak")
587                    && !name_str.contains(".lean-ctx")
588                    && let Ok(bak_content) = fs::read_to_string(entry.path())
589                    && bak_content.contains("lean-ctx")
590                {
591                    let _ = fs::remove_file(entry.path());
592                    cleaned += 1;
593                }
594            }
595        }
596    }
597
598    // Also clean shell RC backups
599    let rc_baks = [
600        home.join(".zshrc.lean-ctx.bak"),
601        home.join(".zshenv.lean-ctx.bak"),
602        home.join(".bashrc.lean-ctx.bak"),
603        home.join(".bashenv.lean-ctx.bak"),
604    ];
605    for bak in &rc_baks {
606        if bak.exists() {
607            let original_name = bak
608                .file_name()
609                .unwrap_or_default()
610                .to_string_lossy()
611                .trim_end_matches(".lean-ctx.bak")
612                .to_string();
613            let original = bak.with_file_name(original_name);
614            if original.exists() {
615                if let Ok(c) = fs::read_to_string(&original)
616                    && !c.contains("lean-ctx")
617                {
618                    let _ = fs::remove_file(bak);
619                    cleaned += 1;
620                }
621            } else {
622                let _ = fs::remove_file(bak);
623                cleaned += 1;
624            }
625        }
626    }
627
628    if cleaned > 0 {
629        println!("  ✓ Cleaned up {cleaned} backup file(s)");
630    }
631}
632
633/// Sweep now-empty installer-created directories (hooks/, rules/, skills/,
634/// steering/). `fs::remove_dir` refuses to delete non-empty directories, so
635/// anything still holding user content survives untouched. Runs as the last
636/// filesystem step of `run()` — after every file-removal pass has finished.
637fn sweep_empty_installer_dirs(home: &Path) {
638    let mut swept = 0;
639    for dir in scan_dirs(home) {
640        let is_installer_dir = dir
641            .file_name()
642            .and_then(|n| n.to_str())
643            .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
644        if is_installer_dir && fs::remove_dir(&dir).is_ok() {
645            swept += 1;
646        }
647    }
648    if swept > 0 {
649        println!("  ✓ Removed {swept} empty installer director(y/ies)");
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use std::collections::HashSet;
657
658    #[test]
659    fn data_dirs_to_remove_covers_canonical_xdg_categories() {
660        // `core::paths::*_dir()` reads the (test-sandboxed) data-dir env, which a
661        // parallel `isolated_data_dir()` repoints under `test_env_lock`. We resolve
662        // those dirs twice — once inside `data_dirs_to_remove` and once in the
663        // assertion loop — so without the lock the value can flip between the two
664        // reads (override active → dropped) and the set won't contain the second
665        // value. Hold the lock so the env stays fixed across both reads (#991).
666        let _lock = crate::core::data_dir::test_env_lock();
667        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/tester"));
668        let dirs = data_dirs_to_remove(&home);
669
670        // Legacy single-dir + pre-split config dir are always targeted.
671        assert!(dirs.contains(&home.join(".lean-ctx")));
672        assert!(dirs.contains(&home.join(".config/lean-ctx")));
673
674        // Regression guard for the macOS data/state/cache leak (#uninstall-completeness):
675        // the set MUST include whatever core::paths actually resolves for every XDG
676        // category. The old dirs::data_dir()-based code missed these — on macOS they
677        // collapse onto Application Support — leaving the real ~/.local/share +
678        // ~/.local/state + ~/.cache (>150 MB) behind after a "full" uninstall.
679        for resolved in [
680            crate::core::paths::config_dir(),
681            crate::core::paths::data_dir(),
682            crate::core::paths::state_dir(),
683            crate::core::paths::cache_dir(),
684        ]
685        .into_iter()
686        .flatten()
687        {
688            assert!(
689                dirs.contains(&resolved),
690                "uninstall would NOT remove canonical dir: {}",
691                resolved.display()
692            );
693        }
694
695        // Each directory is listed exactly once (removed once, no churn).
696        let mut seen = HashSet::new();
697        for d in &dirs {
698            assert!(seen.insert(d.clone()), "duplicate dir: {}", d.display());
699        }
700    }
701}