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