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