Skip to main content

lean_ctx/uninstall/
mod.rs

1mod agents;
2mod parsers;
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use agents::{
8    remove_hook_files, remove_mcp_configs, remove_project_agent_files, remove_rules_files,
9    remove_shell_hook,
10};
11
12pub(super) fn backup_before_modify(path: &Path, dry_run: bool) {
13    if dry_run {
14        return;
15    }
16    if path.exists() {
17        let bak = bak_path_for(path);
18        let _ = fs::copy(path, &bak);
19    }
20}
21
22pub(super) fn bak_path_for(path: &Path) -> PathBuf {
23    let filename = path.file_name().unwrap_or_default().to_string_lossy();
24    path.with_file_name(format!("{filename}.lean-ctx.bak"))
25}
26
27fn cleanup_bak(path: &Path) {
28    let bak = bak_path_for(path);
29    if bak.exists() {
30        let _ = fs::remove_file(&bak);
31    }
32}
33
34pub(super) fn shorten(path: &Path, home: &Path) -> String {
35    match path.strip_prefix(home) {
36        Ok(rel) => format!("~/{}", rel.display()),
37        Err(_) => path.display().to_string(),
38    }
39}
40
41pub(super) fn copilot_instructions_path(home: &Path) -> PathBuf {
42    #[cfg(target_os = "macos")]
43    {
44        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
45    }
46    #[cfg(target_os = "linux")]
47    {
48        return home.join(".config/Code/User/github-copilot-instructions.md");
49    }
50    #[cfg(target_os = "windows")]
51    {
52        if let Ok(appdata) = std::env::var("APPDATA") {
53            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
54        }
55    }
56    #[allow(unreachable_code)]
57    home.join(".config/Code/User/github-copilot-instructions.md")
58}
59
60/// Write `content` to `path` only if not in dry-run mode.
61pub(super) fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
62    if dry_run {
63        return Ok(());
64    }
65    fs::write(path, content)?;
66    // If we successfully wrote the cleaned file, the backup is no longer needed.
67    cleanup_bak(path);
68    Ok(())
69}
70
71/// Remove `path` only if not in dry-run mode.
72pub(super) fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
73    if dry_run {
74        return Ok(());
75    }
76    fs::remove_file(path)?;
77    // If we successfully removed the file, also remove its backup.
78    cleanup_bak(path);
79    Ok(())
80}
81
82// ---------------------------------------------------------------------------
83// Main entry
84// ---------------------------------------------------------------------------
85
86pub fn run(dry_run: bool) {
87    let Some(home) = dirs::home_dir() else {
88        tracing::warn!("Could not determine home directory");
89        return;
90    };
91
92    if dry_run {
93        println!("\n  lean-ctx uninstall --dry-run\n  ──────────────────────────────────\n");
94        println!("  Preview mode — no files will be modified.\n");
95    } else {
96        println!("\n  lean-ctx uninstall\n  ──────────────────────────────────\n");
97    }
98
99    let mut removed_any = false;
100
101    removed_any |= remove_shell_hook(&home, dry_run);
102    if dry_run {
103        crate::proxy_setup::preview_proxy_cleanup(&home);
104    } else {
105        crate::proxy_setup::uninstall_proxy_env(&home, false);
106    }
107    removed_any |= remove_mcp_configs(&home, dry_run);
108    removed_any |= remove_rules_files(&home, dry_run);
109    removed_any |= remove_hook_files(&home, dry_run);
110    removed_any |= remove_project_agent_files(dry_run);
111
112    if dry_run {
113        println!("  Would remove proxy autostart (LaunchAgent/systemd)");
114    } else {
115        crate::proxy_autostart::uninstall(true);
116    }
117
118    if !dry_run {
119        cleanup_bak_files(&home);
120    }
121
122    removed_any |= remove_data_dir(&home, dry_run);
123
124    println!();
125
126    if removed_any {
127        println!("  ──────────────────────────────────");
128        if dry_run {
129            println!(
130                "  The above changes WOULD be applied.\n  Run `lean-ctx uninstall` to execute.\n"
131            );
132        } else {
133            println!("  lean-ctx configuration removed.\n");
134        }
135    } else {
136        println!("  Nothing to remove — lean-ctx was not configured.\n");
137    }
138
139    if !dry_run {
140        print_binary_removal_instructions();
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Marked block removal (for AGENTS.md, SharedMarkdown)
146// ---------------------------------------------------------------------------
147
148pub(super) fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
149    let s = content.find(start);
150    let e = content.find(end);
151    match (s, e) {
152        (Some(si), Some(ei)) if ei >= si => {
153            let after_end = ei + end.len();
154            let before = &content[..si];
155            let after = &content[after_end..];
156            let mut out = String::new();
157            out.push_str(before.trim_end_matches('\n'));
158            out.push('\n');
159            if !after.trim().is_empty() {
160                out.push('\n');
161                out.push_str(after.trim_start_matches('\n'));
162            }
163            out
164        }
165        _ => content.to_string(),
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Data directory
171// ---------------------------------------------------------------------------
172
173fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
174    let data_dir = home.join(".lean-ctx");
175    if !data_dir.exists() {
176        println!("  · No data directory found");
177        return false;
178    }
179
180    if dry_run {
181        println!("  Would remove Data directory (~/.lean-ctx/)");
182        return true;
183    }
184
185    match fs::remove_dir_all(&data_dir) {
186        Ok(()) => {
187            println!("  ✓ Data directory removed (~/.lean-ctx/)");
188            true
189        }
190        Err(e) => {
191            tracing::warn!("Failed to remove ~/.lean-ctx/: {e}");
192            false
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// .bak cleanup: remove orphaned backup files after successful surgical removal
199// ---------------------------------------------------------------------------
200
201fn cleanup_bak_files(home: &Path) {
202    let dirs_to_scan: Vec<PathBuf> = vec![
203        home.join(".cursor"),
204        home.join(".claude"),
205        crate::core::editor_registry::claude_state_dir(home),
206        home.join(".gemini"),
207        home.join(".gemini/antigravity"),
208        crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex")),
209        home.join(".codeium"),
210        home.join(".codeium/windsurf"),
211        home.join(".config/opencode"),
212        home.join(".config/amp"),
213        home.join(".config/crush"),
214        home.join(".config/zed"),
215        home.join(".qwen"),
216        home.join(".trae"),
217        home.join(".aws/amazonq"),
218        home.join(".kiro"),
219        home.join(".kiro/settings"),
220        home.join(".ampcoder"),
221        home.join(".pi"),
222        home.join(".pi/agent"),
223        home.join(".hermes"),
224        home.join(".verdent"),
225        home.join(".cline"),
226        home.join(".roo"),
227        home.join(".continue"),
228        home.join(".jb-rules"),
229    ];
230
231    let mut cleaned = 0;
232    for dir in &dirs_to_scan {
233        if !dir.exists() {
234            continue;
235        }
236        if let Ok(entries) = fs::read_dir(dir) {
237            for entry in entries.flatten() {
238                let name = entry.file_name();
239                let name_str = name.to_string_lossy();
240                if name_str.ends_with(".lean-ctx.tmp") {
241                    let _ = fs::remove_file(entry.path());
242                    cleaned += 1;
243                    continue;
244                }
245                if name_str.ends_with(".lean-ctx.bak") {
246                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
247                    let original = entry.path().with_file_name(original_name);
248                    if original.exists() {
249                        // Only remove backups if the original is already clean.
250                        match fs::read_to_string(&original) {
251                            Ok(c) if !c.contains("lean-ctx") => {
252                                let _ = fs::remove_file(entry.path());
253                                cleaned += 1;
254                            }
255                            _ => {}
256                        }
257                    } else {
258                        // If the original is gone, the backup is no longer needed.
259                        let _ = fs::remove_file(entry.path());
260                        cleaned += 1;
261                    }
262                }
263            }
264        }
265    }
266
267    // Also clean shell RC backups
268    let rc_baks = [
269        home.join(".zshrc.lean-ctx.bak"),
270        home.join(".zshenv.lean-ctx.bak"),
271        home.join(".bashrc.lean-ctx.bak"),
272        home.join(".bashenv.lean-ctx.bak"),
273    ];
274    for bak in &rc_baks {
275        if bak.exists() {
276            let original_name = bak
277                .file_name()
278                .unwrap_or_default()
279                .to_string_lossy()
280                .trim_end_matches(".lean-ctx.bak")
281                .to_string();
282            let original = bak.with_file_name(original_name);
283            if original.exists() {
284                if let Ok(c) = fs::read_to_string(&original) {
285                    if !c.contains("lean-ctx") {
286                        let _ = fs::remove_file(bak);
287                        cleaned += 1;
288                    }
289                }
290            } else {
291                let _ = fs::remove_file(bak);
292                cleaned += 1;
293            }
294        }
295    }
296
297    if cleaned > 0 {
298        println!("  ✓ Cleaned up {cleaned} backup file(s)");
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Binary removal instructions
304// ---------------------------------------------------------------------------
305
306fn print_binary_removal_instructions() {
307    let binary_path = std::env::current_exe()
308        .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
309
310    println!("  To complete uninstallation, remove the binary:\n");
311
312    if binary_path.contains(".cargo") {
313        println!("    cargo uninstall lean-ctx\n");
314    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
315        println!("    brew uninstall lean-ctx\n");
316    } else {
317        println!("    rm {binary_path}\n");
318    }
319
320    println!("  Then restart your shell.\n");
321}