1use std::path::{Path, PathBuf};
26
27use crate::core::rules_canonical::{END_MARK as BLOCK_END, START_MARK as BLOCK_START};
28use crate::core::rules_channel::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
29
30#[derive(Debug, PartialEq, Eq)]
32pub enum Action {
33 DeleteFile { path: PathBuf, reason: String },
35 StripBlocks { path: PathBuf, reason: String },
37 StripCompression { path: PathBuf, reason: String },
41 Report { path: PathBuf, note: String },
43}
44
45fn is_owned_rules_file(content: &str) -> bool {
49 let starts_with_header = content
50 .trim_start()
51 .starts_with(crate::core::rules_canonical::START_MARK)
52 || (content.trim_start().starts_with("---")
54 && content.contains(crate::core::rules_canonical::START_MARK));
55 if starts_with_header && content.contains("<!-- version:") {
56 return true;
57 }
58 is_owned_legacy_rules_file(content)
59}
60
61fn is_owned_legacy_rules_file(content: &str) -> bool {
70 let mut lines = content.trim_start().lines();
71 let first = lines.next().unwrap_or_default().trim();
75 let marker_line = if first.starts_with("# lean-ctx") {
76 lines.next().unwrap_or_default().trim()
77 } else {
78 first
79 };
80 if !marker_line.starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX) {
81 return false;
82 }
83 let Some(idx) = content.rfind("<!-- /lean-ctx") else {
84 return false;
85 };
86 let Some(end) = content[idx..].find("-->") else {
87 return false;
88 };
89 content[idx + end + 3..].trim().is_empty()
90}
91
92fn strip_legacy_owned_prefix(content: &str) -> Option<String> {
98 let trimmed = content.trim_start();
99 let mut lines = trimmed.lines();
100 let first = lines.next()?.trim();
101 let marker_line = if first.starts_with("# lean-ctx") {
102 lines.next().unwrap_or_default().trim()
103 } else {
104 first
105 };
106 if !marker_line.starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX)
107 || marker_line.starts_with(BLOCK_START)
108 {
109 return None;
110 }
111 let mut offset = 0usize;
112 for line in trimmed.split_inclusive('\n') {
113 offset += line.len();
114 let t = line.trim();
115 if t == crate::core::rules_canonical::AGENTS_BLOCK_END
116 || t.starts_with("<!-- /lean-ctx-rules")
117 {
118 return Some(trimmed[offset..].to_string());
119 }
120 }
121 None
122}
123
124fn has_marker_pair(content: &str, start: &str, end: &str) -> bool {
127 crate::marked_block::contains_marker_line(content, start)
128 && crate::marked_block::contains_marker_line(content, end)
129}
130
131fn has_marked_block(content: &str) -> bool {
132 has_marker_pair(content, BLOCK_START, BLOCK_END)
133 || has_marker_pair(content, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END)
134}
135
136pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
138 let mut out = strip_legacy_owned_prefix(content).unwrap_or_else(|| content.to_string());
139 loop {
142 let next = if has_marker_pair(&out, BLOCK_START, BLOCK_END) {
143 crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
144 } else if has_marker_pair(&out, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END) {
145 crate::marked_block::remove_content(
146 &out,
147 COMPRESSION_BLOCK_START,
148 COMPRESSION_BLOCK_END,
149 )
150 } else {
151 break;
152 };
153 if next == out {
154 break;
155 }
156 out = next;
157 }
158 let trimmed = out.trim();
159 if trimmed.is_empty() {
160 String::new()
161 } else {
162 format!("{}\n", out.trim_end().trim_start_matches('\n'))
163 }
164}
165
166fn is_whole_lean_ctx_file(content: &str) -> bool {
171 if is_owned_rules_file(content) {
172 return true;
173 }
174 content
175 .trim_start()
176 .starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX)
177 && !content
178 .lines()
179 .any(|l| l.trim_start().starts_with("<!-- /lean-ctx"))
180}
181
182fn has_strippable_region(content: &str) -> bool {
185 has_marked_block(content) || strip_legacy_owned_prefix(content).is_some()
186}
187
188pub fn retire_rules_file(path: &Path) -> Option<String> {
194 let content = std::fs::read_to_string(path).ok()?;
195 let action = if is_whole_lean_ctx_file(&content) {
196 Action::DeleteFile {
197 path: path.to_path_buf(),
198 reason: String::new(),
199 }
200 } else if has_strippable_region(&content) {
201 Action::StripBlocks {
202 path: path.to_path_buf(),
203 reason: String::new(),
204 }
205 } else {
206 return None;
207 };
208 let line = apply(&action);
209 (!line.starts_with("unchanged")).then_some(line)
210}
211
212pub(crate) fn strip_compression_block(content: &str) -> String {
216 if !(content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END)) {
217 return content.to_string();
218 }
219 let stripped = crate::marked_block::remove_content(
220 content,
221 COMPRESSION_BLOCK_START,
222 COMPRESSION_BLOCK_END,
223 );
224 let trimmed = stripped.trim_end();
225 if trimmed.is_empty() {
226 String::new()
227 } else {
228 format!("{trimmed}\n")
229 }
230}
231
232fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
235 vec![
236 dir.join(".cursor/rules/lean-ctx.mdc"),
237 dir.join(".claude/rules/lean-ctx.md"),
238 dir.join(".codebuddy/rules/lean-ctx.md"),
239 dir.join(".windsurf/rules/lean-ctx.md"),
240 dir.join(".cline/rules/lean-ctx.md"),
241 dir.join(".roo/rules/lean-ctx.md"),
242 ]
243}
244
245fn plan_duplicate_file(actions: &mut Vec<Action>, candidate: PathBuf, reason: &str) {
248 let Ok(content) = std::fs::read_to_string(&candidate) else {
249 return;
250 };
251 if is_whole_lean_ctx_file(&content) {
252 actions.push(Action::DeleteFile {
253 path: candidate,
254 reason: reason.into(),
255 });
256 } else if has_strippable_region(&content) {
257 actions.push(Action::StripBlocks {
258 path: candidate,
259 reason: format!("{reason}; text outside the lean-ctx markers is kept"),
260 });
261 } else if !content.trim().is_empty() {
262 actions.push(Action::Report {
263 path: candidate,
264 note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
265 });
266 }
267}
268
269fn global_duplicate_candidates(home: &Path) -> Vec<PathBuf> {
273 vec![
274 crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
275 home.join(".codebuddy/rules/lean-ctx.md"),
276 ]
277}
278
279pub fn plan(home: &Path, project: &Path) -> Vec<Action> {
281 let mut actions = Vec::new();
282 let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
283
284 for candidate in global_duplicate_candidates(home) {
286 plan_duplicate_file(
287 &mut actions,
288 candidate,
289 "always-loaded duplicate of the host's CLAUDE.md/CODEBUDDY.md rules block",
290 );
291 }
292
293 let mut dir = Some(project.to_path_buf());
295 while let Some(d) = dir {
296 if d == *home {
297 break;
298 }
299 for candidate in project_owned_candidates(&d) {
300 if candidate == canonical_cursor_mdc {
301 continue;
302 }
303 plan_duplicate_file(
304 &mut actions,
305 candidate,
306 "lean-ctx-owned duplicate of the global rules file",
307 );
308 }
309 if !d.starts_with(home) {
314 break;
315 }
316 dir = d.parent().map(Path::to_path_buf);
317 }
318
319 let cursorrules = project.join(".cursorrules");
322 if let Ok(content) = std::fs::read_to_string(&cursorrules) {
323 if canonical_cursor_mdc.exists() && has_marked_block(&content) {
324 actions.push(Action::StripBlocks {
325 path: cursorrules,
326 reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
327 });
328 } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
329 actions.push(Action::Report {
330 path: cursorrules,
331 note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
332 });
333 } else if content.contains("lean-ctx") && !has_marked_block(&content) {
334 actions.push(Action::Report {
335 path: cursorrules,
336 note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
337 });
338 }
339 }
340
341 let agents_md = project.join("AGENTS.md");
346 if let Ok(content) = std::fs::read_to_string(&agents_md) {
347 let has_compression =
348 content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END);
349 if has_compression {
350 if crate::core::rules_channel::agents_md_can_thin(home) {
351 actions.push(Action::StripCompression {
352 path: agents_md,
353 reason: "every AGENTS.md reader already loads the compression block from its own canonical file".into(),
354 });
355 } else {
356 actions.push(Action::Report {
357 path: agents_md,
358 note: "an AGENTS.md reader has no other compression source — AGENTS.md stays the carrier".into(),
359 });
360 }
361 }
362 }
363
364 actions
365}
366
367pub fn apply(action: &Action) -> String {
369 match action {
370 Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
371 Ok(()) => format!("deleted {}", path.display()),
372 Err(e) => format!("FAILED {} ({e})", path.display()),
373 },
374 Action::StripBlocks { path, .. } => {
375 let Ok(content) = std::fs::read_to_string(path) else {
376 return format!("FAILED {} (unreadable)", path.display());
377 };
378 let stripped = strip_lean_ctx_blocks(&content);
379 if stripped == content {
380 return format!("unchanged {}", path.display());
381 }
382 let bak = path.with_extension("bak");
383 if let Err(e) = std::fs::write(&bak, &content) {
384 return format!("FAILED {} (backup: {e})", path.display());
385 }
386 if stripped.is_empty() {
387 match std::fs::remove_file(path) {
388 Ok(()) => format!(
389 "deleted {} (only lean-ctx blocks, backup: {})",
390 path.display(),
391 bak.display()
392 ),
393 Err(e) => format!("FAILED {} ({e})", path.display()),
394 }
395 } else {
396 match std::fs::write(path, &stripped) {
397 Ok(()) => format!("stripped {} (backup: {})", path.display(), bak.display()),
398 Err(e) => format!("FAILED {} ({e})", path.display()),
399 }
400 }
401 }
402 Action::StripCompression { path, .. } => {
403 let Ok(content) = std::fs::read_to_string(path) else {
404 return format!("FAILED {} (unreadable)", path.display());
405 };
406 let stripped = strip_compression_block(&content);
407 if stripped == content {
408 return format!("unchanged {}", path.display());
409 }
410 let bak = path.with_extension("bak");
411 if let Err(e) = std::fs::write(&bak, &content) {
412 return format!("FAILED {} (backup: {e})", path.display());
413 }
414 match std::fs::write(path, &stripped) {
415 Ok(()) => format!("thinned {} (backup: {})", path.display(), bak.display()),
416 Err(e) => format!("FAILED {} ({e})", path.display()),
417 }
418 }
419 Action::Report { path, note } => format!("info {} — {note}", path.display()),
420 }
421}
422
423pub fn auto_apply(home: &Path, project: &Path) -> Vec<String> {
428 plan(home, project)
429 .iter()
430 .filter(|a| !matches!(a, Action::Report { .. }))
431 .map(apply)
432 .collect()
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 fn owned_mdc() -> String {
440 format!(
441 "---\ndescription: lean-ctx\n---\n{}\n<!-- version: 1 -->\nbody\n",
442 crate::core::rules_canonical::START_MARK
443 )
444 }
445
446 #[test]
447 fn detects_owned_dedicated_files() {
448 assert!(is_owned_rules_file(&owned_mdc()));
449 assert!(is_owned_rules_file(&format!(
450 "{}\n<!-- version: 1 -->\nbody\n",
451 crate::core::rules_canonical::START_MARK
452 )));
453 assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
455 assert!(!is_owned_rules_file(&format!(
457 "# my header\n{}\n<!-- version: 1 -->\n",
458 crate::core::rules_canonical::START_MARK
459 )));
460 }
461
462 #[test]
463 fn strip_removes_rules_and_compression_blocks() {
464 let content = format!(
465 "user line\n{BLOCK_START}\nour rules\n{BLOCK_END}\nmore user\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
466 );
467 let out = strip_lean_ctx_blocks(&content);
468 assert!(out.contains("user line"));
469 assert!(out.contains("more user"));
470 assert!(!out.contains("our rules"));
471 assert!(!out.contains("style"));
472 assert!(!out.contains("lean-ctx-compression"));
473 }
474
475 #[test]
476 fn strip_of_pure_block_file_yields_empty() {
477 let content = format!("{BLOCK_START}\nonly ours\n{BLOCK_END}\n");
478 assert_eq!(strip_lean_ctx_blocks(&content), "");
479 }
480
481 #[test]
484 fn legacy_marker_files_owned_only_when_wholly_lean_ctx() {
485 let pristine = "# lean-ctx — Context Engineering Layer\n\
486 <!-- lean-ctx-rules-v9 -->\n\n\
487 PREFER lean-ctx MCP tools.\n\
488 Write, Delete, Glob → use normally.\n\
489 <!-- /lean-ctx -->\n";
490 assert!(is_owned_rules_file(pristine));
491
492 let user_extended = format!("{pristine}\n## My own precedence rules\nkeep me\n");
493 assert!(!is_owned_rules_file(&user_extended));
494
495 assert!(!is_owned_rules_file(
497 "my notes\nabout <!-- lean-ctx-rules-v9 --> markers\n<!-- /lean-ctx -->\n"
498 ));
499 }
500
501 #[test]
502 fn plan_deletes_project_and_parent_owned_files_only() {
503 let tmp = tempfile::tempdir().unwrap();
504 let home = tmp.path();
505 let parent = home.join("projects");
506 let project = parent.join("app");
507
508 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
510 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
511 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
513 std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
514 std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
515 std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
516 std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
518 std::fs::write(
519 project.join(".claude/rules/lean-ctx.md"),
520 "# customized by user\nkeep me\n",
521 )
522 .unwrap();
523
524 let actions = plan(home, &project);
525 let deletes: Vec<&PathBuf> = actions
526 .iter()
527 .filter_map(|a| match a {
528 Action::DeleteFile { path, .. } => Some(path),
529 _ => None,
530 })
531 .collect();
532 assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
533 assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
534 assert!(actions.iter().any(|a| matches!(
535 a,
536 Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
537 )));
538 }
539
540 #[test]
544 fn plan_parent_walk_stays_inside_home() {
545 let tmp = tempfile::tempdir().unwrap();
546 let home = tmp.path().join("home");
547 let outside = tmp.path().join("elsewhere");
548 let project = outside.join("app");
549 std::fs::create_dir_all(&home).unwrap();
550 std::fs::create_dir_all(outside.join(".cursor/rules")).unwrap();
551 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
552 std::fs::write(outside.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
553 std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
554
555 let paths: Vec<PathBuf> = plan(&home, &project)
556 .into_iter()
557 .filter_map(|a| match a {
558 Action::DeleteFile { path, .. } | Action::StripBlocks { path, .. } => Some(path),
559 _ => None,
560 })
561 .collect();
562 assert_eq!(paths, vec![project.join(".cursor/rules/lean-ctx.mdc")]);
563 }
564
565 #[test]
566 fn plan_strips_cursorrules_only_with_canonical_mdc() {
567 let tmp = tempfile::tempdir().unwrap();
568 let home = tmp.path();
569 let project = home.join("app");
570 std::fs::create_dir_all(&project).unwrap();
571 let rules = format!("{BLOCK_START}\npointer\n{BLOCK_END}\n");
572 std::fs::write(project.join(".cursorrules"), rules).unwrap();
573
574 let actions = plan(home, &project);
576 assert!(
577 !actions
578 .iter()
579 .any(|a| matches!(a, Action::StripBlocks { .. })),
580 "{actions:?}"
581 );
582
583 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
585 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
586 let actions = plan(home, &project);
587 assert!(
588 actions.iter().any(|a| matches!(
589 a,
590 Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
591 )),
592 "{actions:?}"
593 );
594 }
595
596 #[test]
597 fn apply_strip_writes_backup_and_keeps_user_content() {
598 let tmp = tempfile::tempdir().unwrap();
599 let path = tmp.path().join(".cursorrules");
600 std::fs::write(
601 &path,
602 format!("my custom rule\n{BLOCK_START}\nours\n{BLOCK_END}\n"),
603 )
604 .unwrap();
605
606 let msg = apply(&Action::StripBlocks {
607 path: path.clone(),
608 reason: String::new(),
609 });
610 assert!(msg.starts_with("stripped"), "{msg}");
611 let after = std::fs::read_to_string(&path).unwrap();
612 assert_eq!(after, "my custom rule\n");
613 let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
614 assert!(bak.contains("ours"));
615 }
616
617 #[test]
618 fn strip_compression_keeps_pointer_and_user_content() {
619 let content = format!(
620 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n",
621 );
622 let out = strip_compression_block(&content);
623 assert!(out.contains("# Agent Instructions"));
624 assert!(out.contains(BLOCK_START));
625 assert!(out.contains("pointer"));
626 assert!(!out.contains("lean-ctx-compression"));
627 assert!(!out.contains("OUTPUT STYLE"));
628 }
629
630 #[test]
631 fn plan_thins_agents_md_when_cursor_covered_and_no_codex() {
632 let _guard = crate::core::data_dir::test_env_lock();
633 let tmp = tempfile::tempdir().unwrap();
634 let home = tmp.path();
635 let project = home.join("app");
636 std::fs::create_dir_all(&project).unwrap();
637 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
639
640 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
642 std::fs::write(
643 home.join(".cursor/rules/lean-ctx.mdc"),
644 format!(
645 "{}\n<!-- version: 1 -->\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
646 crate::core::rules_canonical::START_MARK
647 ),
648 )
649 .unwrap();
650
651 std::fs::write(
653 project.join("AGENTS.md"),
654 format!(
655 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
656 ),
657 )
658 .unwrap();
659
660 let actions = plan(home, &project);
661 assert!(
662 actions.iter().any(|a| matches!(
663 a,
664 Action::StripCompression { path, .. } if path.ends_with("AGENTS.md")
665 )),
666 "{actions:?}"
667 );
668 crate::test_env::remove_var("CODEX_HOME");
669 }
670
671 #[test]
672 fn plan_keeps_agents_md_carrier_when_cursor_uncovered() {
673 let _guard = crate::core::data_dir::test_env_lock();
674 let tmp = tempfile::tempdir().unwrap();
675 let home = tmp.path();
676 let project = home.join("app");
677 std::fs::create_dir_all(&project).unwrap();
678 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
679
680 std::fs::write(
682 project.join("AGENTS.md"),
683 format!(
684 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
685 ),
686 )
687 .unwrap();
688
689 let actions = plan(home, &project);
690 assert!(
691 !actions
692 .iter()
693 .any(|a| matches!(a, Action::StripCompression { .. })),
694 "{actions:?}"
695 );
696 assert!(
698 actions.iter().any(|a| matches!(
699 a,
700 Action::Report { path, .. } if path.ends_with("AGENTS.md")
701 )),
702 "{actions:?}"
703 );
704 crate::test_env::remove_var("CODEX_HOME");
705 }
706
707 #[test]
708 fn apply_strip_compression_thins_and_backs_up() {
709 let tmp = tempfile::tempdir().unwrap();
710 let path = tmp.path().join("AGENTS.md");
711 std::fs::write(
712 &path,
713 format!(
714 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
715 ),
716 )
717 .unwrap();
718
719 let msg = apply(&Action::StripCompression {
720 path: path.clone(),
721 reason: String::new(),
722 });
723 assert!(msg.starts_with("thinned"), "{msg}");
724 let after = std::fs::read_to_string(&path).unwrap();
725 assert!(after.contains(BLOCK_START));
726 assert!(!after.contains("lean-ctx-compression"));
727 let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
728 assert!(bak.contains("lean-ctx-compression"));
729 }
730
731 #[test]
732 fn apply_strip_deletes_file_that_was_only_ours() {
733 let tmp = tempfile::tempdir().unwrap();
734 let path = tmp.path().join(".cursorrules");
735 std::fs::write(&path, format!("{BLOCK_START}\nours\n{BLOCK_END}\n")).unwrap();
736
737 let msg = apply(&Action::StripBlocks {
738 path: path.clone(),
739 reason: String::new(),
740 });
741 assert!(msg.starts_with("deleted"), "{msg}");
742 assert!(!path.exists());
743 assert!(path.with_extension("bak").exists());
744 }
745}