1use std::fs;
2use std::path::{Path, PathBuf};
3
4fn 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
56fn 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 cleanup_bak(path);
64 Ok(())
65}
66
67fn 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 cleanup_bak(path);
75 Ok(())
76}
77
78pub 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
132fn 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 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 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 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 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 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
258fn remove_lean_ctx_section_from_rules(content: &str) -> String {
263 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 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 while out.ends_with("\n\n") {
294 out.pop();
295 }
296 out
297}
298
299fn 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
324fn 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
415fn 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
525fn remove_rules_files(home: &Path, dry_run: bool) -> bool {
530 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 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 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 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 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
710fn 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 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
789fn 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
833fn 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
861fn 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 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 let _ = fs::remove_file(entry.path());
925 cleaned += 1;
926 }
927 }
928 }
929 }
930 }
931
932 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
967fn 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
988fn 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
1049fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
1054 if let Some(result) = remove_lean_ctx_from_json_textual(content) {
1056 return Some(result);
1057 }
1058
1059 remove_lean_ctx_from_json_serde(content)
1061}
1062
1063fn remove_lean_ctx_from_json_textual(content: &str) -> Option<String> {
1067 let mut result = content.to_string();
1068 let mut modified = false;
1069
1070 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 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 if crate::core::jsonc::parse_jsonc(&result).is_ok() {
1097 Some(result)
1098 } else if crate::core::jsonc::parse_jsonc(content).is_ok() {
1099 None
1101 } else {
1102 Some(result)
1104 }
1105 } else {
1106 None
1107 }
1108}
1109
1110fn 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 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 if !is_inside_string(bytes, i) {
1128 return Some(i);
1129 }
1130 }
1131 }
1132 i += 1;
1133 }
1134 None
1135}
1136
1137fn 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; }
1148 b'/' if !in_string && i + 1 < bytes.len() => {
1149 if bytes[i + 1] == b'/' {
1150 while i < pos && i < bytes.len() && bytes[i] != b'\n' {
1152 i += 1;
1153 }
1154 } else if bytes[i + 1] == b'*' {
1155 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
1174fn remove_json_entry_at(content: &str, key_start: usize) -> Option<String> {
1177 let bytes = content.as_bytes();
1178
1179 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 let value_start = colon_pos + 1;
1191 let value_end = skip_json_value(bytes, value_start)?;
1192
1193 let mut remove_start = key_start;
1196
1197 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 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 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 remove_start < key_start && remove_start < bytes.len() && bytes[remove_start] == b',' {
1235 } else {
1237 remove_end = scan_fwd + 1;
1238 }
1239 }
1240
1241 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
1257fn 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 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 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
1293fn 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 while remove_start > 0 && (bytes[remove_start - 1] == b' ' || bytes[remove_start - 1] == b'\t')
1306 {
1307 remove_start -= 1;
1308 }
1309
1310 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 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 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
1344fn skip_json_value(bytes: &[u8], start: usize) -> Option<usize> {
1347 let mut i = start;
1348
1349 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 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
1428fn 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
1465fn 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
1496fn 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#[cfg(test)]
1549mod tests {
1550 use super::*;
1551
1552 #[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 #[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 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 #[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 #[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 #[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 #[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}