Skip to main content

lean_ctx/
uninstall.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4// ---------------------------------------------------------------------------
5// Helpers
6// ---------------------------------------------------------------------------
7
8fn backup_before_modify(path: &Path, dry_run: bool) {
9    if dry_run {
10        return;
11    }
12    if path.exists() {
13        let bak = bak_path_for(path);
14        let _ = fs::copy(path, &bak);
15    }
16}
17
18fn bak_path_for(path: &Path) -> PathBuf {
19    let filename = path.file_name().unwrap_or_default().to_string_lossy();
20    path.with_file_name(format!("{filename}.lean-ctx.bak"))
21}
22
23fn cleanup_bak(path: &Path) {
24    let bak = bak_path_for(path);
25    if bak.exists() {
26        let _ = fs::remove_file(&bak);
27    }
28}
29
30fn shorten(path: &Path, home: &Path) -> String {
31    match path.strip_prefix(home) {
32        Ok(rel) => format!("~/{}", rel.display()),
33        Err(_) => path.display().to_string(),
34    }
35}
36
37fn copilot_instructions_path(home: &Path) -> PathBuf {
38    #[cfg(target_os = "macos")]
39    {
40        return home.join("Library/Application Support/Code/User/github-copilot-instructions.md");
41    }
42    #[cfg(target_os = "linux")]
43    {
44        return home.join(".config/Code/User/github-copilot-instructions.md");
45    }
46    #[cfg(target_os = "windows")]
47    {
48        if let Ok(appdata) = std::env::var("APPDATA") {
49            return PathBuf::from(appdata).join("Code/User/github-copilot-instructions.md");
50        }
51    }
52    #[allow(unreachable_code)]
53    home.join(".config/Code/User/github-copilot-instructions.md")
54}
55
56/// Write `content` to `path` only if not in dry-run mode.
57fn safe_write(path: &Path, content: &str, dry_run: bool) -> Result<(), std::io::Error> {
58    if dry_run {
59        return Ok(());
60    }
61    fs::write(path, content)?;
62    // If we successfully wrote the cleaned file, the backup is no longer needed.
63    cleanup_bak(path);
64    Ok(())
65}
66
67/// Remove `path` only if not in dry-run mode.
68fn safe_remove(path: &Path, dry_run: bool) -> Result<(), std::io::Error> {
69    if dry_run {
70        return Ok(());
71    }
72    fs::remove_file(path)?;
73    // If we successfully removed the file, also remove its backup.
74    cleanup_bak(path);
75    Ok(())
76}
77
78// ---------------------------------------------------------------------------
79// Main entry
80// ---------------------------------------------------------------------------
81
82pub fn run(dry_run: bool) {
83    let Some(home) = dirs::home_dir() else {
84        tracing::warn!("Could not determine home directory");
85        return;
86    };
87
88    if dry_run {
89        println!("\n  lean-ctx uninstall --dry-run\n  ──────────────────────────────────\n");
90        println!("  Preview mode — no files will be modified.\n");
91    } else {
92        println!("\n  lean-ctx uninstall\n  ──────────────────────────────────\n");
93    }
94
95    let mut removed_any = false;
96
97    removed_any |= remove_shell_hook(&home, dry_run);
98    if !dry_run {
99        crate::proxy_setup::uninstall_proxy_env(&home, false);
100    }
101    removed_any |= remove_mcp_configs(&home, dry_run);
102    removed_any |= remove_rules_files(&home, dry_run);
103    removed_any |= remove_hook_files(&home, dry_run);
104    removed_any |= remove_project_agent_files(dry_run);
105
106    if !dry_run {
107        cleanup_bak_files(&home);
108    }
109
110    removed_any |= remove_data_dir(&home, dry_run);
111
112    println!();
113
114    if removed_any {
115        println!("  ──────────────────────────────────");
116        if dry_run {
117            println!(
118                "  The above changes WOULD be applied.\n  Run `lean-ctx uninstall` to execute.\n"
119            );
120        } else {
121            println!("  lean-ctx configuration removed.\n");
122        }
123    } else {
124        println!("  Nothing to remove — lean-ctx was not configured.\n");
125    }
126
127    if !dry_run {
128        print_binary_removal_instructions();
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Project-level agent files (cwd)
134// ---------------------------------------------------------------------------
135
136fn remove_project_agent_files(dry_run: bool) -> bool {
137    let cwd = std::env::current_dir().unwrap_or_default();
138    let agents = cwd.join("AGENTS.md");
139    let lean_ctx_md = cwd.join("LEAN-CTX.md");
140
141    const START: &str = "<!-- lean-ctx -->";
142    const END: &str = "<!-- /lean-ctx -->";
143    const OWNED: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
144
145    let mut removed = false;
146
147    // AGENTS.md: surgical marker-based removal (already correct)
148    if agents.exists() {
149        if let Ok(content) = fs::read_to_string(&agents) {
150            if content.contains(START) {
151                let cleaned = remove_marked_block(&content, START, END);
152                if cleaned != content {
153                    backup_before_modify(&agents, dry_run);
154                    if let Err(e) = safe_write(&agents, &cleaned, dry_run) {
155                        tracing::warn!("Failed to update project AGENTS.md: {e}");
156                    } else {
157                        let verb = if dry_run { "Would remove" } else { "✓" };
158                        println!("  {verb} Project: removed lean-ctx block from AGENTS.md");
159                        removed = true;
160                    }
161                }
162            }
163        }
164    }
165
166    // LEAN-CTX.md: only delete if we own it
167    if lean_ctx_md.exists() {
168        if let Ok(content) = fs::read_to_string(&lean_ctx_md) {
169            if content.contains(OWNED) {
170                if let Err(e) = safe_remove(&lean_ctx_md, dry_run) {
171                    tracing::warn!("Failed to remove project LEAN-CTX.md: {e}");
172                } else {
173                    let verb = if dry_run { "Would remove" } else { "✓" };
174                    println!("  {verb} Project: removed LEAN-CTX.md");
175                    removed = true;
176                }
177            }
178        }
179    }
180
181    // Dedicated lean-ctx files in project: safe to delete entirely
182    let dedicated_project_files = [
183        ".kiro/steering/lean-ctx.md",
184        ".cursor/rules/lean-ctx.mdc",
185        ".claude/rules/lean-ctx.md",
186    ];
187    for rel in &dedicated_project_files {
188        let path = cwd.join(rel);
189        if path.exists() {
190            if let Ok(content) = fs::read_to_string(&path) {
191                if content.contains("lean-ctx") {
192                    let _ = safe_remove(&path, dry_run);
193                    let verb = if dry_run { "Would remove" } else { "✓" };
194                    println!("  {verb} Project: removed {rel}");
195                    removed = true;
196                }
197            }
198        }
199    }
200
201    // Shared project files: surgically remove lean-ctx content, keep user content
202    let shared_project_files = [".cursorrules", ".windsurfrules", ".clinerules"];
203    for rel in &shared_project_files {
204        let path = cwd.join(rel);
205        if !path.exists() {
206            continue;
207        }
208        let Ok(content) = fs::read_to_string(&path) else {
209            continue;
210        };
211        if !content.contains("lean-ctx") {
212            continue;
213        }
214
215        let cleaned = remove_lean_ctx_section_from_rules(&content);
216        if cleaned.trim().is_empty() {
217            backup_before_modify(&path, dry_run);
218            let _ = safe_remove(&path, dry_run);
219            let verb = if dry_run { "Would remove" } else { "✓" };
220            println!("  {verb} Project: removed {rel}");
221        } else {
222            backup_before_modify(&path, dry_run);
223            let _ = safe_write(&path, &cleaned, dry_run);
224            let verb = if dry_run { "Would clean" } else { "✓" };
225            println!("  {verb} Project: removed lean-ctx content from {rel}");
226        }
227        removed = true;
228    }
229
230    // Project-level .claude/settings.local.json: surgically remove lean-ctx hooks
231    let claude_settings = cwd.join(".claude/settings.local.json");
232    if claude_settings.exists() {
233        if let Ok(content) = fs::read_to_string(&claude_settings) {
234            if content.contains("lean-ctx") {
235                backup_before_modify(&claude_settings, dry_run);
236                match remove_lean_ctx_from_hooks_json(&content) {
237                    Some(cleaned) if !cleaned.trim().is_empty() => {
238                        let _ = safe_write(&claude_settings, &cleaned, dry_run);
239                        let verb = if dry_run { "Would clean" } else { "✓" };
240                        println!(
241                            "  {verb} Project: cleaned .claude/settings.local.json (user hooks preserved)"
242                        );
243                    }
244                    _ => {
245                        let _ = safe_remove(&claude_settings, dry_run);
246                        let verb = if dry_run { "Would remove" } else { "✓" };
247                        println!("  {verb} Project: removed .claude/settings.local.json");
248                    }
249                }
250                removed = true;
251            }
252        }
253    }
254
255    removed
256}
257
258/// Remove the lean-ctx section from .cursorrules / .windsurfrules / .clinerules.
259/// These files have lean-ctx content appended starting with `# lean-ctx`.
260/// The content has no end marker, so we remove from the heading to the end of
261/// the lean-ctx block (next non-lean-ctx heading or end of file).
262fn remove_lean_ctx_section_from_rules(content: &str) -> String {
263    // If the file has the markdown markers, use marker-based removal
264    const MARKER_START: &str = "<!-- lean-ctx -->";
265    const MARKER_END: &str = "<!-- /lean-ctx -->";
266    if content.contains(MARKER_START) {
267        return remove_marked_block(content, MARKER_START, MARKER_END);
268    }
269
270    // Otherwise, remove from `# lean-ctx` heading to end of file or next
271    // non-lean-ctx heading.
272    let mut out = String::with_capacity(content.len());
273    let mut in_block = false;
274
275    for line in content.lines() {
276        if !in_block && line.starts_with('#') && line.to_lowercase().contains("lean-ctx") {
277            in_block = true;
278            continue;
279        }
280        if in_block {
281            if line.starts_with('#') && !line.to_lowercase().contains("lean-ctx") {
282                in_block = false;
283                out.push_str(line);
284                out.push('\n');
285            }
286            continue;
287        }
288        out.push_str(line);
289        out.push('\n');
290    }
291
292    // Trim trailing whitespace added by separation
293    while out.ends_with("\n\n") {
294        out.pop();
295    }
296    out
297}
298
299// ---------------------------------------------------------------------------
300// Marked block removal (for AGENTS.md, SharedMarkdown)
301// ---------------------------------------------------------------------------
302
303fn remove_marked_block(content: &str, start: &str, end: &str) -> String {
304    let s = content.find(start);
305    let e = content.find(end);
306    match (s, e) {
307        (Some(si), Some(ei)) if ei >= si => {
308            let after_end = ei + end.len();
309            let before = &content[..si];
310            let after = &content[after_end..];
311            let mut out = String::new();
312            out.push_str(before.trim_end_matches('\n'));
313            out.push('\n');
314            if !after.trim().is_empty() {
315                out.push('\n');
316                out.push_str(after.trim_start_matches('\n'));
317            }
318            out
319        }
320        _ => content.to_string(),
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Shell hook removal
326// ---------------------------------------------------------------------------
327
328fn remove_shell_hook(home: &Path, dry_run: bool) -> bool {
329    let shell = std::env::var("SHELL").unwrap_or_default();
330    let mut removed = false;
331
332    if !dry_run {
333        crate::shell_hook::uninstall_all(false);
334    }
335
336    let rc_files: Vec<PathBuf> = vec![
337        home.join(".zshrc"),
338        home.join(".bashrc"),
339        home.join(".config/fish/config.fish"),
340        #[cfg(windows)]
341        home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
342    ];
343
344    for rc in &rc_files {
345        if !rc.exists() {
346            continue;
347        }
348        let Ok(content) = fs::read_to_string(rc) else {
349            continue;
350        };
351        if !content.contains("lean-ctx") {
352            continue;
353        }
354
355        let is_legacy = !content.contains("# lean-ctx shell hook — end");
356        let mut cleaned = remove_lean_ctx_block(&content);
357        cleaned = remove_source_lines(&cleaned);
358        if cleaned.trim() != content.trim() {
359            let bak = rc.with_extension("lean-ctx.bak");
360            if !dry_run {
361                let _ = fs::copy(rc, &bak);
362            }
363            if let Err(e) = safe_write(rc, &cleaned, dry_run) {
364                tracing::warn!("Failed to update {}: {}", rc.display(), e);
365            } else {
366                let short = shorten(rc, home);
367                let verb = if dry_run { "Would remove" } else { "✓" };
368                println!("  {verb} Shell hook removed from {short}");
369                if !dry_run {
370                    println!("    Backup: {}", shorten(&bak, home));
371                }
372                if is_legacy {
373                    println!("    ⚠ Legacy hook (no end marker) — please review {short} manually");
374                }
375                removed = true;
376            }
377        }
378    }
379
380    let hook_files = [
381        "shell-hook.zsh",
382        "shell-hook.bash",
383        "shell-hook.fish",
384        "shell-hook.ps1",
385    ];
386    let lc_dir = home.join(".lean-ctx");
387    for f in &hook_files {
388        let path = lc_dir.join(f);
389        if path.exists() {
390            let _ = safe_remove(&path, dry_run);
391            let verb = if dry_run { "Would remove" } else { "✓" };
392            println!("  {verb} Removed ~/.lean-ctx/{f}");
393            removed = true;
394        }
395    }
396
397    if !removed && !shell.is_empty() {
398        println!("  · No shell hook found");
399    }
400
401    removed
402}
403
404fn remove_source_lines(content: &str) -> String {
405    content
406        .lines()
407        .filter(|line| {
408            !line.contains("lean-ctx/shell-hook.") && !line.contains("lean-ctx\\shell-hook.")
409        })
410        .collect::<Vec<_>>()
411        .join("\n")
412        + "\n"
413}
414
415// ---------------------------------------------------------------------------
416// MCP config removal (JSON / YAML / TOML)
417// ---------------------------------------------------------------------------
418
419fn remove_mcp_configs(home: &Path, dry_run: bool) -> bool {
420    let claude_cfg_dir_json = std::env::var("CLAUDE_CONFIG_DIR").ok().map_or_else(
421        || PathBuf::from("/nonexistent"),
422        |d| PathBuf::from(d).join(".claude.json"),
423    );
424    let configs: Vec<(&str, PathBuf)> = vec![
425        ("Cursor", home.join(".cursor/mcp.json")),
426        ("Claude Code (config dir)", claude_cfg_dir_json),
427        ("Claude Code (home)", home.join(".claude.json")),
428        ("Windsurf", home.join(".codeium/windsurf/mcp_config.json")),
429        ("Gemini CLI", home.join(".gemini/settings.json")),
430        (
431            "Gemini CLI (legacy)",
432            home.join(".gemini/settings/mcp.json"),
433        ),
434        (
435            "Antigravity",
436            home.join(".gemini/antigravity/mcp_config.json"),
437        ),
438        ("Codex CLI", home.join(".codex/config.toml")),
439        ("OpenCode", home.join(".config/opencode/opencode.json")),
440        ("Qwen Code", home.join(".qwen/settings.json")),
441        ("Qwen Code (legacy)", home.join(".qwen/mcp.json")),
442        ("Trae", home.join(".trae/mcp.json")),
443        ("Amazon Q Developer", home.join(".aws/amazonq/default.json")),
444        (
445            "Amazon Q Developer (legacy)",
446            home.join(".aws/amazonq/mcp.json"),
447        ),
448        ("JetBrains IDEs", home.join(".jb-mcp.json")),
449        ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
450        ("Verdent", home.join(".verdent/mcp.json")),
451        ("Amp", home.join(".config/amp/settings.json")),
452        ("Crush", home.join(".config/crush/crush.json")),
453        ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
454        ("Cline", crate::core::editor_registry::cline_mcp_path()),
455        ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
456        ("Hermes Agent", home.join(".hermes/config.yaml")),
457    ];
458
459    let mut removed = false;
460
461    for (name, path) in &configs {
462        if !path.exists() {
463            continue;
464        }
465        let Ok(content) = fs::read_to_string(path) else {
466            continue;
467        };
468        if !content.contains("lean-ctx") {
469            continue;
470        }
471
472        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
473        let is_yaml = ext == "yaml" || ext == "yml";
474        let is_toml = ext == "toml";
475
476        let cleaned = if is_yaml {
477            Some(remove_lean_ctx_from_yaml(&content))
478        } else if is_toml {
479            Some(remove_lean_ctx_from_toml(&content))
480        } else {
481            remove_lean_ctx_from_json(&content)
482        };
483
484        if let Some(cleaned) = cleaned {
485            backup_before_modify(path, dry_run);
486            if let Err(e) = safe_write(path, &cleaned, dry_run) {
487                tracing::warn!("Failed to update {} config: {}", name, e);
488            } else {
489                let verb = if dry_run { "Would update" } else { "✓" };
490                println!("  {verb} MCP config removed from {name}");
491                removed = true;
492            }
493        }
494    }
495
496    let zed_path = crate::core::editor_registry::zed_settings_path(home);
497    if zed_path.exists() {
498        if let Ok(content) = fs::read_to_string(&zed_path) {
499            if content.contains("lean-ctx") {
500                println!(
501                    "  ⚠ Zed: manually remove lean-ctx from {}",
502                    shorten(&zed_path, home)
503                );
504            }
505        }
506    }
507
508    let vscode_path = crate::core::editor_registry::vscode_mcp_path();
509    if vscode_path.exists() {
510        if let Ok(content) = fs::read_to_string(&vscode_path) {
511            if content.contains("lean-ctx") {
512                if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
513                    backup_before_modify(&vscode_path, dry_run);
514                    if let Err(e) = safe_write(&vscode_path, &cleaned, dry_run) {
515                        tracing::warn!("Failed to update VS Code config: {e}");
516                    } else {
517                        let verb = if dry_run { "Would update" } else { "✓" };
518                        println!("  {verb} MCP config removed from VS Code / Copilot");
519                        removed = true;
520                    }
521                }
522            }
523        }
524    }
525
526    removed
527}
528
529// ---------------------------------------------------------------------------
530// Rules files removal (shared vs dedicated)
531// ---------------------------------------------------------------------------
532
533fn remove_rules_files(home: &Path, dry_run: bool) -> bool {
534    // Dedicated files: entirely owned by lean-ctx — safe to delete
535    let dedicated_files: Vec<(&str, PathBuf)> = vec![
536        (
537            "Claude Code",
538            crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
539        ),
540        ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
541        (
542            "Gemini CLI (legacy)",
543            home.join(".gemini/rules/lean-ctx.md"),
544        ),
545        ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
546        ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
547        ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
548        ("Cline", home.join(".cline/rules/lean-ctx.md")),
549        ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
550        ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
551        ("Continue", home.join(".continue/rules/lean-ctx.md")),
552        ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
553        ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
554        ("Trae", home.join(".trae/rules/lean-ctx.md")),
555        (
556            "Amazon Q Developer",
557            home.join(".aws/amazonq/rules/lean-ctx.md"),
558        ),
559        ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
560        (
561            "Antigravity",
562            home.join(".gemini/antigravity/rules/lean-ctx.md"),
563        ),
564        ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
565        ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
566        ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
567        ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
568    ];
569
570    // Shared files: contain user content + lean-ctx block with markers.
571    // Only remove the <!-- lean-ctx --> ... <!-- /lean-ctx --> section.
572    let shared_files: Vec<(&str, PathBuf)> = vec![
573        (
574            "Claude Code (legacy)",
575            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
576        ),
577        ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
578        ("Gemini CLI", home.join(".gemini/GEMINI.md")),
579        ("Codex CLI", home.join(".codex/instructions.md")),
580        ("VS Code / Copilot", copilot_instructions_path(home)),
581    ];
582
583    let mut removed = false;
584
585    // --- Dedicated: delete if contains lean-ctx ---
586    for (name, path) in &dedicated_files {
587        if !path.exists() {
588            continue;
589        }
590        if let Ok(content) = fs::read_to_string(path) {
591            if content.contains("lean-ctx") {
592                if let Err(e) = safe_remove(path, dry_run) {
593                    tracing::warn!("Failed to remove {name} rules: {e}");
594                } else {
595                    let verb = if dry_run { "Would remove" } else { "✓" };
596                    println!("  {verb} Rules removed from {name}");
597                    removed = true;
598                }
599            }
600        }
601    }
602
603    // --- Shared: surgically remove lean-ctx section, keep user content ---
604    const RULES_MARKER: &str = "# lean-ctx — Context Engineering Layer";
605    const RULES_END: &str = "<!-- /lean-ctx -->";
606
607    for (name, path) in &shared_files {
608        if !path.exists() {
609            continue;
610        }
611        let Ok(content) = fs::read_to_string(path) else {
612            continue;
613        };
614        if !content.contains("lean-ctx") {
615            continue;
616        }
617
618        let cleaned = if content.contains(RULES_END) {
619            remove_marked_block(&content, RULES_MARKER, RULES_END)
620        } else {
621            remove_lean_ctx_block_from_md(&content)
622        };
623
624        if cleaned.trim().is_empty() {
625            backup_before_modify(path, dry_run);
626            let _ = safe_remove(path, dry_run);
627            let verb = if dry_run { "Would remove" } else { "✓" };
628            println!("  {verb} Rules removed from {name} (file was lean-ctx only)");
629        } else if cleaned.trim() != content.trim() {
630            backup_before_modify(path, dry_run);
631            let _ = safe_write(path, &cleaned, dry_run);
632            let verb = if dry_run { "Would clean" } else { "✓" };
633            println!("  {verb} Rules removed from {name} (user content preserved)");
634        }
635        removed = true;
636    }
637
638    // --- Hermes Agent: block-based removal from shared HERMES.md ---
639    let hermes_md = home.join(".hermes/HERMES.md");
640    if hermes_md.exists() {
641        if let Ok(content) = fs::read_to_string(&hermes_md) {
642            if content.contains("lean-ctx") {
643                let cleaned = remove_lean_ctx_block_from_md(&content);
644                backup_before_modify(&hermes_md, dry_run);
645                if cleaned.trim().is_empty() {
646                    let _ = safe_remove(&hermes_md, dry_run);
647                } else {
648                    let _ = safe_write(&hermes_md, &cleaned, dry_run);
649                }
650                let verb = if dry_run { "Would clean" } else { "✓" };
651                println!("  {verb} Rules removed from Hermes Agent");
652                removed = true;
653            }
654        }
655    }
656
657    if let Ok(cwd) = std::env::current_dir() {
658        let project_hermes = cwd.join(".hermes.md");
659        if project_hermes.exists() {
660            if let Ok(content) = fs::read_to_string(&project_hermes) {
661                if content.contains("lean-ctx") {
662                    let cleaned = remove_lean_ctx_block_from_md(&content);
663                    backup_before_modify(&project_hermes, dry_run);
664                    if cleaned.trim().is_empty() {
665                        let _ = safe_remove(&project_hermes, dry_run);
666                    } else {
667                        let _ = safe_write(&project_hermes, &cleaned, dry_run);
668                    }
669                    let verb = if dry_run { "Would clean" } else { "✓" };
670                    println!("  {verb} Rules removed from .hermes.md");
671                    removed = true;
672                }
673            }
674        }
675    }
676
677    if !removed {
678        println!("  · No rules files found");
679    }
680    removed
681}
682
683fn remove_lean_ctx_block_from_md(content: &str) -> String {
684    let mut out = String::with_capacity(content.len());
685    let mut in_block = false;
686
687    for line in content.lines() {
688        if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
689            in_block = true;
690            continue;
691        }
692        if in_block {
693            if line.starts_with('#') && !line.contains("lean-ctx") {
694                in_block = false;
695                out.push_str(line);
696                out.push('\n');
697            }
698            continue;
699        }
700        out.push_str(line);
701        out.push('\n');
702    }
703
704    while out.starts_with('\n') {
705        out.remove(0);
706    }
707    while out.ends_with("\n\n") {
708        out.pop();
709    }
710    out
711}
712
713// ---------------------------------------------------------------------------
714// Hook files removal
715// ---------------------------------------------------------------------------
716
717fn remove_hook_files(home: &Path, dry_run: bool) -> bool {
718    let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
719    let hook_files: Vec<PathBuf> = vec![
720        claude_hooks_dir.join("lean-ctx-rewrite.sh"),
721        claude_hooks_dir.join("lean-ctx-redirect.sh"),
722        claude_hooks_dir.join("lean-ctx-rewrite-native"),
723        claude_hooks_dir.join("lean-ctx-redirect-native"),
724        home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
725        home.join(".cursor/hooks/lean-ctx-redirect.sh"),
726        home.join(".cursor/hooks/lean-ctx-rewrite-native"),
727        home.join(".cursor/hooks/lean-ctx-redirect-native"),
728        home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
729        home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
730        home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
731        home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
732    ];
733
734    let mut removed = false;
735    for path in &hook_files {
736        if path.exists() {
737            if let Err(e) = safe_remove(path, dry_run) {
738                tracing::warn!("Failed to remove hook {}: {e}", path.display());
739            } else {
740                removed = true;
741            }
742        }
743    }
744
745    if removed {
746        let verb = if dry_run { "Would remove" } else { "✓" };
747        println!("  {verb} Hook scripts removed");
748    }
749
750    // hooks.json: surgically remove lean-ctx entries instead of deleting
751    for (label, hj_path) in [
752        ("Cursor", home.join(".cursor/hooks.json")),
753        ("Codex", home.join(".codex/hooks.json")),
754    ] {
755        if !hj_path.exists() {
756            continue;
757        }
758        let Ok(content) = fs::read_to_string(&hj_path) else {
759            continue;
760        };
761        if !content.contains("lean-ctx") {
762            continue;
763        }
764
765        backup_before_modify(&hj_path, dry_run);
766
767        match remove_lean_ctx_from_hooks_json(&content) {
768            Some(cleaned) if !cleaned.trim().is_empty() => {
769                if let Err(e) = safe_write(&hj_path, &cleaned, dry_run) {
770                    tracing::warn!("Failed to update {label} hooks.json: {e}");
771                } else {
772                    let verb = if dry_run { "Would clean" } else { "✓" };
773                    println!("  {verb} {label} hooks.json cleaned (non-lean-ctx hooks preserved)");
774                    removed = true;
775                }
776            }
777            _ => {
778                if let Err(e) = safe_remove(&hj_path, dry_run) {
779                    tracing::warn!("Failed to remove {label} hooks.json: {e}");
780                } else {
781                    let verb = if dry_run { "Would remove" } else { "✓" };
782                    println!("  {verb} {label} hooks.json removed");
783                    removed = true;
784                }
785            }
786        }
787    }
788
789    removed
790}
791
792/// Remove lean-ctx hook entries from hooks.json, preserving other hooks.
793/// Returns `Some(cleaned_json)` if non-lean-ctx hooks remain, `None` if empty.
794fn remove_lean_ctx_from_hooks_json(content: &str) -> Option<String> {
795    let mut parsed: serde_json::Value = crate::core::jsonc::parse_jsonc(content).ok()?;
796    let mut modified = false;
797
798    if let Some(hooks) = parsed.get_mut("hooks").and_then(|h| h.as_object_mut()) {
799        for entries in hooks.values_mut() {
800            if let Some(arr) = entries.as_array_mut() {
801                let before = arr.len();
802                arr.retain(|entry| {
803                    !entry
804                        .get("command")
805                        .and_then(|c| c.as_str())
806                        .is_some_and(|cmd| cmd.contains("lean-ctx"))
807                });
808                if arr.len() < before {
809                    modified = true;
810                }
811            }
812        }
813    }
814
815    if !modified {
816        return None;
817    }
818
819    let has_remaining_hooks =
820        parsed
821            .get("hooks")
822            .and_then(|h| h.as_object())
823            .is_some_and(|hooks| {
824                hooks
825                    .values()
826                    .any(|entries| entries.as_array().is_some_and(|a| !a.is_empty()))
827            });
828
829    if has_remaining_hooks {
830        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
831    } else {
832        None
833    }
834}
835
836// ---------------------------------------------------------------------------
837// Data directory
838// ---------------------------------------------------------------------------
839
840fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
841    let data_dir = home.join(".lean-ctx");
842    if !data_dir.exists() {
843        println!("  · No data directory found");
844        return false;
845    }
846
847    if dry_run {
848        println!("  Would remove Data directory (~/.lean-ctx/)");
849        return true;
850    }
851
852    match fs::remove_dir_all(&data_dir) {
853        Ok(()) => {
854            println!("  ✓ Data directory removed (~/.lean-ctx/)");
855            true
856        }
857        Err(e) => {
858            tracing::warn!("Failed to remove ~/.lean-ctx/: {e}");
859            false
860        }
861    }
862}
863
864// ---------------------------------------------------------------------------
865// .bak cleanup: remove orphaned backup files after successful surgical removal
866// ---------------------------------------------------------------------------
867
868fn cleanup_bak_files(home: &Path) {
869    let dirs_to_scan: Vec<PathBuf> = vec![
870        home.join(".cursor"),
871        home.join(".claude"),
872        crate::core::editor_registry::claude_state_dir(home),
873        home.join(".gemini"),
874        home.join(".gemini/antigravity"),
875        home.join(".codex"),
876        home.join(".codeium"),
877        home.join(".codeium/windsurf"),
878        home.join(".config/opencode"),
879        home.join(".config/amp"),
880        home.join(".config/crush"),
881        home.join(".config/zed"),
882        home.join(".qwen"),
883        home.join(".trae"),
884        home.join(".aws/amazonq"),
885        home.join(".kiro"),
886        home.join(".kiro/settings"),
887        home.join(".ampcoder"),
888        home.join(".pi"),
889        home.join(".pi/agent"),
890        home.join(".hermes"),
891        home.join(".verdent"),
892        home.join(".cline"),
893        home.join(".roo"),
894        home.join(".continue"),
895        home.join(".jb-rules"),
896    ];
897
898    let mut cleaned = 0;
899    for dir in &dirs_to_scan {
900        if !dir.exists() {
901            continue;
902        }
903        if let Ok(entries) = fs::read_dir(dir) {
904            for entry in entries.flatten() {
905                let name = entry.file_name();
906                let name_str = name.to_string_lossy();
907                if name_str.ends_with(".lean-ctx.tmp") {
908                    let _ = fs::remove_file(entry.path());
909                    cleaned += 1;
910                    continue;
911                }
912                if name_str.ends_with(".lean-ctx.bak") {
913                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
914                    let original = entry.path().with_file_name(original_name);
915                    if original.exists() {
916                        // Only remove backups if the original is already clean.
917                        match fs::read_to_string(&original) {
918                            Ok(c) if !c.contains("lean-ctx") => {
919                                let _ = fs::remove_file(entry.path());
920                                cleaned += 1;
921                            }
922                            _ => {}
923                        }
924                    } else {
925                        // If the original is gone, the backup is no longer needed.
926                        let _ = fs::remove_file(entry.path());
927                        cleaned += 1;
928                    }
929                }
930            }
931        }
932    }
933
934    // Also clean shell RC backups
935    let rc_baks = [
936        home.join(".zshrc.lean-ctx.bak"),
937        home.join(".zshenv.lean-ctx.bak"),
938        home.join(".bashrc.lean-ctx.bak"),
939        home.join(".bashenv.lean-ctx.bak"),
940    ];
941    for bak in &rc_baks {
942        if bak.exists() {
943            let original_name = bak
944                .file_name()
945                .unwrap_or_default()
946                .to_string_lossy()
947                .trim_end_matches(".lean-ctx.bak")
948                .to_string();
949            let original = bak.with_file_name(original_name);
950            if original.exists() {
951                if let Ok(c) = fs::read_to_string(&original) {
952                    if !c.contains("lean-ctx") {
953                        let _ = fs::remove_file(bak);
954                        cleaned += 1;
955                    }
956                }
957            } else {
958                let _ = fs::remove_file(bak);
959                cleaned += 1;
960            }
961        }
962    }
963
964    if cleaned > 0 {
965        println!("  ✓ Cleaned up {cleaned} backup file(s)");
966    }
967}
968
969// ---------------------------------------------------------------------------
970// Binary removal instructions
971// ---------------------------------------------------------------------------
972
973fn print_binary_removal_instructions() {
974    let binary_path = std::env::current_exe()
975        .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
976
977    println!("  To complete uninstallation, remove the binary:\n");
978
979    if binary_path.contains(".cargo") {
980        println!("    cargo uninstall lean-ctx\n");
981    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
982        println!("    brew uninstall lean-ctx\n");
983    } else {
984        println!("    rm {binary_path}\n");
985    }
986
987    println!("  Then restart your shell.\n");
988}
989
990// ---------------------------------------------------------------------------
991// Shell block removal
992// ---------------------------------------------------------------------------
993
994fn remove_lean_ctx_block(content: &str) -> String {
995    if content.contains("# lean-ctx shell hook — end") {
996        return remove_lean_ctx_block_by_marker(content);
997    }
998    remove_lean_ctx_block_legacy(content)
999}
1000
1001fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1002    let mut result = String::new();
1003    let mut in_block = false;
1004
1005    for line in content.lines() {
1006        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1007            in_block = true;
1008            continue;
1009        }
1010        if in_block {
1011            if line.trim() == "# lean-ctx shell hook — end" {
1012                in_block = false;
1013            }
1014            continue;
1015        }
1016        result.push_str(line);
1017        result.push('\n');
1018    }
1019    result
1020}
1021
1022fn remove_lean_ctx_block_legacy(content: &str) -> String {
1023    let mut result = String::new();
1024    let mut in_block = false;
1025
1026    for line in content.lines() {
1027        if line.contains("lean-ctx shell hook") {
1028            in_block = true;
1029            continue;
1030        }
1031        if in_block {
1032            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1033                if line.trim() == "fi" || line.trim() == "end" {
1034                    in_block = false;
1035                }
1036                continue;
1037            }
1038            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1039                in_block = false;
1040                result.push_str(line);
1041                result.push('\n');
1042            }
1043            continue;
1044        }
1045        result.push_str(line);
1046        result.push('\n');
1047    }
1048    result
1049}
1050
1051// ---------------------------------------------------------------------------
1052// JSON removal — textual approach preserving comments and formatting
1053// ---------------------------------------------------------------------------
1054
1055fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
1056    // Try textual removal first (preserves comments, formatting, key order)
1057    if let Some(result) = remove_lean_ctx_from_json_textual(content) {
1058        return Some(result);
1059    }
1060
1061    // Fallback to serde-based approach for edge cases
1062    remove_lean_ctx_from_json_serde(content)
1063}
1064
1065/// Textual JSON key removal: finds `"lean-ctx"` key-value pairs and removes
1066/// them from the raw text without re-serializing. Preserves JSONC comments,
1067/// formatting, trailing commas, and key ordering.
1068fn remove_lean_ctx_from_json_textual(content: &str) -> Option<String> {
1069    let mut result = content.to_string();
1070    let mut modified = false;
1071
1072    // Repeatedly find and remove "lean-ctx" entries until none remain.
1073    // Each iteration rescans because positions shift after removal.
1074    while let Some(key_start) = find_json_key_position(result.as_bytes(), "lean-ctx") {
1075        let Some(new_result) = remove_json_entry_at(&result, key_start) else {
1076            break;
1077        };
1078
1079        result = new_result;
1080        modified = true;
1081    }
1082
1083    // Also handle array-style entries: {"name": "lean-ctx", ...}
1084    loop {
1085        let bytes = result.as_bytes();
1086        let Some(pos) = find_named_array_entry(bytes, "lean-ctx") else {
1087            break;
1088        };
1089        let Some(new_result) = remove_array_entry_at(&result, pos) else {
1090            break;
1091        };
1092        result = new_result;
1093        modified = true;
1094    }
1095
1096    if modified {
1097        // Validate the result is still valid JSON(C) if the input was valid
1098        if crate::core::jsonc::parse_jsonc(&result).is_ok() {
1099            Some(result)
1100        } else if crate::core::jsonc::parse_jsonc(content).is_ok() {
1101            // Input was valid but our textual removal broke it — don't use this result
1102            None
1103        } else {
1104            // Input was already invalid, return our best effort
1105            Some(result)
1106        }
1107    } else {
1108        None
1109    }
1110}
1111
1112/// Find the byte position of a JSON key `"key_name"` that is followed by `:`.
1113fn find_json_key_position(bytes: &[u8], key_name: &str) -> Option<usize> {
1114    let needle = format!("\"{key_name}\"");
1115    let needle_bytes = needle.as_bytes();
1116    let mut i = 0;
1117
1118    while i + needle_bytes.len() <= bytes.len() {
1119        if &bytes[i..i + needle_bytes.len()] == needle_bytes {
1120            // Check it's followed by `:` (after optional whitespace)
1121            let after = i + needle_bytes.len();
1122            let mut j = after;
1123            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1124                j += 1;
1125            }
1126            if j < bytes.len() && bytes[j] == b':' {
1127                // Make sure we're not inside a string by checking if we have
1128                // an even number of unescaped quotes before this position
1129                if !is_inside_string(bytes, i) {
1130                    return Some(i);
1131                }
1132            }
1133        }
1134        i += 1;
1135    }
1136    None
1137}
1138
1139/// Check if position `pos` is inside a JSON string literal.
1140fn is_inside_string(bytes: &[u8], pos: usize) -> bool {
1141    let mut in_string = false;
1142    let mut i = 0;
1143    while i < pos {
1144        match bytes[i] {
1145            b'"' if !in_string => in_string = true,
1146            b'"' if in_string => in_string = false,
1147            b'\\' if in_string => {
1148                i += 1; // skip escaped char
1149            }
1150            b'/' if !in_string && i + 1 < bytes.len() => {
1151                if bytes[i + 1] == b'/' {
1152                    // Line comment — skip to end of line
1153                    while i < pos && i < bytes.len() && bytes[i] != b'\n' {
1154                        i += 1;
1155                    }
1156                } else if bytes[i + 1] == b'*' {
1157                    // Block comment — skip to */
1158                    i += 2;
1159                    while i + 1 < bytes.len() {
1160                        if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1161                            i += 2;
1162                            break;
1163                        }
1164                        i += 1;
1165                    }
1166                    continue;
1167                }
1168            }
1169            _ => {}
1170        }
1171        i += 1;
1172    }
1173    in_string
1174}
1175
1176/// Remove a JSON key-value entry starting at `key_start` position.
1177/// Handles surrounding commas and whitespace.
1178fn remove_json_entry_at(content: &str, key_start: usize) -> Option<String> {
1179    let bytes = content.as_bytes();
1180
1181    // Find the colon after the key
1182    let key_name_end = content[key_start + 1..].find('"')? + key_start + 2;
1183    let mut colon_pos = key_name_end;
1184    while colon_pos < bytes.len() && bytes[colon_pos] != b':' {
1185        colon_pos += 1;
1186    }
1187    if colon_pos >= bytes.len() {
1188        return None;
1189    }
1190
1191    // Skip the value
1192    let value_start = colon_pos + 1;
1193    let value_end = skip_json_value(bytes, value_start)?;
1194
1195    // Determine the range to remove, including surrounding comma and whitespace.
1196    // Scan backwards from key_start to find leading comma or whitespace.
1197    let mut remove_start = key_start;
1198
1199    // Look backwards for a comma (we might be after a comma)
1200    let mut scan_back = key_start;
1201    while scan_back > 0 {
1202        scan_back -= 1;
1203        let ch = bytes[scan_back];
1204        if ch == b',' {
1205            remove_start = scan_back;
1206            break;
1207        }
1208        if ch == b'{' || ch == b'[' {
1209            break;
1210        }
1211        if !ch.is_ascii_whitespace() {
1212            break;
1213        }
1214    }
1215
1216    // Extend remove_start back to include the newline before the comma/key
1217    if remove_start > 0 && remove_start == key_start {
1218        let mut ns = remove_start;
1219        while ns > 0 && bytes[ns - 1].is_ascii_whitespace() && bytes[ns - 1] != b'\n' {
1220            ns -= 1;
1221        }
1222        if ns > 0 && bytes[ns - 1] == b'\n' {
1223            remove_start = ns;
1224        }
1225    }
1226
1227    let mut remove_end = value_end;
1228
1229    // Look forward for a trailing comma
1230    let mut scan_fwd = value_end;
1231    while scan_fwd < bytes.len() && bytes[scan_fwd].is_ascii_whitespace() {
1232        scan_fwd += 1;
1233    }
1234    if scan_fwd < bytes.len() && bytes[scan_fwd] == b',' {
1235        // If we already consumed a leading comma, don't consume trailing too
1236        if remove_start < key_start && remove_start < bytes.len() && bytes[remove_start] == b',' {
1237            // Already have leading comma removed, skip trailing
1238        } else {
1239            remove_end = scan_fwd + 1;
1240        }
1241    }
1242
1243    // Skip trailing whitespace/newline after the removed entry
1244    while remove_end < bytes.len()
1245        && (bytes[remove_end] == b' ' || bytes[remove_end] == b'\t' || bytes[remove_end] == b'\r')
1246    {
1247        remove_end += 1;
1248    }
1249    if remove_end < bytes.len() && bytes[remove_end] == b'\n' {
1250        remove_end += 1;
1251    }
1252
1253    let mut result = String::with_capacity(content.len());
1254    result.push_str(&content[..remove_start]);
1255    result.push_str(&content[remove_end..]);
1256    Some(result)
1257}
1258
1259/// Find an array entry like `{"name": "lean-ctx", ...}` and return its start position.
1260fn find_named_array_entry(bytes: &[u8], name: &str) -> Option<usize> {
1261    let needle = format!("\"{name}\"");
1262    let needle_bytes = needle.as_bytes();
1263    let mut i = 0;
1264
1265    while i + needle_bytes.len() <= bytes.len() {
1266        if &bytes[i..i + needle_bytes.len()] == needle_bytes && !is_inside_string(bytes, i) {
1267            // Check this is a value (preceded by `:` after `"name"`)
1268            // Scan backwards to check if the key is "name"
1269            let mut j = i;
1270            while j > 0 && bytes[j - 1].is_ascii_whitespace() {
1271                j -= 1;
1272            }
1273            if j > 0 && bytes[j - 1] == b':' {
1274                j -= 1;
1275                while j > 0 && bytes[j - 1].is_ascii_whitespace() {
1276                    j -= 1;
1277                }
1278                if j >= 6 && &bytes[j - 6..j] == b"\"name\"" {
1279                    // Found "name": "lean-ctx" — now find the enclosing object `{`
1280                    let mut obj_start = j - 6;
1281                    while obj_start > 0 {
1282                        if bytes[obj_start] == b'{' && !is_inside_string(bytes, obj_start) {
1283                            return Some(obj_start);
1284                        }
1285                        obj_start -= 1;
1286                    }
1287                }
1288            }
1289        }
1290        i += 1;
1291    }
1292    None
1293}
1294
1295/// Remove an array entry (object) starting at `entry_start`, handling commas.
1296fn remove_array_entry_at(content: &str, entry_start: usize) -> Option<String> {
1297    let bytes = content.as_bytes();
1298    if bytes[entry_start] != b'{' {
1299        return None;
1300    }
1301    let entry_end = skip_json_value(bytes, entry_start)?;
1302
1303    let mut remove_start = entry_start;
1304    let mut remove_end = entry_end;
1305
1306    // Handle leading whitespace
1307    while remove_start > 0 && (bytes[remove_start - 1] == b' ' || bytes[remove_start - 1] == b'\t')
1308    {
1309        remove_start -= 1;
1310    }
1311
1312    // Handle trailing comma
1313    let mut fwd = entry_end;
1314    while fwd < bytes.len() && bytes[fwd].is_ascii_whitespace() {
1315        fwd += 1;
1316    }
1317    if fwd < bytes.len() && bytes[fwd] == b',' {
1318        remove_end = fwd + 1;
1319    } else {
1320        // No trailing comma — check for leading comma
1321        let mut back = remove_start;
1322        while back > 0 && bytes[back - 1].is_ascii_whitespace() {
1323            back -= 1;
1324        }
1325        if back > 0 && bytes[back - 1] == b',' {
1326            remove_start = back - 1;
1327        }
1328    }
1329
1330    // Skip trailing newline
1331    while remove_end < bytes.len()
1332        && (bytes[remove_end] == b' ' || bytes[remove_end] == b'\t' || bytes[remove_end] == b'\r')
1333    {
1334        remove_end += 1;
1335    }
1336    if remove_end < bytes.len() && bytes[remove_end] == b'\n' {
1337        remove_end += 1;
1338    }
1339
1340    let mut result = String::with_capacity(content.len());
1341    result.push_str(&content[..remove_start]);
1342    result.push_str(&content[remove_end..]);
1343    Some(result)
1344}
1345
1346/// Skip over a JSON value (object, array, string, number, boolean, null)
1347/// starting from `start`. Returns the position after the value.
1348fn skip_json_value(bytes: &[u8], start: usize) -> Option<usize> {
1349    let mut i = start;
1350
1351    // Skip whitespace
1352    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1353        i += 1;
1354    }
1355    if i >= bytes.len() {
1356        return None;
1357    }
1358
1359    match bytes[i] {
1360        b'{' | b'[' => {
1361            let open = bytes[i];
1362            let close = if open == b'{' { b'}' } else { b']' };
1363            let mut depth = 1;
1364            i += 1;
1365            while i < bytes.len() && depth > 0 {
1366                match bytes[i] {
1367                    c if c == open => depth += 1,
1368                    c if c == close => {
1369                        depth -= 1;
1370                        if depth == 0 {
1371                            return Some(i + 1);
1372                        }
1373                    }
1374                    b'"' => {
1375                        i += 1;
1376                        while i < bytes.len() {
1377                            if bytes[i] == b'\\' {
1378                                i += 1;
1379                            } else if bytes[i] == b'"' {
1380                                break;
1381                            }
1382                            i += 1;
1383                        }
1384                    }
1385                    b'/' if i + 1 < bytes.len() => {
1386                        if bytes[i + 1] == b'/' {
1387                            while i < bytes.len() && bytes[i] != b'\n' {
1388                                i += 1;
1389                            }
1390                            continue;
1391                        } else if bytes[i + 1] == b'*' {
1392                            i += 2;
1393                            while i + 1 < bytes.len() {
1394                                if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1395                                    i += 1;
1396                                    break;
1397                                }
1398                                i += 1;
1399                            }
1400                        }
1401                    }
1402                    _ => {}
1403                }
1404                i += 1;
1405            }
1406            Some(i)
1407        }
1408        b'"' => {
1409            i += 1;
1410            while i < bytes.len() {
1411                if bytes[i] == b'\\' {
1412                    i += 1;
1413                } else if bytes[i] == b'"' {
1414                    return Some(i + 1);
1415                }
1416                i += 1;
1417            }
1418            None
1419        }
1420        _ => {
1421            // Number, boolean, null
1422            while i < bytes.len() && !matches!(bytes[i], b',' | b'}' | b']' | b'\n' | b'\r') {
1423                i += 1;
1424            }
1425            Some(i)
1426        }
1427    }
1428}
1429
1430/// Fallback: serde-based JSON removal (destroys comments/formatting).
1431fn remove_lean_ctx_from_json_serde(content: &str) -> Option<String> {
1432    let mut parsed: serde_json::Value = crate::core::jsonc::parse_jsonc(content).ok()?;
1433    let mut modified = false;
1434
1435    if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
1436        modified |= servers.remove("lean-ctx").is_some();
1437    }
1438
1439    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
1440        modified |= servers.remove("lean-ctx").is_some();
1441    }
1442
1443    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
1444        let before = servers.len();
1445        servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
1446        modified |= servers.len() < before;
1447    }
1448
1449    if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
1450        modified |= mcp.remove("lean-ctx").is_some();
1451    }
1452
1453    if let Some(amp) = parsed
1454        .get_mut("amp.mcpServers")
1455        .and_then(|s| s.as_object_mut())
1456    {
1457        modified |= amp.remove("lean-ctx").is_some();
1458    }
1459
1460    if modified {
1461        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
1462    } else {
1463        None
1464    }
1465}
1466
1467// ---------------------------------------------------------------------------
1468// YAML removal
1469// ---------------------------------------------------------------------------
1470
1471fn remove_lean_ctx_from_yaml(content: &str) -> String {
1472    let mut out = String::with_capacity(content.len());
1473    let mut skip_depth: Option<usize> = None;
1474
1475    for line in content.lines() {
1476        if let Some(depth) = skip_depth {
1477            let indent = line.len() - line.trim_start().len();
1478            if indent > depth || line.trim().is_empty() {
1479                continue;
1480            }
1481            skip_depth = None;
1482        }
1483
1484        let trimmed = line.trim();
1485        if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
1486            let indent = line.len() - line.trim_start().len();
1487            skip_depth = Some(indent);
1488            continue;
1489        }
1490
1491        out.push_str(line);
1492        out.push('\n');
1493    }
1494
1495    out
1496}
1497
1498// ---------------------------------------------------------------------------
1499// TOML removal
1500// ---------------------------------------------------------------------------
1501
1502fn remove_lean_ctx_from_toml(content: &str) -> String {
1503    let mut out = String::with_capacity(content.len());
1504    let mut skip = false;
1505
1506    for line in content.lines() {
1507        let trimmed = line.trim();
1508
1509        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1510            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
1511            if section == "mcp_servers.lean-ctx"
1512                || section == "mcp_servers.\"lean-ctx\""
1513                || section.starts_with("mcp_servers.lean-ctx.")
1514                || section.starts_with("mcp_servers.\"lean-ctx\".")
1515            {
1516                skip = true;
1517                continue;
1518            }
1519            skip = false;
1520        }
1521
1522        if skip {
1523            continue;
1524        }
1525
1526        if trimmed.contains("codex_hooks") && trimmed.contains("true") {
1527            out.push_str(&line.replace("true", "false"));
1528            out.push('\n');
1529            continue;
1530        }
1531
1532        out.push_str(line);
1533        out.push('\n');
1534    }
1535
1536    let cleaned: String = out
1537        .lines()
1538        .filter(|l| l.trim() != "[]")
1539        .collect::<Vec<_>>()
1540        .join("\n");
1541    if cleaned.is_empty() {
1542        cleaned
1543    } else {
1544        cleaned + "\n"
1545    }
1546}
1547
1548// moved to core/editor_registry/paths.rs
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553
1554    // --- TOML tests ---
1555
1556    #[test]
1557    fn remove_toml_mcp_server_section() {
1558        let input = "\
1559[features]
1560codex_hooks = true
1561
1562[mcp_servers.lean-ctx]
1563command = \"/usr/local/bin/lean-ctx\"
1564args = []
1565
1566[mcp_servers.other-tool]
1567command = \"/usr/bin/other\"
1568";
1569        let result = remove_lean_ctx_from_toml(input);
1570        assert!(
1571            !result.contains("lean-ctx"),
1572            "lean-ctx section should be removed"
1573        );
1574        assert!(
1575            result.contains("[mcp_servers.other-tool]"),
1576            "other sections should be preserved"
1577        );
1578        assert!(
1579            result.contains("codex_hooks = false"),
1580            "codex_hooks should be set to false"
1581        );
1582    }
1583
1584    #[test]
1585    fn remove_toml_only_lean_ctx() {
1586        let input = "\
1587[mcp_servers.lean-ctx]
1588command = \"lean-ctx\"
1589";
1590        let result = remove_lean_ctx_from_toml(input);
1591        assert!(
1592            result.trim().is_empty(),
1593            "should produce empty output: {result}"
1594        );
1595    }
1596
1597    #[test]
1598    fn remove_toml_no_lean_ctx() {
1599        let input = "\
1600[mcp_servers.other]
1601command = \"other\"
1602";
1603        let result = remove_lean_ctx_from_toml(input);
1604        assert!(
1605            result.contains("[mcp_servers.other]"),
1606            "other content should be preserved"
1607        );
1608    }
1609
1610    // --- JSON textual removal tests ---
1611
1612    #[test]
1613    fn json_textual_removes_key_from_object() {
1614        let input = r#"{
1615  "mcpServers": {
1616    "other-tool": {
1617      "command": "other"
1618    },
1619    "lean-ctx": {
1620      "command": "/usr/bin/lean-ctx",
1621      "args": []
1622    }
1623  }
1624}
1625"#;
1626        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1627        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1628        assert!(
1629            result.contains("other-tool"),
1630            "other-tool should be preserved"
1631        );
1632        // Verify valid JSON
1633        assert!(
1634            crate::core::jsonc::parse_jsonc(&result).is_ok(),
1635            "result should be valid JSON: {result}"
1636        );
1637    }
1638
1639    #[test]
1640    fn json_textual_preserves_comments() {
1641        let input = r#"{
1642  // This is a user comment
1643  "mcpServers": {
1644    "lean-ctx": {
1645      "command": "lean-ctx"
1646    },
1647    "my-tool": {
1648      "command": "my-tool"
1649    }
1650  }
1651}
1652"#;
1653        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1654        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1655        assert!(
1656            result.contains("// This is a user comment"),
1657            "comment should be preserved: {result}"
1658        );
1659        assert!(result.contains("my-tool"), "my-tool should be preserved");
1660    }
1661
1662    #[test]
1663    fn json_textual_only_lean_ctx() {
1664        let input = r#"{
1665  "mcpServers": {
1666    "lean-ctx": {
1667      "command": "lean-ctx"
1668    }
1669  }
1670}
1671"#;
1672        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1673        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1674    }
1675
1676    #[test]
1677    fn json_no_lean_ctx_returns_none() {
1678        let input = r#"{"mcpServers": {"other": {"command": "other"}}}"#;
1679        assert!(remove_lean_ctx_from_json(input).is_none());
1680    }
1681
1682    // --- Shared rules (SharedMarkdown) tests ---
1683
1684    #[test]
1685    fn shared_markdown_surgical_removal() {
1686        let input = "# My custom rules\n\nDo this and that.\n\n\
1687                      # lean-ctx — Context Engineering Layer\n\
1688                      <!-- lean-ctx-rules-v9 -->\n\n\
1689                      Use ctx_read instead of Read.\n\
1690                      <!-- /lean-ctx -->\n\n\
1691                      # Other section\n\nMore user content.\n";
1692
1693        let cleaned = remove_marked_block(
1694            input,
1695            "# lean-ctx — Context Engineering Layer",
1696            "<!-- /lean-ctx -->",
1697        );
1698
1699        assert!(
1700            !cleaned.contains("lean-ctx"),
1701            "lean-ctx block should be removed"
1702        );
1703        assert!(
1704            cleaned.contains("My custom rules"),
1705            "user content before should be preserved"
1706        );
1707        assert!(
1708            cleaned.contains("Other section"),
1709            "user content after should be preserved"
1710        );
1711        assert!(
1712            cleaned.contains("More user content"),
1713            "user content after should be preserved"
1714        );
1715    }
1716
1717    #[test]
1718    fn shared_markdown_only_lean_ctx() {
1719        let input = "# lean-ctx — Context Engineering Layer\n\
1720                      <!-- lean-ctx-rules-v9 -->\n\
1721                      content\n\
1722                      <!-- /lean-ctx -->\n";
1723
1724        let cleaned = remove_marked_block(
1725            input,
1726            "# lean-ctx — Context Engineering Layer",
1727            "<!-- /lean-ctx -->",
1728        );
1729
1730        assert!(
1731            cleaned.trim().is_empty() || !cleaned.contains("lean-ctx"),
1732            "should be empty or without lean-ctx: '{cleaned}'"
1733        );
1734    }
1735
1736    // --- Project files (.cursorrules) tests ---
1737
1738    #[test]
1739    fn cursorrules_surgical_removal() {
1740        let input = "# My project rules\n\n\
1741                      Always use TypeScript.\n\n\
1742                      # lean-ctx — Context Engineering Layer\n\n\
1743                      PREFER lean-ctx MCP tools over native equivalents.\n";
1744
1745        let cleaned = remove_lean_ctx_section_from_rules(input);
1746
1747        assert!(
1748            !cleaned.contains("lean-ctx"),
1749            "lean-ctx section should be removed"
1750        );
1751        assert!(
1752            cleaned.contains("My project rules"),
1753            "user rules should be preserved"
1754        );
1755        assert!(
1756            cleaned.contains("Always use TypeScript"),
1757            "user content should be preserved"
1758        );
1759    }
1760
1761    #[test]
1762    fn cursorrules_only_lean_ctx() {
1763        let input = "# lean-ctx — Context Engineering Layer\n\n\
1764                      PREFER lean-ctx MCP tools.\n";
1765
1766        let cleaned = remove_lean_ctx_section_from_rules(input);
1767        assert!(
1768            cleaned.trim().is_empty(),
1769            "should be empty when only lean-ctx content: '{cleaned}'"
1770        );
1771    }
1772
1773    // --- hooks.json tests ---
1774
1775    #[test]
1776    fn hooks_json_preserves_other_hooks() {
1777        let input = r#"{
1778  "version": 1,
1779  "hooks": {
1780    "preToolUse": [
1781      {
1782        "matcher": "Shell",
1783        "command": "lean-ctx hook rewrite"
1784      },
1785      {
1786        "matcher": "Shell",
1787        "command": "my-other-tool hook"
1788      }
1789    ]
1790  }
1791}"#;
1792        let result = remove_lean_ctx_from_hooks_json(input).expect("should return cleaned JSON");
1793        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1794        assert!(
1795            result.contains("my-other-tool"),
1796            "other hooks should be preserved"
1797        );
1798    }
1799
1800    #[test]
1801    fn hooks_json_returns_none_when_only_lean_ctx() {
1802        let input = r#"{
1803  "version": 1,
1804  "hooks": {
1805    "preToolUse": [
1806      {
1807        "matcher": "Shell",
1808        "command": "lean-ctx hook rewrite"
1809      },
1810      {
1811        "matcher": "Read|Grep",
1812        "command": "lean-ctx hook redirect"
1813      }
1814    ]
1815  }
1816}"#;
1817        assert!(
1818            remove_lean_ctx_from_hooks_json(input).is_none(),
1819            "should return None when all hooks are lean-ctx"
1820        );
1821    }
1822
1823    // --- Marked block tests ---
1824
1825    #[test]
1826    fn marked_block_preserves_surrounding() {
1827        let content = "before\n<!-- lean-ctx -->\nhook content\n<!-- /lean-ctx -->\nafter\n";
1828        let cleaned = remove_marked_block(content, "<!-- lean-ctx -->", "<!-- /lean-ctx -->");
1829        assert!(!cleaned.contains("hook content"));
1830        assert!(cleaned.contains("before"));
1831        assert!(cleaned.contains("after"));
1832    }
1833
1834    #[test]
1835    fn marked_block_preserves_when_missing() {
1836        let content = "no hook here\n";
1837        let cleaned = remove_marked_block(content, "<!-- lean-ctx -->", "<!-- /lean-ctx -->");
1838        assert_eq!(cleaned, content);
1839    }
1840
1841    #[test]
1842    fn backup_before_modify_respects_dry_run() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let path = dir.path().join("file.txt");
1845        std::fs::write(&path, "hello").unwrap();
1846
1847        backup_before_modify(&path, true);
1848        assert!(
1849            !bak_path_for(&path).exists(),
1850            "dry-run must not create backups"
1851        );
1852
1853        backup_before_modify(&path, false);
1854        assert!(
1855            bak_path_for(&path).exists(),
1856            "non-dry-run should create backups"
1857        );
1858    }
1859}