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/mcp.json")),
441        ("Trae", home.join(".trae/mcp.json")),
442        ("Amazon Q Developer", home.join(".aws/amazonq/mcp.json")),
443        ("JetBrains IDEs", home.join(".jb-mcp.json")),
444        ("AWS Kiro", home.join(".kiro/settings/mcp.json")),
445        ("Verdent", home.join(".verdent/mcp.json")),
446        ("Aider", home.join(".aider/mcp.json")),
447        ("Amp", home.join(".config/amp/settings.json")),
448        ("Crush", home.join(".config/crush/crush.json")),
449        ("Pi Coding Agent", home.join(".pi/agent/mcp.json")),
450        ("Cline", crate::core::editor_registry::cline_mcp_path()),
451        ("Roo Code", crate::core::editor_registry::roo_mcp_path()),
452        ("Hermes Agent", home.join(".hermes/config.yaml")),
453    ];
454
455    let mut removed = false;
456
457    for (name, path) in &configs {
458        if !path.exists() {
459            continue;
460        }
461        let Ok(content) = fs::read_to_string(path) else {
462            continue;
463        };
464        if !content.contains("lean-ctx") {
465            continue;
466        }
467
468        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
469        let is_yaml = ext == "yaml" || ext == "yml";
470        let is_toml = ext == "toml";
471
472        let cleaned = if is_yaml {
473            Some(remove_lean_ctx_from_yaml(&content))
474        } else if is_toml {
475            Some(remove_lean_ctx_from_toml(&content))
476        } else {
477            remove_lean_ctx_from_json(&content)
478        };
479
480        if let Some(cleaned) = cleaned {
481            backup_before_modify(path, dry_run);
482            if let Err(e) = safe_write(path, &cleaned, dry_run) {
483                tracing::warn!("Failed to update {} config: {}", name, e);
484            } else {
485                let verb = if dry_run { "Would update" } else { "✓" };
486                println!("  {verb} MCP config removed from {name}");
487                removed = true;
488            }
489        }
490    }
491
492    let zed_path = crate::core::editor_registry::zed_settings_path(home);
493    if zed_path.exists() {
494        if let Ok(content) = fs::read_to_string(&zed_path) {
495            if content.contains("lean-ctx") {
496                println!(
497                    "  ⚠ Zed: manually remove lean-ctx from {}",
498                    shorten(&zed_path, home)
499                );
500            }
501        }
502    }
503
504    let vscode_path = crate::core::editor_registry::vscode_mcp_path();
505    if vscode_path.exists() {
506        if let Ok(content) = fs::read_to_string(&vscode_path) {
507            if content.contains("lean-ctx") {
508                if let Some(cleaned) = remove_lean_ctx_from_json(&content) {
509                    backup_before_modify(&vscode_path, dry_run);
510                    if let Err(e) = safe_write(&vscode_path, &cleaned, dry_run) {
511                        tracing::warn!("Failed to update VS Code config: {e}");
512                    } else {
513                        let verb = if dry_run { "Would update" } else { "✓" };
514                        println!("  {verb} MCP config removed from VS Code / Copilot");
515                        removed = true;
516                    }
517                }
518            }
519        }
520    }
521
522    removed
523}
524
525// ---------------------------------------------------------------------------
526// Rules files removal (shared vs dedicated)
527// ---------------------------------------------------------------------------
528
529fn remove_rules_files(home: &Path, dry_run: bool) -> bool {
530    // Dedicated files: entirely owned by lean-ctx — safe to delete
531    let dedicated_files: Vec<(&str, PathBuf)> = vec![
532        (
533            "Claude Code",
534            crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
535        ),
536        ("Cursor", home.join(".cursor/rules/lean-ctx.mdc")),
537        (
538            "Gemini CLI (legacy)",
539            home.join(".gemini/rules/lean-ctx.md"),
540        ),
541        ("Codex CLI", home.join(".codex/LEAN-CTX.md")),
542        ("Windsurf", home.join(".codeium/windsurf/rules/lean-ctx.md")),
543        ("Zed", home.join(".config/zed/rules/lean-ctx.md")),
544        ("Cline", home.join(".cline/rules/lean-ctx.md")),
545        ("Roo Code", home.join(".roo/rules/lean-ctx.md")),
546        ("OpenCode", home.join(".config/opencode/rules/lean-ctx.md")),
547        ("Continue", home.join(".continue/rules/lean-ctx.md")),
548        ("Aider", home.join(".aider/rules/lean-ctx.md")),
549        ("Amp", home.join(".ampcoder/rules/lean-ctx.md")),
550        ("Qwen Code", home.join(".qwen/rules/lean-ctx.md")),
551        ("Trae", home.join(".trae/rules/lean-ctx.md")),
552        (
553            "Amazon Q Developer",
554            home.join(".aws/amazonq/rules/lean-ctx.md"),
555        ),
556        ("JetBrains IDEs", home.join(".jb-rules/lean-ctx.md")),
557        (
558            "Antigravity",
559            home.join(".gemini/antigravity/rules/lean-ctx.md"),
560        ),
561        ("Pi Coding Agent", home.join(".pi/rules/lean-ctx.md")),
562        ("AWS Kiro", home.join(".kiro/steering/lean-ctx.md")),
563        ("Verdent", home.join(".verdent/rules/lean-ctx.md")),
564        ("Crush", home.join(".config/crush/rules/lean-ctx.md")),
565    ];
566
567    // Shared files: contain user content + lean-ctx block with markers.
568    // Only remove the <!-- lean-ctx --> ... <!-- /lean-ctx --> section.
569    let shared_files: Vec<(&str, PathBuf)> = vec![
570        (
571            "Claude Code (legacy)",
572            crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"),
573        ),
574        ("Claude Code (legacy home)", home.join(".claude/CLAUDE.md")),
575        ("Gemini CLI", home.join(".gemini/GEMINI.md")),
576        ("Codex CLI", home.join(".codex/instructions.md")),
577        ("VS Code / Copilot", copilot_instructions_path(home)),
578    ];
579
580    let mut removed = false;
581
582    // --- Dedicated: delete if contains lean-ctx ---
583    for (name, path) in &dedicated_files {
584        if !path.exists() {
585            continue;
586        }
587        if let Ok(content) = fs::read_to_string(path) {
588            if content.contains("lean-ctx") {
589                if let Err(e) = safe_remove(path, dry_run) {
590                    tracing::warn!("Failed to remove {name} rules: {e}");
591                } else {
592                    let verb = if dry_run { "Would remove" } else { "✓" };
593                    println!("  {verb} Rules removed from {name}");
594                    removed = true;
595                }
596            }
597        }
598    }
599
600    // --- Shared: surgically remove lean-ctx section, keep user content ---
601    const RULES_MARKER: &str = "# lean-ctx — Context Engineering Layer";
602    const RULES_END: &str = "<!-- /lean-ctx -->";
603
604    for (name, path) in &shared_files {
605        if !path.exists() {
606            continue;
607        }
608        let Ok(content) = fs::read_to_string(path) else {
609            continue;
610        };
611        if !content.contains("lean-ctx") {
612            continue;
613        }
614
615        let cleaned = if content.contains(RULES_END) {
616            remove_marked_block(&content, RULES_MARKER, RULES_END)
617        } else {
618            remove_lean_ctx_block_from_md(&content)
619        };
620
621        if cleaned.trim().is_empty() {
622            backup_before_modify(path, dry_run);
623            let _ = safe_remove(path, dry_run);
624            let verb = if dry_run { "Would remove" } else { "✓" };
625            println!("  {verb} Rules removed from {name} (file was lean-ctx only)");
626        } else if cleaned.trim() != content.trim() {
627            backup_before_modify(path, dry_run);
628            let _ = safe_write(path, &cleaned, dry_run);
629            let verb = if dry_run { "Would clean" } else { "✓" };
630            println!("  {verb} Rules removed from {name} (user content preserved)");
631        }
632        removed = true;
633    }
634
635    // --- Hermes Agent: block-based removal from shared HERMES.md ---
636    let hermes_md = home.join(".hermes/HERMES.md");
637    if hermes_md.exists() {
638        if let Ok(content) = fs::read_to_string(&hermes_md) {
639            if content.contains("lean-ctx") {
640                let cleaned = remove_lean_ctx_block_from_md(&content);
641                backup_before_modify(&hermes_md, dry_run);
642                if cleaned.trim().is_empty() {
643                    let _ = safe_remove(&hermes_md, dry_run);
644                } else {
645                    let _ = safe_write(&hermes_md, &cleaned, dry_run);
646                }
647                let verb = if dry_run { "Would clean" } else { "✓" };
648                println!("  {verb} Rules removed from Hermes Agent");
649                removed = true;
650            }
651        }
652    }
653
654    if let Ok(cwd) = std::env::current_dir() {
655        let project_hermes = cwd.join(".hermes.md");
656        if project_hermes.exists() {
657            if let Ok(content) = fs::read_to_string(&project_hermes) {
658                if content.contains("lean-ctx") {
659                    let cleaned = remove_lean_ctx_block_from_md(&content);
660                    backup_before_modify(&project_hermes, dry_run);
661                    if cleaned.trim().is_empty() {
662                        let _ = safe_remove(&project_hermes, dry_run);
663                    } else {
664                        let _ = safe_write(&project_hermes, &cleaned, dry_run);
665                    }
666                    let verb = if dry_run { "Would clean" } else { "✓" };
667                    println!("  {verb} Rules removed from .hermes.md");
668                    removed = true;
669                }
670            }
671        }
672    }
673
674    if !removed {
675        println!("  · No rules files found");
676    }
677    removed
678}
679
680fn remove_lean_ctx_block_from_md(content: &str) -> String {
681    let mut out = String::with_capacity(content.len());
682    let mut in_block = false;
683
684    for line in content.lines() {
685        if !in_block && line.contains("lean-ctx") && line.starts_with('#') {
686            in_block = true;
687            continue;
688        }
689        if in_block {
690            if line.starts_with('#') && !line.contains("lean-ctx") {
691                in_block = false;
692                out.push_str(line);
693                out.push('\n');
694            }
695            continue;
696        }
697        out.push_str(line);
698        out.push('\n');
699    }
700
701    while out.starts_with('\n') {
702        out.remove(0);
703    }
704    while out.ends_with("\n\n") {
705        out.pop();
706    }
707    out
708}
709
710// ---------------------------------------------------------------------------
711// Hook files removal
712// ---------------------------------------------------------------------------
713
714fn remove_hook_files(home: &Path, dry_run: bool) -> bool {
715    let claude_hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
716    let hook_files: Vec<PathBuf> = vec![
717        claude_hooks_dir.join("lean-ctx-rewrite.sh"),
718        claude_hooks_dir.join("lean-ctx-redirect.sh"),
719        claude_hooks_dir.join("lean-ctx-rewrite-native"),
720        claude_hooks_dir.join("lean-ctx-redirect-native"),
721        home.join(".cursor/hooks/lean-ctx-rewrite.sh"),
722        home.join(".cursor/hooks/lean-ctx-redirect.sh"),
723        home.join(".cursor/hooks/lean-ctx-rewrite-native"),
724        home.join(".cursor/hooks/lean-ctx-redirect-native"),
725        home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh"),
726        home.join(".gemini/hooks/lean-ctx-redirect-gemini.sh"),
727        home.join(".gemini/hooks/lean-ctx-hook-gemini.sh"),
728        home.join(".codex/hooks/lean-ctx-rewrite-codex.sh"),
729    ];
730
731    let mut removed = false;
732    for path in &hook_files {
733        if path.exists() {
734            if let Err(e) = safe_remove(path, dry_run) {
735                tracing::warn!("Failed to remove hook {}: {e}", path.display());
736            } else {
737                removed = true;
738            }
739        }
740    }
741
742    if removed {
743        let verb = if dry_run { "Would remove" } else { "✓" };
744        println!("  {verb} Hook scripts removed");
745    }
746
747    // hooks.json: surgically remove lean-ctx entries instead of deleting
748    for (label, hj_path) in [
749        ("Cursor", home.join(".cursor/hooks.json")),
750        ("Codex", home.join(".codex/hooks.json")),
751    ] {
752        if !hj_path.exists() {
753            continue;
754        }
755        let Ok(content) = fs::read_to_string(&hj_path) else {
756            continue;
757        };
758        if !content.contains("lean-ctx") {
759            continue;
760        }
761
762        backup_before_modify(&hj_path, dry_run);
763
764        match remove_lean_ctx_from_hooks_json(&content) {
765            Some(cleaned) if !cleaned.trim().is_empty() => {
766                if let Err(e) = safe_write(&hj_path, &cleaned, dry_run) {
767                    tracing::warn!("Failed to update {label} hooks.json: {e}");
768                } else {
769                    let verb = if dry_run { "Would clean" } else { "✓" };
770                    println!("  {verb} {label} hooks.json cleaned (non-lean-ctx hooks preserved)");
771                    removed = true;
772                }
773            }
774            _ => {
775                if let Err(e) = safe_remove(&hj_path, dry_run) {
776                    tracing::warn!("Failed to remove {label} hooks.json: {e}");
777                } else {
778                    let verb = if dry_run { "Would remove" } else { "✓" };
779                    println!("  {verb} {label} hooks.json removed");
780                    removed = true;
781                }
782            }
783        }
784    }
785
786    removed
787}
788
789/// Remove lean-ctx hook entries from hooks.json, preserving other hooks.
790/// Returns `Some(cleaned_json)` if non-lean-ctx hooks remain, `None` if empty.
791fn remove_lean_ctx_from_hooks_json(content: &str) -> Option<String> {
792    let mut parsed: serde_json::Value = crate::core::jsonc::parse_jsonc(content).ok()?;
793    let mut modified = false;
794
795    if let Some(hooks) = parsed.get_mut("hooks").and_then(|h| h.as_object_mut()) {
796        for entries in hooks.values_mut() {
797            if let Some(arr) = entries.as_array_mut() {
798                let before = arr.len();
799                arr.retain(|entry| {
800                    !entry
801                        .get("command")
802                        .and_then(|c| c.as_str())
803                        .is_some_and(|cmd| cmd.contains("lean-ctx"))
804                });
805                if arr.len() < before {
806                    modified = true;
807                }
808            }
809        }
810    }
811
812    if !modified {
813        return None;
814    }
815
816    let has_remaining_hooks =
817        parsed
818            .get("hooks")
819            .and_then(|h| h.as_object())
820            .is_some_and(|hooks| {
821                hooks
822                    .values()
823                    .any(|entries| entries.as_array().is_some_and(|a| !a.is_empty()))
824            });
825
826    if has_remaining_hooks {
827        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
828    } else {
829        None
830    }
831}
832
833// ---------------------------------------------------------------------------
834// Data directory
835// ---------------------------------------------------------------------------
836
837fn remove_data_dir(home: &Path, dry_run: bool) -> bool {
838    let data_dir = home.join(".lean-ctx");
839    if !data_dir.exists() {
840        println!("  · No data directory found");
841        return false;
842    }
843
844    if dry_run {
845        println!("  Would remove Data directory (~/.lean-ctx/)");
846        return true;
847    }
848
849    match fs::remove_dir_all(&data_dir) {
850        Ok(()) => {
851            println!("  ✓ Data directory removed (~/.lean-ctx/)");
852            true
853        }
854        Err(e) => {
855            tracing::warn!("Failed to remove ~/.lean-ctx/: {e}");
856            false
857        }
858    }
859}
860
861// ---------------------------------------------------------------------------
862// .bak cleanup: remove orphaned backup files after successful surgical removal
863// ---------------------------------------------------------------------------
864
865fn cleanup_bak_files(home: &Path) {
866    let dirs_to_scan: Vec<PathBuf> = vec![
867        home.join(".cursor"),
868        home.join(".claude"),
869        crate::core::editor_registry::claude_state_dir(home),
870        home.join(".gemini"),
871        home.join(".gemini/antigravity"),
872        home.join(".codex"),
873        home.join(".codeium"),
874        home.join(".codeium/windsurf"),
875        home.join(".config/opencode"),
876        home.join(".config/amp"),
877        home.join(".config/crush"),
878        home.join(".config/zed"),
879        home.join(".qwen"),
880        home.join(".trae"),
881        home.join(".aws/amazonq"),
882        home.join(".kiro"),
883        home.join(".kiro/settings"),
884        home.join(".aider"),
885        home.join(".ampcoder"),
886        home.join(".pi"),
887        home.join(".pi/agent"),
888        home.join(".hermes"),
889        home.join(".verdent"),
890        home.join(".cline"),
891        home.join(".roo"),
892        home.join(".continue"),
893        home.join(".jb-rules"),
894    ];
895
896    let mut cleaned = 0;
897    for dir in &dirs_to_scan {
898        if !dir.exists() {
899            continue;
900        }
901        if let Ok(entries) = fs::read_dir(dir) {
902            for entry in entries.flatten() {
903                let name = entry.file_name();
904                let name_str = name.to_string_lossy();
905                if name_str.ends_with(".lean-ctx.tmp") {
906                    let _ = fs::remove_file(entry.path());
907                    cleaned += 1;
908                    continue;
909                }
910                if name_str.ends_with(".lean-ctx.bak") {
911                    let original_name = name_str.trim_end_matches(".lean-ctx.bak");
912                    let original = entry.path().with_file_name(original_name);
913                    if original.exists() {
914                        // Only remove backups if the original is already clean.
915                        match fs::read_to_string(&original) {
916                            Ok(c) if !c.contains("lean-ctx") => {
917                                let _ = fs::remove_file(entry.path());
918                                cleaned += 1;
919                            }
920                            _ => {}
921                        }
922                    } else {
923                        // If the original is gone, the backup is no longer needed.
924                        let _ = fs::remove_file(entry.path());
925                        cleaned += 1;
926                    }
927                }
928            }
929        }
930    }
931
932    // Also clean shell RC backups
933    let rc_baks = [
934        home.join(".zshrc.lean-ctx.bak"),
935        home.join(".zshenv.lean-ctx.bak"),
936        home.join(".bashrc.lean-ctx.bak"),
937        home.join(".bashenv.lean-ctx.bak"),
938    ];
939    for bak in &rc_baks {
940        if bak.exists() {
941            let original_name = bak
942                .file_name()
943                .unwrap_or_default()
944                .to_string_lossy()
945                .trim_end_matches(".lean-ctx.bak")
946                .to_string();
947            let original = bak.with_file_name(original_name);
948            if original.exists() {
949                if let Ok(c) = fs::read_to_string(&original) {
950                    if !c.contains("lean-ctx") {
951                        let _ = fs::remove_file(bak);
952                        cleaned += 1;
953                    }
954                }
955            } else {
956                let _ = fs::remove_file(bak);
957                cleaned += 1;
958            }
959        }
960    }
961
962    if cleaned > 0 {
963        println!("  ✓ Cleaned up {cleaned} backup file(s)");
964    }
965}
966
967// ---------------------------------------------------------------------------
968// Binary removal instructions
969// ---------------------------------------------------------------------------
970
971fn print_binary_removal_instructions() {
972    let binary_path = std::env::current_exe()
973        .map_or_else(|_| "lean-ctx".to_string(), |p| p.display().to_string());
974
975    println!("  To complete uninstallation, remove the binary:\n");
976
977    if binary_path.contains(".cargo") {
978        println!("    cargo uninstall lean-ctx\n");
979    } else if binary_path.contains("homebrew") || binary_path.contains("Cellar") {
980        println!("    brew uninstall lean-ctx\n");
981    } else {
982        println!("    rm {binary_path}\n");
983    }
984
985    println!("  Then restart your shell.\n");
986}
987
988// ---------------------------------------------------------------------------
989// Shell block removal
990// ---------------------------------------------------------------------------
991
992fn remove_lean_ctx_block(content: &str) -> String {
993    if content.contains("# lean-ctx shell hook — end") {
994        return remove_lean_ctx_block_by_marker(content);
995    }
996    remove_lean_ctx_block_legacy(content)
997}
998
999fn remove_lean_ctx_block_by_marker(content: &str) -> String {
1000    let mut result = String::new();
1001    let mut in_block = false;
1002
1003    for line in content.lines() {
1004        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
1005            in_block = true;
1006            continue;
1007        }
1008        if in_block {
1009            if line.trim() == "# lean-ctx shell hook — end" {
1010                in_block = false;
1011            }
1012            continue;
1013        }
1014        result.push_str(line);
1015        result.push('\n');
1016    }
1017    result
1018}
1019
1020fn remove_lean_ctx_block_legacy(content: &str) -> String {
1021    let mut result = String::new();
1022    let mut in_block = false;
1023
1024    for line in content.lines() {
1025        if line.contains("lean-ctx shell hook") {
1026            in_block = true;
1027            continue;
1028        }
1029        if in_block {
1030            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
1031                if line.trim() == "fi" || line.trim() == "end" {
1032                    in_block = false;
1033                }
1034                continue;
1035            }
1036            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
1037                in_block = false;
1038                result.push_str(line);
1039                result.push('\n');
1040            }
1041            continue;
1042        }
1043        result.push_str(line);
1044        result.push('\n');
1045    }
1046    result
1047}
1048
1049// ---------------------------------------------------------------------------
1050// JSON removal — textual approach preserving comments and formatting
1051// ---------------------------------------------------------------------------
1052
1053fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
1054    // Try textual removal first (preserves comments, formatting, key order)
1055    if let Some(result) = remove_lean_ctx_from_json_textual(content) {
1056        return Some(result);
1057    }
1058
1059    // Fallback to serde-based approach for edge cases
1060    remove_lean_ctx_from_json_serde(content)
1061}
1062
1063/// Textual JSON key removal: finds `"lean-ctx"` key-value pairs and removes
1064/// them from the raw text without re-serializing. Preserves JSONC comments,
1065/// formatting, trailing commas, and key ordering.
1066fn remove_lean_ctx_from_json_textual(content: &str) -> Option<String> {
1067    let mut result = content.to_string();
1068    let mut modified = false;
1069
1070    // Repeatedly find and remove "lean-ctx" entries until none remain.
1071    // Each iteration rescans because positions shift after removal.
1072    while let Some(key_start) = find_json_key_position(result.as_bytes(), "lean-ctx") {
1073        let Some(new_result) = remove_json_entry_at(&result, key_start) else {
1074            break;
1075        };
1076
1077        result = new_result;
1078        modified = true;
1079    }
1080
1081    // Also handle array-style entries: {"name": "lean-ctx", ...}
1082    loop {
1083        let bytes = result.as_bytes();
1084        let Some(pos) = find_named_array_entry(bytes, "lean-ctx") else {
1085            break;
1086        };
1087        let Some(new_result) = remove_array_entry_at(&result, pos) else {
1088            break;
1089        };
1090        result = new_result;
1091        modified = true;
1092    }
1093
1094    if modified {
1095        // Validate the result is still valid JSON(C) if the input was valid
1096        if crate::core::jsonc::parse_jsonc(&result).is_ok() {
1097            Some(result)
1098        } else if crate::core::jsonc::parse_jsonc(content).is_ok() {
1099            // Input was valid but our textual removal broke it — don't use this result
1100            None
1101        } else {
1102            // Input was already invalid, return our best effort
1103            Some(result)
1104        }
1105    } else {
1106        None
1107    }
1108}
1109
1110/// Find the byte position of a JSON key `"key_name"` that is followed by `:`.
1111fn find_json_key_position(bytes: &[u8], key_name: &str) -> Option<usize> {
1112    let needle = format!("\"{key_name}\"");
1113    let needle_bytes = needle.as_bytes();
1114    let mut i = 0;
1115
1116    while i + needle_bytes.len() <= bytes.len() {
1117        if &bytes[i..i + needle_bytes.len()] == needle_bytes {
1118            // Check it's followed by `:` (after optional whitespace)
1119            let after = i + needle_bytes.len();
1120            let mut j = after;
1121            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
1122                j += 1;
1123            }
1124            if j < bytes.len() && bytes[j] == b':' {
1125                // Make sure we're not inside a string by checking if we have
1126                // an even number of unescaped quotes before this position
1127                if !is_inside_string(bytes, i) {
1128                    return Some(i);
1129                }
1130            }
1131        }
1132        i += 1;
1133    }
1134    None
1135}
1136
1137/// Check if position `pos` is inside a JSON string literal.
1138fn is_inside_string(bytes: &[u8], pos: usize) -> bool {
1139    let mut in_string = false;
1140    let mut i = 0;
1141    while i < pos {
1142        match bytes[i] {
1143            b'"' if !in_string => in_string = true,
1144            b'"' if in_string => in_string = false,
1145            b'\\' if in_string => {
1146                i += 1; // skip escaped char
1147            }
1148            b'/' if !in_string && i + 1 < bytes.len() => {
1149                if bytes[i + 1] == b'/' {
1150                    // Line comment — skip to end of line
1151                    while i < pos && i < bytes.len() && bytes[i] != b'\n' {
1152                        i += 1;
1153                    }
1154                } else if bytes[i + 1] == b'*' {
1155                    // Block comment — skip to */
1156                    i += 2;
1157                    while i + 1 < bytes.len() {
1158                        if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1159                            i += 2;
1160                            break;
1161                        }
1162                        i += 1;
1163                    }
1164                    continue;
1165                }
1166            }
1167            _ => {}
1168        }
1169        i += 1;
1170    }
1171    in_string
1172}
1173
1174/// Remove a JSON key-value entry starting at `key_start` position.
1175/// Handles surrounding commas and whitespace.
1176fn remove_json_entry_at(content: &str, key_start: usize) -> Option<String> {
1177    let bytes = content.as_bytes();
1178
1179    // Find the colon after the key
1180    let key_name_end = content[key_start + 1..].find('"')? + key_start + 2;
1181    let mut colon_pos = key_name_end;
1182    while colon_pos < bytes.len() && bytes[colon_pos] != b':' {
1183        colon_pos += 1;
1184    }
1185    if colon_pos >= bytes.len() {
1186        return None;
1187    }
1188
1189    // Skip the value
1190    let value_start = colon_pos + 1;
1191    let value_end = skip_json_value(bytes, value_start)?;
1192
1193    // Determine the range to remove, including surrounding comma and whitespace.
1194    // Scan backwards from key_start to find leading comma or whitespace.
1195    let mut remove_start = key_start;
1196
1197    // Look backwards for a comma (we might be after a comma)
1198    let mut scan_back = key_start;
1199    while scan_back > 0 {
1200        scan_back -= 1;
1201        let ch = bytes[scan_back];
1202        if ch == b',' {
1203            remove_start = scan_back;
1204            break;
1205        }
1206        if ch == b'{' || ch == b'[' {
1207            break;
1208        }
1209        if !ch.is_ascii_whitespace() {
1210            break;
1211        }
1212    }
1213
1214    // Extend remove_start back to include the newline before the comma/key
1215    if remove_start > 0 && remove_start == key_start {
1216        let mut ns = remove_start;
1217        while ns > 0 && bytes[ns - 1].is_ascii_whitespace() && bytes[ns - 1] != b'\n' {
1218            ns -= 1;
1219        }
1220        if ns > 0 && bytes[ns - 1] == b'\n' {
1221            remove_start = ns;
1222        }
1223    }
1224
1225    let mut remove_end = value_end;
1226
1227    // Look forward for a trailing comma
1228    let mut scan_fwd = value_end;
1229    while scan_fwd < bytes.len() && bytes[scan_fwd].is_ascii_whitespace() {
1230        scan_fwd += 1;
1231    }
1232    if scan_fwd < bytes.len() && bytes[scan_fwd] == b',' {
1233        // If we already consumed a leading comma, don't consume trailing too
1234        if remove_start < key_start && remove_start < bytes.len() && bytes[remove_start] == b',' {
1235            // Already have leading comma removed, skip trailing
1236        } else {
1237            remove_end = scan_fwd + 1;
1238        }
1239    }
1240
1241    // Skip trailing whitespace/newline after the removed entry
1242    while remove_end < bytes.len()
1243        && (bytes[remove_end] == b' ' || bytes[remove_end] == b'\t' || bytes[remove_end] == b'\r')
1244    {
1245        remove_end += 1;
1246    }
1247    if remove_end < bytes.len() && bytes[remove_end] == b'\n' {
1248        remove_end += 1;
1249    }
1250
1251    let mut result = String::with_capacity(content.len());
1252    result.push_str(&content[..remove_start]);
1253    result.push_str(&content[remove_end..]);
1254    Some(result)
1255}
1256
1257/// Find an array entry like `{"name": "lean-ctx", ...}` and return its start position.
1258fn find_named_array_entry(bytes: &[u8], name: &str) -> Option<usize> {
1259    let needle = format!("\"{name}\"");
1260    let needle_bytes = needle.as_bytes();
1261    let mut i = 0;
1262
1263    while i + needle_bytes.len() <= bytes.len() {
1264        if &bytes[i..i + needle_bytes.len()] == needle_bytes && !is_inside_string(bytes, i) {
1265            // Check this is a value (preceded by `:` after `"name"`)
1266            // Scan backwards to check if the key is "name"
1267            let mut j = i;
1268            while j > 0 && bytes[j - 1].is_ascii_whitespace() {
1269                j -= 1;
1270            }
1271            if j > 0 && bytes[j - 1] == b':' {
1272                j -= 1;
1273                while j > 0 && bytes[j - 1].is_ascii_whitespace() {
1274                    j -= 1;
1275                }
1276                if j >= 6 && &bytes[j - 6..j] == b"\"name\"" {
1277                    // Found "name": "lean-ctx" — now find the enclosing object `{`
1278                    let mut obj_start = j - 6;
1279                    while obj_start > 0 {
1280                        if bytes[obj_start] == b'{' && !is_inside_string(bytes, obj_start) {
1281                            return Some(obj_start);
1282                        }
1283                        obj_start -= 1;
1284                    }
1285                }
1286            }
1287        }
1288        i += 1;
1289    }
1290    None
1291}
1292
1293/// Remove an array entry (object) starting at `entry_start`, handling commas.
1294fn remove_array_entry_at(content: &str, entry_start: usize) -> Option<String> {
1295    let bytes = content.as_bytes();
1296    if bytes[entry_start] != b'{' {
1297        return None;
1298    }
1299    let entry_end = skip_json_value(bytes, entry_start)?;
1300
1301    let mut remove_start = entry_start;
1302    let mut remove_end = entry_end;
1303
1304    // Handle leading whitespace
1305    while remove_start > 0 && (bytes[remove_start - 1] == b' ' || bytes[remove_start - 1] == b'\t')
1306    {
1307        remove_start -= 1;
1308    }
1309
1310    // Handle trailing comma
1311    let mut fwd = entry_end;
1312    while fwd < bytes.len() && bytes[fwd].is_ascii_whitespace() {
1313        fwd += 1;
1314    }
1315    if fwd < bytes.len() && bytes[fwd] == b',' {
1316        remove_end = fwd + 1;
1317    } else {
1318        // No trailing comma — check for leading comma
1319        let mut back = remove_start;
1320        while back > 0 && bytes[back - 1].is_ascii_whitespace() {
1321            back -= 1;
1322        }
1323        if back > 0 && bytes[back - 1] == b',' {
1324            remove_start = back - 1;
1325        }
1326    }
1327
1328    // Skip trailing newline
1329    while remove_end < bytes.len()
1330        && (bytes[remove_end] == b' ' || bytes[remove_end] == b'\t' || bytes[remove_end] == b'\r')
1331    {
1332        remove_end += 1;
1333    }
1334    if remove_end < bytes.len() && bytes[remove_end] == b'\n' {
1335        remove_end += 1;
1336    }
1337
1338    let mut result = String::with_capacity(content.len());
1339    result.push_str(&content[..remove_start]);
1340    result.push_str(&content[remove_end..]);
1341    Some(result)
1342}
1343
1344/// Skip over a JSON value (object, array, string, number, boolean, null)
1345/// starting from `start`. Returns the position after the value.
1346fn skip_json_value(bytes: &[u8], start: usize) -> Option<usize> {
1347    let mut i = start;
1348
1349    // Skip whitespace
1350    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1351        i += 1;
1352    }
1353    if i >= bytes.len() {
1354        return None;
1355    }
1356
1357    match bytes[i] {
1358        b'{' | b'[' => {
1359            let open = bytes[i];
1360            let close = if open == b'{' { b'}' } else { b']' };
1361            let mut depth = 1;
1362            i += 1;
1363            while i < bytes.len() && depth > 0 {
1364                match bytes[i] {
1365                    c if c == open => depth += 1,
1366                    c if c == close => {
1367                        depth -= 1;
1368                        if depth == 0 {
1369                            return Some(i + 1);
1370                        }
1371                    }
1372                    b'"' => {
1373                        i += 1;
1374                        while i < bytes.len() {
1375                            if bytes[i] == b'\\' {
1376                                i += 1;
1377                            } else if bytes[i] == b'"' {
1378                                break;
1379                            }
1380                            i += 1;
1381                        }
1382                    }
1383                    b'/' if i + 1 < bytes.len() => {
1384                        if bytes[i + 1] == b'/' {
1385                            while i < bytes.len() && bytes[i] != b'\n' {
1386                                i += 1;
1387                            }
1388                            continue;
1389                        } else if bytes[i + 1] == b'*' {
1390                            i += 2;
1391                            while i + 1 < bytes.len() {
1392                                if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1393                                    i += 1;
1394                                    break;
1395                                }
1396                                i += 1;
1397                            }
1398                        }
1399                    }
1400                    _ => {}
1401                }
1402                i += 1;
1403            }
1404            Some(i)
1405        }
1406        b'"' => {
1407            i += 1;
1408            while i < bytes.len() {
1409                if bytes[i] == b'\\' {
1410                    i += 1;
1411                } else if bytes[i] == b'"' {
1412                    return Some(i + 1);
1413                }
1414                i += 1;
1415            }
1416            None
1417        }
1418        _ => {
1419            // Number, boolean, null
1420            while i < bytes.len() && !matches!(bytes[i], b',' | b'}' | b']' | b'\n' | b'\r') {
1421                i += 1;
1422            }
1423            Some(i)
1424        }
1425    }
1426}
1427
1428/// Fallback: serde-based JSON removal (destroys comments/formatting).
1429fn remove_lean_ctx_from_json_serde(content: &str) -> Option<String> {
1430    let mut parsed: serde_json::Value = crate::core::jsonc::parse_jsonc(content).ok()?;
1431    let mut modified = false;
1432
1433    if let Some(servers) = parsed.get_mut("mcpServers").and_then(|s| s.as_object_mut()) {
1434        modified |= servers.remove("lean-ctx").is_some();
1435    }
1436
1437    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_object_mut()) {
1438        modified |= servers.remove("lean-ctx").is_some();
1439    }
1440
1441    if let Some(servers) = parsed.get_mut("servers").and_then(|s| s.as_array_mut()) {
1442        let before = servers.len();
1443        servers.retain(|entry| entry.get("name").and_then(|n| n.as_str()) != Some("lean-ctx"));
1444        modified |= servers.len() < before;
1445    }
1446
1447    if let Some(mcp) = parsed.get_mut("mcp").and_then(|s| s.as_object_mut()) {
1448        modified |= mcp.remove("lean-ctx").is_some();
1449    }
1450
1451    if let Some(amp) = parsed
1452        .get_mut("amp.mcpServers")
1453        .and_then(|s| s.as_object_mut())
1454    {
1455        modified |= amp.remove("lean-ctx").is_some();
1456    }
1457
1458    if modified {
1459        Some(serde_json::to_string_pretty(&parsed).ok()? + "\n")
1460    } else {
1461        None
1462    }
1463}
1464
1465// ---------------------------------------------------------------------------
1466// YAML removal
1467// ---------------------------------------------------------------------------
1468
1469fn remove_lean_ctx_from_yaml(content: &str) -> String {
1470    let mut out = String::with_capacity(content.len());
1471    let mut skip_depth: Option<usize> = None;
1472
1473    for line in content.lines() {
1474        if let Some(depth) = skip_depth {
1475            let indent = line.len() - line.trim_start().len();
1476            if indent > depth || line.trim().is_empty() {
1477                continue;
1478            }
1479            skip_depth = None;
1480        }
1481
1482        let trimmed = line.trim();
1483        if trimmed == "lean-ctx:" || trimmed.starts_with("lean-ctx:") {
1484            let indent = line.len() - line.trim_start().len();
1485            skip_depth = Some(indent);
1486            continue;
1487        }
1488
1489        out.push_str(line);
1490        out.push('\n');
1491    }
1492
1493    out
1494}
1495
1496// ---------------------------------------------------------------------------
1497// TOML removal
1498// ---------------------------------------------------------------------------
1499
1500fn remove_lean_ctx_from_toml(content: &str) -> String {
1501    let mut out = String::with_capacity(content.len());
1502    let mut skip = false;
1503
1504    for line in content.lines() {
1505        let trimmed = line.trim();
1506
1507        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1508            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
1509            if section == "mcp_servers.lean-ctx"
1510                || section == "mcp_servers.\"lean-ctx\""
1511                || section.starts_with("mcp_servers.lean-ctx.")
1512                || section.starts_with("mcp_servers.\"lean-ctx\".")
1513            {
1514                skip = true;
1515                continue;
1516            }
1517            skip = false;
1518        }
1519
1520        if skip {
1521            continue;
1522        }
1523
1524        if trimmed.contains("codex_hooks") && trimmed.contains("true") {
1525            out.push_str(&line.replace("true", "false"));
1526            out.push('\n');
1527            continue;
1528        }
1529
1530        out.push_str(line);
1531        out.push('\n');
1532    }
1533
1534    let cleaned: String = out
1535        .lines()
1536        .filter(|l| l.trim() != "[]")
1537        .collect::<Vec<_>>()
1538        .join("\n");
1539    if cleaned.is_empty() {
1540        cleaned
1541    } else {
1542        cleaned + "\n"
1543    }
1544}
1545
1546// moved to core/editor_registry/paths.rs
1547
1548#[cfg(test)]
1549mod tests {
1550    use super::*;
1551
1552    // --- TOML tests ---
1553
1554    #[test]
1555    fn remove_toml_mcp_server_section() {
1556        let input = "\
1557[features]
1558codex_hooks = true
1559
1560[mcp_servers.lean-ctx]
1561command = \"/usr/local/bin/lean-ctx\"
1562args = []
1563
1564[mcp_servers.other-tool]
1565command = \"/usr/bin/other\"
1566";
1567        let result = remove_lean_ctx_from_toml(input);
1568        assert!(
1569            !result.contains("lean-ctx"),
1570            "lean-ctx section should be removed"
1571        );
1572        assert!(
1573            result.contains("[mcp_servers.other-tool]"),
1574            "other sections should be preserved"
1575        );
1576        assert!(
1577            result.contains("codex_hooks = false"),
1578            "codex_hooks should be set to false"
1579        );
1580    }
1581
1582    #[test]
1583    fn remove_toml_only_lean_ctx() {
1584        let input = "\
1585[mcp_servers.lean-ctx]
1586command = \"lean-ctx\"
1587";
1588        let result = remove_lean_ctx_from_toml(input);
1589        assert!(
1590            result.trim().is_empty(),
1591            "should produce empty output: {result}"
1592        );
1593    }
1594
1595    #[test]
1596    fn remove_toml_no_lean_ctx() {
1597        let input = "\
1598[mcp_servers.other]
1599command = \"other\"
1600";
1601        let result = remove_lean_ctx_from_toml(input);
1602        assert!(
1603            result.contains("[mcp_servers.other]"),
1604            "other content should be preserved"
1605        );
1606    }
1607
1608    // --- JSON textual removal tests ---
1609
1610    #[test]
1611    fn json_textual_removes_key_from_object() {
1612        let input = r#"{
1613  "mcpServers": {
1614    "other-tool": {
1615      "command": "other"
1616    },
1617    "lean-ctx": {
1618      "command": "/usr/bin/lean-ctx",
1619      "args": []
1620    }
1621  }
1622}
1623"#;
1624        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1625        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1626        assert!(
1627            result.contains("other-tool"),
1628            "other-tool should be preserved"
1629        );
1630        // Verify valid JSON
1631        assert!(
1632            crate::core::jsonc::parse_jsonc(&result).is_ok(),
1633            "result should be valid JSON: {result}"
1634        );
1635    }
1636
1637    #[test]
1638    fn json_textual_preserves_comments() {
1639        let input = r#"{
1640  // This is a user comment
1641  "mcpServers": {
1642    "lean-ctx": {
1643      "command": "lean-ctx"
1644    },
1645    "my-tool": {
1646      "command": "my-tool"
1647    }
1648  }
1649}
1650"#;
1651        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1652        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1653        assert!(
1654            result.contains("// This is a user comment"),
1655            "comment should be preserved: {result}"
1656        );
1657        assert!(result.contains("my-tool"), "my-tool should be preserved");
1658    }
1659
1660    #[test]
1661    fn json_textual_only_lean_ctx() {
1662        let input = r#"{
1663  "mcpServers": {
1664    "lean-ctx": {
1665      "command": "lean-ctx"
1666    }
1667  }
1668}
1669"#;
1670        let result = remove_lean_ctx_from_json(input).expect("should find lean-ctx");
1671        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1672    }
1673
1674    #[test]
1675    fn json_no_lean_ctx_returns_none() {
1676        let input = r#"{"mcpServers": {"other": {"command": "other"}}}"#;
1677        assert!(remove_lean_ctx_from_json(input).is_none());
1678    }
1679
1680    // --- Shared rules (SharedMarkdown) tests ---
1681
1682    #[test]
1683    fn shared_markdown_surgical_removal() {
1684        let input = "# My custom rules\n\nDo this and that.\n\n\
1685                      # lean-ctx — Context Engineering Layer\n\
1686                      <!-- lean-ctx-rules-v9 -->\n\n\
1687                      Use ctx_read instead of Read.\n\
1688                      <!-- /lean-ctx -->\n\n\
1689                      # Other section\n\nMore user content.\n";
1690
1691        let cleaned = remove_marked_block(
1692            input,
1693            "# lean-ctx — Context Engineering Layer",
1694            "<!-- /lean-ctx -->",
1695        );
1696
1697        assert!(
1698            !cleaned.contains("lean-ctx"),
1699            "lean-ctx block should be removed"
1700        );
1701        assert!(
1702            cleaned.contains("My custom rules"),
1703            "user content before should be preserved"
1704        );
1705        assert!(
1706            cleaned.contains("Other section"),
1707            "user content after should be preserved"
1708        );
1709        assert!(
1710            cleaned.contains("More user content"),
1711            "user content after should be preserved"
1712        );
1713    }
1714
1715    #[test]
1716    fn shared_markdown_only_lean_ctx() {
1717        let input = "# lean-ctx — Context Engineering Layer\n\
1718                      <!-- lean-ctx-rules-v9 -->\n\
1719                      content\n\
1720                      <!-- /lean-ctx -->\n";
1721
1722        let cleaned = remove_marked_block(
1723            input,
1724            "# lean-ctx — Context Engineering Layer",
1725            "<!-- /lean-ctx -->",
1726        );
1727
1728        assert!(
1729            cleaned.trim().is_empty() || !cleaned.contains("lean-ctx"),
1730            "should be empty or without lean-ctx: '{cleaned}'"
1731        );
1732    }
1733
1734    // --- Project files (.cursorrules) tests ---
1735
1736    #[test]
1737    fn cursorrules_surgical_removal() {
1738        let input = "# My project rules\n\n\
1739                      Always use TypeScript.\n\n\
1740                      # lean-ctx — Context Engineering Layer\n\n\
1741                      PREFER lean-ctx MCP tools over native equivalents.\n";
1742
1743        let cleaned = remove_lean_ctx_section_from_rules(input);
1744
1745        assert!(
1746            !cleaned.contains("lean-ctx"),
1747            "lean-ctx section should be removed"
1748        );
1749        assert!(
1750            cleaned.contains("My project rules"),
1751            "user rules should be preserved"
1752        );
1753        assert!(
1754            cleaned.contains("Always use TypeScript"),
1755            "user content should be preserved"
1756        );
1757    }
1758
1759    #[test]
1760    fn cursorrules_only_lean_ctx() {
1761        let input = "# lean-ctx — Context Engineering Layer\n\n\
1762                      PREFER lean-ctx MCP tools.\n";
1763
1764        let cleaned = remove_lean_ctx_section_from_rules(input);
1765        assert!(
1766            cleaned.trim().is_empty(),
1767            "should be empty when only lean-ctx content: '{cleaned}'"
1768        );
1769    }
1770
1771    // --- hooks.json tests ---
1772
1773    #[test]
1774    fn hooks_json_preserves_other_hooks() {
1775        let input = r#"{
1776  "version": 1,
1777  "hooks": {
1778    "preToolUse": [
1779      {
1780        "matcher": "Shell",
1781        "command": "lean-ctx hook rewrite"
1782      },
1783      {
1784        "matcher": "Shell",
1785        "command": "my-other-tool hook"
1786      }
1787    ]
1788  }
1789}"#;
1790        let result = remove_lean_ctx_from_hooks_json(input).expect("should return cleaned JSON");
1791        assert!(!result.contains("lean-ctx"), "lean-ctx should be removed");
1792        assert!(
1793            result.contains("my-other-tool"),
1794            "other hooks should be preserved"
1795        );
1796    }
1797
1798    #[test]
1799    fn hooks_json_returns_none_when_only_lean_ctx() {
1800        let input = r#"{
1801  "version": 1,
1802  "hooks": {
1803    "preToolUse": [
1804      {
1805        "matcher": "Shell",
1806        "command": "lean-ctx hook rewrite"
1807      },
1808      {
1809        "matcher": "Read|Grep",
1810        "command": "lean-ctx hook redirect"
1811      }
1812    ]
1813  }
1814}"#;
1815        assert!(
1816            remove_lean_ctx_from_hooks_json(input).is_none(),
1817            "should return None when all hooks are lean-ctx"
1818        );
1819    }
1820
1821    // --- Marked block tests ---
1822
1823    #[test]
1824    fn marked_block_preserves_surrounding() {
1825        let content = "before\n<!-- lean-ctx -->\nhook content\n<!-- /lean-ctx -->\nafter\n";
1826        let cleaned = remove_marked_block(content, "<!-- lean-ctx -->", "<!-- /lean-ctx -->");
1827        assert!(!cleaned.contains("hook content"));
1828        assert!(cleaned.contains("before"));
1829        assert!(cleaned.contains("after"));
1830    }
1831
1832    #[test]
1833    fn marked_block_preserves_when_missing() {
1834        let content = "no hook here\n";
1835        let cleaned = remove_marked_block(content, "<!-- lean-ctx -->", "<!-- /lean-ctx -->");
1836        assert_eq!(cleaned, content);
1837    }
1838
1839    #[test]
1840    fn backup_before_modify_respects_dry_run() {
1841        let dir = tempfile::tempdir().unwrap();
1842        let path = dir.path().join("file.txt");
1843        std::fs::write(&path, "hello").unwrap();
1844
1845        backup_before_modify(&path, true);
1846        assert!(
1847            !bak_path_for(&path).exists(),
1848            "dry-run must not create backups"
1849        );
1850
1851        backup_before_modify(&path, false);
1852        assert!(
1853            bak_path_for(&path).exists(),
1854            "non-dry-run should create backups"
1855        );
1856    }
1857}