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