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| !line.contains(".lean-ctx/shell-hook."))
408 .collect::<Vec<_>>()
409 .join("\n")
410 + "\n"
411}
412
413fn 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
523fn remove_rules_files(home: &Path, dry_run: bool) -> bool {
528 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 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 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 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 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
708fn 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 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
787fn 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
831fn 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
859fn 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 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 let _ = fs::remove_file(entry.path());
923 cleaned += 1;
924 }
925 }
926 }
927 }
928 }
929
930 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
965fn 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
986fn 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
1047fn remove_lean_ctx_from_json(content: &str) -> Option<String> {
1052 if let Some(result) = remove_lean_ctx_from_json_textual(content) {
1054 return Some(result);
1055 }
1056
1057 remove_lean_ctx_from_json_serde(content)
1059}
1060
1061fn remove_lean_ctx_from_json_textual(content: &str) -> Option<String> {
1065 let mut result = content.to_string();
1066 let mut modified = false;
1067
1068 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 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 if crate::core::jsonc::parse_jsonc(&result).is_ok() {
1095 Some(result)
1096 } else if crate::core::jsonc::parse_jsonc(content).is_ok() {
1097 None
1099 } else {
1100 Some(result)
1102 }
1103 } else {
1104 None
1105 }
1106}
1107
1108fn 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 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 if !is_inside_string(bytes, i) {
1126 return Some(i);
1127 }
1128 }
1129 }
1130 i += 1;
1131 }
1132 None
1133}
1134
1135fn 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; }
1146 b'/' if !in_string && i + 1 < bytes.len() => {
1147 if bytes[i + 1] == b'/' {
1148 while i < pos && i < bytes.len() && bytes[i] != b'\n' {
1150 i += 1;
1151 }
1152 } else if bytes[i + 1] == b'*' {
1153 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
1172fn remove_json_entry_at(content: &str, key_start: usize) -> Option<String> {
1175 let bytes = content.as_bytes();
1176
1177 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 let value_start = colon_pos + 1;
1189 let value_end = skip_json_value(bytes, value_start)?;
1190
1191 let mut remove_start = key_start;
1194
1195 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 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 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 remove_start < key_start && remove_start < bytes.len() && bytes[remove_start] == b',' {
1233 } else {
1235 remove_end = scan_fwd + 1;
1236 }
1237 }
1238
1239 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
1255fn 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 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 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
1291fn 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 while remove_start > 0 && (bytes[remove_start - 1] == b' ' || bytes[remove_start - 1] == b'\t')
1304 {
1305 remove_start -= 1;
1306 }
1307
1308 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 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 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
1342fn skip_json_value(bytes: &[u8], start: usize) -> Option<usize> {
1345 let mut i = start;
1346
1347 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 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
1426fn 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
1463fn 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
1494fn 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#[cfg(test)]
1547mod tests {
1548 use super::*;
1549
1550 #[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 #[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 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 #[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 #[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 #[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 #[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}