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