Skip to main content

lean_ctx/cli/
rules_dedup.rs

1//! `lean-ctx rules dedup` — collapse duplicated lean-ctx guidance (#578).
2//!
3//! A client should pay for lean-ctx rules exactly once per session. Older
4//! installs (and parent-directory walks in monorepos) left full rule copies
5//! in several auto-loaded files; `doctor overhead` detects the duplication,
6//! this command repairs it:
7//!
8//!  1. lean-ctx-OWNED dedicated rule files outside the canonical global
9//!     location (project/parent `.cursor/rules/lean-ctx.mdc`,
10//!     `.claude/rules/lean-ctx.md`, …) → deleted.
11//!  2. `.cursorrules` lean-ctx blocks → removed when the canonical global
12//!     Cursor mdc exists (Cursor auto-loads both; pointer lives in AGENTS.md).
13//!  3. Stale compression blocks in `.cursorrules` → removed under the same
14//!     condition (the global mdc carries the block).
15//!  4. Compression block in a shared `AGENTS.md` (#684) → removed (pointer
16//!     kept) once every AGENTS.md reader is covered by its own canonical
17//!     carrier; otherwise reported and left as the carrier.
18//!
19//! Only lean-ctx-owned files and lean-ctx-marked blocks are ever touched.
20//! Unmarked user content is reported, never modified. Default is a dry-run
21//! report; `--apply` executes with `.bak` backups for partial edits.
22
23use 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/// One planned dedup action.
31#[derive(Debug, PartialEq, Eq)]
32pub(crate) enum Action {
33    /// Delete a wholly lean-ctx-owned duplicate rules file.
34    DeleteFile { path: PathBuf, reason: String },
35    /// Strip lean-ctx-marked blocks from a shared file (keeps user content).
36    StripBlocks { path: PathBuf, reason: String },
37    /// Strip only the compression block from a shared file, keeping the
38    /// `<!-- lean-ctx -->` pointer and all user content (#684 — thins a shared
39    /// AGENTS.md whose readers are all covered by their own canonical carrier).
40    StripCompression { path: PathBuf, reason: String },
41    /// Informational only — lean-ctx guidance in user-maintained content.
42    Report { path: PathBuf, note: String },
43}
44
45/// A file is "lean-ctx-owned" when lean-ctx wrote the whole file: dedicated
46/// rule files start with the canonical header and carry a rules-version
47/// marker, project LEAN-CTX.md carries its ownership marker.
48fn 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        // CursorMdc has YAML frontmatter before the header.
53        || (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
63/// Strips every lean-ctx-marked block (rules + compression) from `content`.
64pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
65    let mut out = content.to_string();
66    // Repeat until stable — a file can contain both block kinds (and in
67    // pathological cases several of the same kind).
68    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
89/// Strips only the compression block, keeping the `<!-- lean-ctx -->` pointer
90/// and every line of user content. Used to thin a shared AGENTS.md once each
91/// reader is covered by its own canonical carrier (#684).
92pub(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
105/// Dedicated lean-ctx rule files that may linger in a project / parent chain
106/// from older versions. Canonical copies live under `home` (global targets).
107fn 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
118/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
119pub(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    // 1. Owned dedicated duplicates in the project + parent chain.
124    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    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
152    // global mdc exists (Cursor loads both files every session).
153    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    // 4. Shared project AGENTS.md (#684): Cursor, Codex and other agents all
174    // auto-load it, so a compression block here duplicates each reader's own
175    // canonical carrier. Strip only the compression block (the pointer stays)
176    // once every reader present on this machine is covered elsewhere.
177    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
199/// Executes one action. Returns a human-readable result line.
200fn 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
255/// CLI entry: `lean-ctx rules dedup [--apply]`.
256pub 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        // User file mentioning lean-ctx is NOT owned.
338        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
339        // Marker buried mid-file (user prepended content) is NOT owned.
340        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        // Canonical global mdc (must never be planned for deletion).
371        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        // Stale project + parent copies.
374        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        // User-customized file — must only be reported.
379        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        // Without the global mdc, .cursorrules is the carrier — report only.
412        let actions = plan(home, &project);
413        assert!(
414            !actions
415                .iter()
416                .any(|a| matches!(a, Action::StripBlocks { .. })),
417            "{actions:?}"
418        );
419
420        // With the canonical mdc, the block is a duplicate — strip.
421        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        // Isolate codex resolution to this empty home (no codex present).
473        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
474
475        // Canonical global mdc carries the compression block → cursor covered.
476        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        // Project AGENTS.md still carries pointer + compression.
487        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        // No global mdc → cursor not covered → AGENTS.md must stay the carrier.
516        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        // Reported instead, so the user understands why it was left alone.
532        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}