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        ("Copilot", home.join(".copilot/skills/lean-ctx")),
291        ("OpenClaw", home.join(".openclaw/skills/lean-ctx")),
292    ];
293
294    // If CLAUDE_CONFIG_DIR differs from ~/.claude, also clean default path
295    let default_claude_skill = home.join(".claude/skills/lean-ctx");
296    if !skill_dirs.iter().any(|(_, p)| *p == default_claude_skill) {
297        skill_dirs.push(("Claude Code (default)", default_claude_skill));
298    }
299
300    // If CODEBUDDY_CONFIG_DIR differs from ~/.codebuddy, also clean default path
301    let default_codebuddy_skill = home.join(".codebuddy/skills/lean-ctx");
302    if !skill_dirs
303        .iter()
304        .any(|(_, p)| *p == default_codebuddy_skill)
305    {
306        skill_dirs.push(("CodeBuddy (default)", default_codebuddy_skill));
307    }
308
309    let mut removed = false;
310    for (name, dir) in &skill_dirs {
311        if !dir.exists() {
312            continue;
313        }
314        if dry_run {
315            println!("  Would remove {name} skill directory");
316            removed = true;
317        } else if let Err(e) = fs::remove_dir_all(dir) {
318            tracing::warn!("Failed to remove {name} skill dir: {e}");
319        } else {
320            println!("  ✓ {name} skill directory removed");
321            removed = true;
322        }
323    }
324    removed
325}
326
327// ---------------------------------------------------------------------------
328// Data directory
329// ---------------------------------------------------------------------------
330
331/// Every lean-ctx directory an uninstall must delete, de-duplicated and
332/// order-preserving.
333///
334/// Historically this used `dirs::data_dir()` / `dirs::data_local_dir()`, which on
335/// macOS both resolve to `~/Library/Application Support` — so the *real* runtime
336/// dirs (`~/.local/share`, `~/.local/state`, `~/.cache`) were never removed and a
337/// "full" uninstall left >150 MB of data + cache behind. We now resolve through the
338/// exact same [`core::paths`](crate::core::paths) functions the daemon/proxy use,
339/// so every XDG category (config/data/state/cache, honoring `LEAN_CTX_*_DIR` and
340/// `XDG_*` overrides) is covered, plus the legacy single-dir and macOS
341/// Application Support locations for older installs.
342fn data_dirs_to_remove(home: &Path) -> Vec<PathBuf> {
343    let mut dirs = vec![home.join(".lean-ctx"), home.join(".config/lean-ctx")];
344
345    let push = |dirs: &mut Vec<PathBuf>, p: PathBuf| {
346        if !dirs.contains(&p) {
347            dirs.push(p);
348        }
349    };
350
351    // Canonical XDG categories actually written at runtime.
352    for resolved in [
353        crate::core::paths::config_dir(),
354        crate::core::paths::data_dir(),
355        crate::core::paths::state_dir(),
356        crate::core::paths::cache_dir(),
357    ]
358    .into_iter()
359    .flatten()
360    {
361        push(&mut dirs, resolved);
362    }
363
364    // Older installs (and Windows %LOCALAPPDATA%) may have used the platform dir.
365    for platform_dir in [dirs::data_local_dir(), dirs::data_dir()]
366        .into_iter()
367        .flatten()
368    {
369        push(&mut dirs, platform_dir.join("lean-ctx"));
370    }
371
372    dirs
373}
374
375fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
376    let mut removed = false;
377
378    let dirs_to_remove = data_dirs_to_remove(home);
379
380    for data_dir in &dirs_to_remove {
381        if !data_dir.exists() {
382            continue;
383        }
384        let short = shorten(data_dir, home);
385        if dry_run {
386            println!("  Would remove data directory ({short})");
387            removed = true;
388            continue;
389        }
390        match fs::remove_dir_all(data_dir) {
391            Ok(()) => {
392                println!("  ✓ Data directory removed ({short})");
393                removed = true;
394            }
395            Err(e) => tracing::warn!("Failed to remove {short}: {e}"),
396        }
397    }
398
399    // Project-local .lean-ctx/ and .lean-ctx-id in CWD
400    if let Ok(cwd) = std::env::current_dir() {
401        let project_dir = cwd.join(".lean-ctx");
402        let project_id = cwd.join(".lean-ctx-id");
403        for p in [&project_dir, &project_id] {
404            if p.exists() {
405                if dry_run {
406                    println!("  Would remove {}", p.display());
407                    removed = true;
408                } else if p.is_dir() {
409                    if fs::remove_dir_all(p).is_ok() {
410                        println!("  ✓ Removed {}", p.display());
411                        removed = true;
412                    }
413                } else if fs::remove_file(p).is_ok() {
414                    println!("  ✓ Removed {}", p.display());
415                    removed = true;
416                }
417            }
418        }
419    }
420
421    if !removed {
422        println!("  · No data directory found");
423    }
424    removed
425}
426
427fn try_claude_mcp_remove() {
428    let result = std::process::Command::new("claude")
429        .args(["mcp", "remove", "lean-ctx", "--scope", "user"])
430        .stdout(std::process::Stdio::null())
431        .stderr(std::process::Stdio::null())
432        .status();
433    match result {
434        Ok(s) if s.success() => println!("  ✓ Removed lean-ctx from Claude MCP registry"),
435        _ => {} // claude CLI not available or already removed
436    }
437}
438
439// ---------------------------------------------------------------------------
440// .bak cleanup: remove orphaned backup files after successful surgical removal
441// ---------------------------------------------------------------------------
442
443/// Every directory the installer may have written backups or files into:
444/// agent config roots, their well-known subdirectories, and the project-local
445/// config dirs in CWD.
446fn scan_dirs(home: &Path) -> Vec<PathBuf> {
447    let base_dirs: Vec<PathBuf> = vec![
448        home.join(".cursor"),
449        home.join(".claude"),
450        crate::core::editor_registry::claude_state_dir(home),
451        home.join(".codebuddy"),
452        crate::core::editor_registry::codebuddy_state_dir(home),
453        crate::core::editor_registry::zed_config_dir(home),
454        home.join(".gemini"),
455        home.join(".gemini/antigravity"),
456        home.join(".gemini/antigravity-cli"),
457        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
458        home.join(".codeium"),
459        home.join(".codeium/windsurf"),
460        home.join(".config/opencode"),
461        home.join(".config/amp"),
462        home.join(".config/crush"),
463        home.join(".config/zed"),
464        home.join(".qwen"),
465        home.join(".trae"),
466        home.join(".aws/amazonq"),
467        home.join(".kiro"),
468        home.join(".kiro/settings"),
469        home.join(".ampcoder"),
470        home.join(".pi"),
471        home.join(".pi/agent"),
472        home.join(".hermes"),
473        home.join(".verdent"),
474        home.join(".cline"),
475        home.join(".roo"),
476        home.join(".continue"),
477        home.join(".jb-rules"),
478        home.join(".openclaw"),
479        home.join(".augment"),
480        home.join(".qoder"),
481        home.join(".qoderwork"),
482        home.join(".aider"),
483        home.join(".emacs.d"),
484        home.join(".copilot"),
485        home.join(".github"),
486        home.join(".config/mcphub"),
487        home.join(".config/sublime-text"),
488    ];
489
490    // Installers write into well-known subdirectories (hook scripts, rules
491    // files, steering docs, …). read_dir below is non-recursive, so backups in
492    // those subdirectories were previously missed (GL #558).
493    const KNOWN_SUBDIRS: [&str; 6] = ["hooks", "rules", "skills", "steering", "settings", "User"];
494    let mut dirs_to_scan: Vec<PathBuf> = Vec::with_capacity(base_dirs.len() * 4);
495    for dir in base_dirs {
496        for sub in KNOWN_SUBDIRS {
497            let p = dir.join(sub);
498            if p.is_dir() {
499                dirs_to_scan.push(p);
500            }
501        }
502        dirs_to_scan.push(dir);
503    }
504
505    // Project-local config dirs in CWD get the same backup treatment as HOME:
506    // setup writes (and uninstall removes) rules/hooks there too.
507    if let Ok(cwd) = std::env::current_dir() {
508        for rel in [
509            ".cursor/rules",
510            ".claude",
511            ".claude/rules",
512            ".claude/hooks",
513            ".codebuddy",
514            ".codebuddy/rules",
515            ".codebuddy/hooks",
516            ".kiro/steering",
517            ".github",
518            ".github/hooks",
519            ".vscode",
520        ] {
521            let p = cwd.join(rel);
522            if p.is_dir() {
523                dirs_to_scan.push(p);
524            }
525        }
526    }
527
528    dirs_to_scan
529}
530
531fn cleanup_bak_files(home: &Path) {
532    let dirs_to_scan = scan_dirs(home);
533    let mut cleaned = 0;
534    for dir in &dirs_to_scan {
535        if !dir.exists() {
536            continue;
537        }
538        if let Ok(entries) = fs::read_dir(dir) {
539            for entry in entries.flatten() {
540                let name = entry.file_name();
541                let name_str = name.to_string_lossy();
542                if name_str.ends_with(".lean-ctx.tmp") {
543                    let _ = fs::remove_file(entry.path());
544                    cleaned += 1;
545                    continue;
546                }
547                // Backups of our own hook scripts / rules files
548                // (lean-ctx-rewrite.sh.bak, lean-ctx.mdc.bak, …): the originals
549                // are lean-ctx-owned and already removed at this point, so the
550                // backups are pure leftovers.
551                if name_str.ends_with(".bak")
552                    && (name_str.starts_with("lean-ctx-") || name_str.starts_with("lean-ctx."))
553                {
554                    let _ = fs::remove_file(entry.path());
555                    cleaned += 1;
556                    continue;
557                }
558                if name_str.contains(".lean-ctx.invalid.") && name_str.ends_with(".bak") {
559                    let _ = fs::remove_file(entry.path());
560                    cleaned += 1;
561                    continue;
562                }
563                if name_str.ends_with(".lean-ctx.bak") {
564                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
565                    let original = entry.path().with_file_name(original_name);
566                    if original.exists() {
567                        match fs::read_to_string(&original) {
568                            Ok(c) if !c.contains("lean-ctx") => {
569                                let _ = fs::remove_file(entry.path());
570                                cleaned += 1;
571                            }
572                            _ => {}
573                        }
574                    } else {
575                        let _ = fs::remove_file(entry.path());
576                        cleaned += 1;
577                    }
578                    continue;
579                }
580                // Plain .bak files next to known config files (created by
581                // config_io). Removed whether or not the original still exists:
582                // when uninstall deletes a config file that only contained
583                // lean-ctx content, its backup would otherwise be orphaned.
584                if name_str.ends_with(".bak")
585                    && !name_str.contains(".lean-ctx")
586                    && let Ok(bak_content) = fs::read_to_string(entry.path())
587                    && bak_content.contains("lean-ctx")
588                {
589                    let _ = fs::remove_file(entry.path());
590                    cleaned += 1;
591                }
592            }
593        }
594    }
595
596    // Also clean shell RC backups
597    let rc_baks = [
598        home.join(".zshrc.lean-ctx.bak"),
599        home.join(".zshenv.lean-ctx.bak"),
600        home.join(".bashrc.lean-ctx.bak"),
601        home.join(".bashenv.lean-ctx.bak"),
602    ];
603    for bak in &rc_baks {
604        if bak.exists() {
605            let original_name = bak
606                .file_name()
607                .unwrap_or_default()
608                .to_string_lossy()
609                .trim_end_matches(".lean-ctx.bak")
610                .to_string();
611            let original = bak.with_file_name(original_name);
612            if original.exists() {
613                if let Ok(c) = fs::read_to_string(&original)
614                    && !c.contains("lean-ctx")
615                {
616                    let _ = fs::remove_file(bak);
617                    cleaned += 1;
618                }
619            } else {
620                let _ = fs::remove_file(bak);
621                cleaned += 1;
622            }
623        }
624    }
625
626    if cleaned > 0 {
627        println!("  ✓ Cleaned up {cleaned} backup file(s)");
628    }
629}
630
631/// Sweep now-empty installer-created directories (hooks/, rules/, skills/,
632/// steering/). `fs::remove_dir` refuses to delete non-empty directories, so
633/// anything still holding user content survives untouched. Runs as the last
634/// filesystem step of `run()` — after every file-removal pass has finished.
635fn sweep_empty_installer_dirs(home: &Path) {
636    let mut swept = 0;
637    for dir in scan_dirs(home) {
638        let is_installer_dir = dir
639            .file_name()
640            .and_then(|n| n.to_str())
641            .is_some_and(|n| matches!(n, "hooks" | "rules" | "skills" | "steering"));
642        if is_installer_dir && fs::remove_dir(&dir).is_ok() {
643            swept += 1;
644        }
645    }
646    if swept > 0 {
647        println!("  ✓ Removed {swept} empty installer director(y/ies)");
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use std::collections::HashSet;
655
656    #[test]
657    fn data_dirs_to_remove_covers_canonical_xdg_categories() {
658        // `core::paths::*_dir()` reads the (test-sandboxed) data-dir env, which a
659        // parallel `isolated_data_dir()` repoints under `test_env_lock`. We resolve
660        // those dirs twice — once inside `data_dirs_to_remove` and once in the
661        // assertion loop — so without the lock the value can flip between the two
662        // reads (override active → dropped) and the set won't contain the second
663        // value. Hold the lock so the env stays fixed across both reads (#991).
664        let _lock = crate::core::data_dir::test_env_lock();
665        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/tester"));
666        let dirs = data_dirs_to_remove(&home);
667
668        // Legacy single-dir + pre-split config dir are always targeted.
669        assert!(dirs.contains(&home.join(".lean-ctx")));
670        assert!(dirs.contains(&home.join(".config/lean-ctx")));
671
672        // Regression guard for the macOS data/state/cache leak (#uninstall-completeness):
673        // the set MUST include whatever core::paths actually resolves for every XDG
674        // category. The old dirs::data_dir()-based code missed these — on macOS they
675        // collapse onto Application Support — leaving the real ~/.local/share +
676        // ~/.local/state + ~/.cache (>150 MB) behind after a "full" uninstall.
677        for resolved in [
678            crate::core::paths::config_dir(),
679            crate::core::paths::data_dir(),
680            crate::core::paths::state_dir(),
681            crate::core::paths::cache_dir(),
682        ]
683        .into_iter()
684        .flatten()
685        {
686            assert!(
687                dirs.contains(&resolved),
688                "uninstall would NOT remove canonical dir: {}",
689                resolved.display()
690            );
691        }
692
693        // Each directory is listed exactly once (removed once, no churn).
694        let mut seen = HashSet::new();
695        for d in &dirs {
696            assert!(seen.insert(d.clone()), "duplicate dir: {}", d.display());
697        }
698    }
699}