1use std::path::{Path, PathBuf};
24
25use crate::core::rules_canonical::{END_MARK as BLOCK_END, START_MARK as BLOCK_START};
26use crate::core::rules_channel::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
27
28#[derive(Debug, PartialEq, Eq)]
30pub(crate) enum Action {
31 DeleteFile { path: PathBuf, reason: String },
33 StripBlocks { path: PathBuf, reason: String },
35 StripCompression { path: PathBuf, reason: String },
39 Report { path: PathBuf, note: String },
41}
42
43fn is_owned_rules_file(content: &str) -> bool {
47 let starts_with_header = content
48 .trim_start()
49 .starts_with(crate::core::rules_canonical::START_MARK)
50 || (content.trim_start().starts_with("---")
52 && content.contains(crate::core::rules_canonical::START_MARK));
53 starts_with_header && content.contains("<!-- version:")
54}
55
56fn has_marker_pair(content: &str, start: &str, end: &str) -> bool {
59 crate::marked_block::contains_marker_line(content, start)
60 && crate::marked_block::contains_marker_line(content, end)
61}
62
63fn has_marked_block(content: &str) -> bool {
64 has_marker_pair(content, BLOCK_START, BLOCK_END)
65 || has_marker_pair(content, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END)
66}
67
68pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
70 let mut out = content.to_string();
71 loop {
74 let next = if has_marker_pair(&out, BLOCK_START, BLOCK_END) {
75 crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
76 } else if has_marker_pair(&out, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END) {
77 crate::marked_block::remove_content(
78 &out,
79 COMPRESSION_BLOCK_START,
80 COMPRESSION_BLOCK_END,
81 )
82 } else {
83 break;
84 };
85 if next == out {
86 break;
87 }
88 out = next;
89 }
90 let trimmed = out.trim_end();
91 if trimmed.is_empty() {
92 String::new()
93 } else {
94 format!("{trimmed}\n")
95 }
96}
97
98pub(crate) fn strip_compression_block(content: &str) -> String {
102 if !(content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END)) {
103 return content.to_string();
104 }
105 let stripped = crate::marked_block::remove_content(
106 content,
107 COMPRESSION_BLOCK_START,
108 COMPRESSION_BLOCK_END,
109 );
110 let trimmed = stripped.trim_end();
111 if trimmed.is_empty() {
112 String::new()
113 } else {
114 format!("{trimmed}\n")
115 }
116}
117
118fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
121 vec![
122 dir.join(".cursor/rules/lean-ctx.mdc"),
123 dir.join(".claude/rules/lean-ctx.md"),
124 dir.join(".codebuddy/rules/lean-ctx.md"),
125 dir.join(".windsurf/rules/lean-ctx.md"),
126 dir.join(".cline/rules/lean-ctx.md"),
127 dir.join(".roo/rules/lean-ctx.md"),
128 ]
129}
130
131pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
133 let mut actions = Vec::new();
134 let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
135
136 let mut dir = Some(project.to_path_buf());
138 while let Some(d) = dir {
139 if d == *home {
140 break;
141 }
142 for candidate in project_owned_candidates(&d) {
143 if candidate == canonical_cursor_mdc {
144 continue;
145 }
146 let Ok(content) = std::fs::read_to_string(&candidate) else {
147 continue;
148 };
149 if is_owned_rules_file(&content) {
150 actions.push(Action::DeleteFile {
151 path: candidate,
152 reason: "lean-ctx-owned duplicate of the global rules file".into(),
153 });
154 } else if !content.trim().is_empty() {
155 actions.push(Action::Report {
156 path: candidate,
157 note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
158 });
159 }
160 }
161 dir = d.parent().map(Path::to_path_buf);
162 }
163
164 let cursorrules = project.join(".cursorrules");
167 if let Ok(content) = std::fs::read_to_string(&cursorrules) {
168 if canonical_cursor_mdc.exists() && has_marked_block(&content) {
169 actions.push(Action::StripBlocks {
170 path: cursorrules,
171 reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
172 });
173 } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
174 actions.push(Action::Report {
175 path: cursorrules,
176 note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
177 });
178 } else if content.contains("lean-ctx") && !has_marked_block(&content) {
179 actions.push(Action::Report {
180 path: cursorrules,
181 note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
182 });
183 }
184 }
185
186 let agents_md = project.join("AGENTS.md");
191 if let Ok(content) = std::fs::read_to_string(&agents_md) {
192 let has_compression =
193 content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END);
194 if has_compression {
195 if crate::core::rules_channel::agents_md_can_thin(home) {
196 actions.push(Action::StripCompression {
197 path: agents_md,
198 reason: "every AGENTS.md reader already loads the compression block from its own canonical file".into(),
199 });
200 } else {
201 actions.push(Action::Report {
202 path: agents_md,
203 note: "an AGENTS.md reader has no other compression source — AGENTS.md stays the carrier".into(),
204 });
205 }
206 }
207 }
208
209 actions
210}
211
212fn apply(action: &Action) -> String {
214 match action {
215 Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
216 Ok(()) => format!("deleted {}", path.display()),
217 Err(e) => format!("FAILED {} ({e})", path.display()),
218 },
219 Action::StripBlocks { path, .. } => {
220 let Ok(content) = std::fs::read_to_string(path) else {
221 return format!("FAILED {} (unreadable)", path.display());
222 };
223 let stripped = strip_lean_ctx_blocks(&content);
224 if stripped == content {
225 return format!("unchanged {}", path.display());
226 }
227 let bak = path.with_extension("bak");
228 if let Err(e) = std::fs::write(&bak, &content) {
229 return format!("FAILED {} (backup: {e})", path.display());
230 }
231 if stripped.is_empty() {
232 match std::fs::remove_file(path) {
233 Ok(()) => format!(
234 "deleted {} (only lean-ctx blocks, backup: {})",
235 path.display(),
236 bak.display()
237 ),
238 Err(e) => format!("FAILED {} ({e})", path.display()),
239 }
240 } else {
241 match std::fs::write(path, &stripped) {
242 Ok(()) => format!("stripped {} (backup: {})", path.display(), bak.display()),
243 Err(e) => format!("FAILED {} ({e})", path.display()),
244 }
245 }
246 }
247 Action::StripCompression { path, .. } => {
248 let Ok(content) = std::fs::read_to_string(path) else {
249 return format!("FAILED {} (unreadable)", path.display());
250 };
251 let stripped = strip_compression_block(&content);
252 if stripped == content {
253 return format!("unchanged {}", path.display());
254 }
255 let bak = path.with_extension("bak");
256 if let Err(e) = std::fs::write(&bak, &content) {
257 return format!("FAILED {} (backup: {e})", path.display());
258 }
259 match std::fs::write(path, &stripped) {
260 Ok(()) => format!("thinned {} (backup: {})", path.display(), bak.display()),
261 Err(e) => format!("FAILED {} ({e})", path.display()),
262 }
263 }
264 Action::Report { path, note } => format!("info {} — {note}", path.display()),
265 }
266}
267
268pub fn auto_apply(home: &Path, project: &Path) -> Vec<String> {
273 plan(home, project)
274 .iter()
275 .filter(|a| !matches!(a, Action::Report { .. }))
276 .map(apply)
277 .collect()
278}
279
280pub fn run(apply_changes: bool) -> i32 {
282 let Some(home) = dirs::home_dir() else {
283 eprintln!("Error: could not determine home directory");
284 return 1;
285 };
286 let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
287 let actions = plan(&home, &project);
288
289 if actions.is_empty() {
290 println!("No duplicated lean-ctx rules found — every client pays once.");
291 return 0;
292 }
293
294 println!(
295 "{} (project: {})\n",
296 if apply_changes {
297 "Deduplicating lean-ctx rules"
298 } else {
299 "Dedup plan (dry-run — pass --apply to execute)"
300 },
301 project.display()
302 );
303
304 let mut fixable = 0usize;
305 for action in &actions {
306 match action {
307 Action::DeleteFile { path, reason } => {
308 fixable += 1;
309 if apply_changes {
310 println!(" {}", apply(action));
311 } else {
312 println!(" delete {}\n ({reason})", path.display());
313 }
314 }
315 Action::StripBlocks { path, reason } => {
316 fixable += 1;
317 if apply_changes {
318 println!(" {}", apply(action));
319 } else {
320 println!(" strip {}\n ({reason})", path.display());
321 }
322 }
323 Action::StripCompression { path, reason } => {
324 fixable += 1;
325 if apply_changes {
326 println!(" {}", apply(action));
327 } else {
328 println!(" thin {}\n ({reason})", path.display());
329 }
330 }
331 Action::Report { .. } => println!(" {}", apply(action)),
332 }
333 }
334
335 if !apply_changes && fixable > 0 {
336 println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
337 }
338 if apply_changes && fixable > 0 {
339 println!("\nDone. Verify with `lean-ctx doctor overhead`.");
340 }
341 0
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn owned_mdc() -> String {
349 format!(
350 "---\ndescription: lean-ctx\n---\n{}\n<!-- version: 1 -->\nbody\n",
351 crate::core::rules_canonical::START_MARK
352 )
353 }
354
355 #[test]
356 fn detects_owned_dedicated_files() {
357 assert!(is_owned_rules_file(&owned_mdc()));
358 assert!(is_owned_rules_file(&format!(
359 "{}\n<!-- version: 1 -->\nbody\n",
360 crate::core::rules_canonical::START_MARK
361 )));
362 assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
364 assert!(!is_owned_rules_file(&format!(
366 "# my header\n{}\n<!-- version: 1 -->\n",
367 crate::core::rules_canonical::START_MARK
368 )));
369 }
370
371 #[test]
372 fn strip_removes_rules_and_compression_blocks() {
373 let content = format!(
374 "user line\n{BLOCK_START}\nour rules\n{BLOCK_END}\nmore user\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
375 );
376 let out = strip_lean_ctx_blocks(&content);
377 assert!(out.contains("user line"));
378 assert!(out.contains("more user"));
379 assert!(!out.contains("our rules"));
380 assert!(!out.contains("style"));
381 assert!(!out.contains("lean-ctx-compression"));
382 }
383
384 #[test]
385 fn strip_of_pure_block_file_yields_empty() {
386 let content = format!("{BLOCK_START}\nonly ours\n{BLOCK_END}\n");
387 assert_eq!(strip_lean_ctx_blocks(&content), "");
388 }
389
390 #[test]
391 fn plan_deletes_project_and_parent_owned_files_only() {
392 let tmp = tempfile::tempdir().unwrap();
393 let home = tmp.path();
394 let parent = home.join("projects");
395 let project = parent.join("app");
396
397 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
399 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
400 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
402 std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
403 std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
404 std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
405 std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
407 std::fs::write(
408 project.join(".claude/rules/lean-ctx.md"),
409 "# customized by user\nkeep me\n",
410 )
411 .unwrap();
412
413 let actions = plan(home, &project);
414 let deletes: Vec<&PathBuf> = actions
415 .iter()
416 .filter_map(|a| match a {
417 Action::DeleteFile { path, .. } => Some(path),
418 _ => None,
419 })
420 .collect();
421 assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
422 assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
423 assert!(actions.iter().any(|a| matches!(
424 a,
425 Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
426 )));
427 }
428
429 #[test]
430 fn plan_strips_cursorrules_only_with_canonical_mdc() {
431 let tmp = tempfile::tempdir().unwrap();
432 let home = tmp.path();
433 let project = home.join("app");
434 std::fs::create_dir_all(&project).unwrap();
435 let rules = format!("{BLOCK_START}\npointer\n{BLOCK_END}\n");
436 std::fs::write(project.join(".cursorrules"), rules).unwrap();
437
438 let actions = plan(home, &project);
440 assert!(
441 !actions
442 .iter()
443 .any(|a| matches!(a, Action::StripBlocks { .. })),
444 "{actions:?}"
445 );
446
447 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
449 std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
450 let actions = plan(home, &project);
451 assert!(
452 actions.iter().any(|a| matches!(
453 a,
454 Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
455 )),
456 "{actions:?}"
457 );
458 }
459
460 #[test]
461 fn apply_strip_writes_backup_and_keeps_user_content() {
462 let tmp = tempfile::tempdir().unwrap();
463 let path = tmp.path().join(".cursorrules");
464 std::fs::write(
465 &path,
466 format!("my custom rule\n{BLOCK_START}\nours\n{BLOCK_END}\n"),
467 )
468 .unwrap();
469
470 let msg = apply(&Action::StripBlocks {
471 path: path.clone(),
472 reason: String::new(),
473 });
474 assert!(msg.starts_with("stripped"), "{msg}");
475 let after = std::fs::read_to_string(&path).unwrap();
476 assert_eq!(after, "my custom rule\n");
477 let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
478 assert!(bak.contains("ours"));
479 }
480
481 #[test]
482 fn strip_compression_keeps_pointer_and_user_content() {
483 let content = format!(
484 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n",
485 );
486 let out = strip_compression_block(&content);
487 assert!(out.contains("# Agent Instructions"));
488 assert!(out.contains(BLOCK_START));
489 assert!(out.contains("pointer"));
490 assert!(!out.contains("lean-ctx-compression"));
491 assert!(!out.contains("OUTPUT STYLE"));
492 }
493
494 #[test]
495 fn plan_thins_agents_md_when_cursor_covered_and_no_codex() {
496 let _guard = crate::core::data_dir::test_env_lock();
497 let tmp = tempfile::tempdir().unwrap();
498 let home = tmp.path();
499 let project = home.join("app");
500 std::fs::create_dir_all(&project).unwrap();
501 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
503
504 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
506 std::fs::write(
507 home.join(".cursor/rules/lean-ctx.mdc"),
508 format!(
509 "{}\n<!-- version: 1 -->\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
510 crate::core::rules_canonical::START_MARK
511 ),
512 )
513 .unwrap();
514
515 std::fs::write(
517 project.join("AGENTS.md"),
518 format!(
519 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
520 ),
521 )
522 .unwrap();
523
524 let actions = plan(home, &project);
525 assert!(
526 actions.iter().any(|a| matches!(
527 a,
528 Action::StripCompression { path, .. } if path.ends_with("AGENTS.md")
529 )),
530 "{actions:?}"
531 );
532 crate::test_env::remove_var("CODEX_HOME");
533 }
534
535 #[test]
536 fn plan_keeps_agents_md_carrier_when_cursor_uncovered() {
537 let _guard = crate::core::data_dir::test_env_lock();
538 let tmp = tempfile::tempdir().unwrap();
539 let home = tmp.path();
540 let project = home.join("app");
541 std::fs::create_dir_all(&project).unwrap();
542 crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
543
544 std::fs::write(
546 project.join("AGENTS.md"),
547 format!(
548 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
549 ),
550 )
551 .unwrap();
552
553 let actions = plan(home, &project);
554 assert!(
555 !actions
556 .iter()
557 .any(|a| matches!(a, Action::StripCompression { .. })),
558 "{actions:?}"
559 );
560 assert!(
562 actions.iter().any(|a| matches!(
563 a,
564 Action::Report { path, .. } if path.ends_with("AGENTS.md")
565 )),
566 "{actions:?}"
567 );
568 crate::test_env::remove_var("CODEX_HOME");
569 }
570
571 #[test]
572 fn apply_strip_compression_thins_and_backs_up() {
573 let tmp = tempfile::tempdir().unwrap();
574 let path = tmp.path().join("AGENTS.md");
575 std::fs::write(
576 &path,
577 format!(
578 "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
579 ),
580 )
581 .unwrap();
582
583 let msg = apply(&Action::StripCompression {
584 path: path.clone(),
585 reason: String::new(),
586 });
587 assert!(msg.starts_with("thinned"), "{msg}");
588 let after = std::fs::read_to_string(&path).unwrap();
589 assert!(after.contains(BLOCK_START));
590 assert!(!after.contains("lean-ctx-compression"));
591 let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
592 assert!(bak.contains("lean-ctx-compression"));
593 }
594
595 #[test]
596 fn apply_strip_deletes_file_that_was_only_ours() {
597 let tmp = tempfile::tempdir().unwrap();
598 let path = tmp.path().join(".cursorrules");
599 std::fs::write(&path, format!("{BLOCK_START}\nours\n{BLOCK_END}\n")).unwrap();
600
601 let msg = apply(&Action::StripBlocks {
602 path: path.clone(),
603 reason: String::new(),
604 });
605 assert!(msg.starts_with("deleted"), "{msg}");
606 assert!(!path.exists());
607 assert!(path.with_extension("bak").exists());
608 }
609}