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//!
16//! Only lean-ctx-owned files and lean-ctx-marked blocks are ever touched.
17//! Unmarked user content is reported, never modified. Default is a dry-run
18//! report; `--apply` executes with `.bak` backups for partial edits.
19
20use std::path::{Path, PathBuf};
21
22const COMPRESSION_START: &str = "<!-- lean-ctx-compression -->";
23const COMPRESSION_END: &str = "<!-- /lean-ctx-compression -->";
24const BLOCK_START: &str = "<!-- lean-ctx -->";
25const BLOCK_END: &str = "<!-- /lean-ctx -->";
26
27/// One planned dedup action.
28#[derive(Debug, PartialEq, Eq)]
29pub(crate) enum Action {
30    /// Delete a wholly lean-ctx-owned duplicate rules file.
31    DeleteFile { path: PathBuf, reason: String },
32    /// Strip lean-ctx-marked blocks from a shared file (keeps user content).
33    StripBlocks { path: PathBuf, reason: String },
34    /// Informational only — lean-ctx guidance in user-maintained content.
35    Report { path: PathBuf, note: String },
36}
37
38/// A file is "lean-ctx-owned" when lean-ctx wrote the whole file: dedicated
39/// rule files start with the canonical header and carry a rules-version
40/// marker, project LEAN-CTX.md carries its ownership marker.
41fn is_owned_rules_file(content: &str) -> bool {
42    let starts_with_header = content
43        .trim_start()
44        .starts_with(crate::rules_inject::RULES_MARKER)
45        // CursorMdc has YAML frontmatter before the header.
46        || (content.trim_start().starts_with("---")
47            && content.contains(crate::rules_inject::RULES_MARKER));
48    starts_with_header && content.contains("<!-- lean-ctx-rules-")
49}
50
51fn has_marked_block(content: &str) -> bool {
52    (content.contains(BLOCK_START) && content.contains(BLOCK_END))
53        || (content.contains(COMPRESSION_START) && content.contains(COMPRESSION_END))
54}
55
56/// Strips every lean-ctx-marked block (rules + compression) from `content`.
57pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
58    let mut out = content.to_string();
59    // Repeat until stable — a file can contain both block kinds (and in
60    // pathological cases several of the same kind).
61    loop {
62        let next = if out.contains(BLOCK_START) && out.contains(BLOCK_END) {
63            crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
64        } else if out.contains(COMPRESSION_START) && out.contains(COMPRESSION_END) {
65            crate::marked_block::remove_content(&out, COMPRESSION_START, COMPRESSION_END)
66        } else {
67            break;
68        };
69        if next == out {
70            break;
71        }
72        out = next;
73    }
74    let trimmed = out.trim_end();
75    if trimmed.is_empty() {
76        String::new()
77    } else {
78        format!("{trimmed}\n")
79    }
80}
81
82/// Dedicated lean-ctx rule files that may linger in a project / parent chain
83/// from older versions. Canonical copies live under `home` (global targets).
84fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
85    vec![
86        dir.join(".cursor/rules/lean-ctx.mdc"),
87        dir.join(".claude/rules/lean-ctx.md"),
88        dir.join(".windsurf/rules/lean-ctx.md"),
89        dir.join(".cline/rules/lean-ctx.md"),
90        dir.join(".roo/rules/lean-ctx.md"),
91    ]
92}
93
94/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
95pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
96    let mut actions = Vec::new();
97    let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
98
99    // 1. Owned dedicated duplicates in the project + parent chain.
100    let mut dir = Some(project.to_path_buf());
101    while let Some(d) = dir {
102        if d == *home {
103            break;
104        }
105        for candidate in project_owned_candidates(&d) {
106            if candidate == canonical_cursor_mdc {
107                continue;
108            }
109            let Ok(content) = std::fs::read_to_string(&candidate) else {
110                continue;
111            };
112            if is_owned_rules_file(&content) {
113                actions.push(Action::DeleteFile {
114                    path: candidate,
115                    reason: "lean-ctx-owned duplicate of the global rules file".into(),
116                });
117            } else if !content.trim().is_empty() {
118                actions.push(Action::Report {
119                    path: candidate,
120                    note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
121                });
122            }
123        }
124        dir = d.parent().map(Path::to_path_buf);
125    }
126
127    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
128    // global mdc exists (Cursor loads both files every session).
129    let cursorrules = project.join(".cursorrules");
130    if let Ok(content) = std::fs::read_to_string(&cursorrules) {
131        if canonical_cursor_mdc.exists() && has_marked_block(&content) {
132            actions.push(Action::StripBlocks {
133                path: cursorrules,
134                reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
135            });
136        } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
137            actions.push(Action::Report {
138                path: cursorrules,
139                note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
140            });
141        } else if content.contains("lean-ctx") && !has_marked_block(&content) {
142            actions.push(Action::Report {
143                path: cursorrules,
144                note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
145            });
146        }
147    }
148
149    actions
150}
151
152/// Executes one action. Returns a human-readable result line.
153fn apply(action: &Action) -> String {
154    match action {
155        Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
156            Ok(()) => format!("deleted   {}", path.display()),
157            Err(e) => format!("FAILED    {} ({e})", path.display()),
158        },
159        Action::StripBlocks { path, .. } => {
160            let Ok(content) = std::fs::read_to_string(path) else {
161                return format!("FAILED    {} (unreadable)", path.display());
162            };
163            let stripped = strip_lean_ctx_blocks(&content);
164            if stripped == content {
165                return format!("unchanged {}", path.display());
166            }
167            let bak = path.with_extension("bak");
168            if let Err(e) = std::fs::write(&bak, &content) {
169                return format!("FAILED    {} (backup: {e})", path.display());
170            }
171            if stripped.is_empty() {
172                match std::fs::remove_file(path) {
173                    Ok(()) => format!(
174                        "deleted   {} (only lean-ctx blocks, backup: {})",
175                        path.display(),
176                        bak.display()
177                    ),
178                    Err(e) => format!("FAILED    {} ({e})", path.display()),
179                }
180            } else {
181                match std::fs::write(path, &stripped) {
182                    Ok(()) => format!("stripped  {} (backup: {})", path.display(), bak.display()),
183                    Err(e) => format!("FAILED    {} ({e})", path.display()),
184                }
185            }
186        }
187        Action::Report { path, note } => format!("info      {} — {note}", path.display()),
188    }
189}
190
191/// CLI entry: `lean-ctx rules dedup [--apply]`.
192pub fn run(apply_changes: bool) -> i32 {
193    let Some(home) = dirs::home_dir() else {
194        eprintln!("Error: could not determine home directory");
195        return 1;
196    };
197    let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
198    let actions = plan(&home, &project);
199
200    if actions.is_empty() {
201        println!("No duplicated lean-ctx rules found — every client pays once.");
202        return 0;
203    }
204
205    println!(
206        "{} (project: {})\n",
207        if apply_changes {
208            "Deduplicating lean-ctx rules"
209        } else {
210            "Dedup plan (dry-run — pass --apply to execute)"
211        },
212        project.display()
213    );
214
215    let mut fixable = 0usize;
216    for action in &actions {
217        match action {
218            Action::DeleteFile { path, reason } => {
219                fixable += 1;
220                if apply_changes {
221                    println!("  {}", apply(action));
222                } else {
223                    println!("  delete    {}\n            ({reason})", path.display());
224                }
225            }
226            Action::StripBlocks { path, reason } => {
227                fixable += 1;
228                if apply_changes {
229                    println!("  {}", apply(action));
230                } else {
231                    println!("  strip     {}\n            ({reason})", path.display());
232                }
233            }
234            Action::Report { .. } => println!("  {}", apply(action)),
235        }
236    }
237
238    if !apply_changes && fixable > 0 {
239        println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
240    }
241    if apply_changes && fixable > 0 {
242        println!("\nDone. Verify with `lean-ctx doctor overhead`.");
243    }
244    0
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn owned_mdc() -> String {
252        format!(
253            "---\ndescription: lean-ctx\n---\n{}\n<!-- lean-ctx-rules-v9 -->\nbody\n",
254            crate::rules_inject::RULES_MARKER
255        )
256    }
257
258    #[test]
259    fn detects_owned_dedicated_files() {
260        assert!(is_owned_rules_file(&owned_mdc()));
261        assert!(is_owned_rules_file(&format!(
262            "{}\n<!-- lean-ctx-rules-v11 -->\nbody\n",
263            crate::rules_inject::RULES_MARKER
264        )));
265        // User file mentioning lean-ctx is NOT owned.
266        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
267        // Marker buried mid-file (user prepended content) is NOT owned.
268        assert!(!is_owned_rules_file(&format!(
269            "# my header\n{}\n<!-- lean-ctx-rules-v11 -->\n",
270            crate::rules_inject::RULES_MARKER
271        )));
272    }
273
274    #[test]
275    fn strip_removes_rules_and_compression_blocks() {
276        let content = "user line\n<!-- lean-ctx -->\nour rules\n<!-- /lean-ctx -->\nmore user\n<!-- lean-ctx-compression -->\nstyle\n<!-- /lean-ctx-compression -->\n";
277        let out = strip_lean_ctx_blocks(content);
278        assert!(out.contains("user line"));
279        assert!(out.contains("more user"));
280        assert!(!out.contains("our rules"));
281        assert!(!out.contains("style"));
282        assert!(!out.contains("lean-ctx-compression"));
283    }
284
285    #[test]
286    fn strip_of_pure_block_file_yields_empty() {
287        let content = "<!-- lean-ctx -->\nonly ours\n<!-- /lean-ctx -->\n";
288        assert_eq!(strip_lean_ctx_blocks(content), "");
289    }
290
291    #[test]
292    fn plan_deletes_project_and_parent_owned_files_only() {
293        let tmp = tempfile::tempdir().unwrap();
294        let home = tmp.path();
295        let parent = home.join("projects");
296        let project = parent.join("app");
297
298        // Canonical global mdc (must never be planned for deletion).
299        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
300        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
301        // Stale project + parent copies.
302        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
303        std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
304        std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
305        std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
306        // User-customized file — must only be reported.
307        std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
308        std::fs::write(
309            project.join(".claude/rules/lean-ctx.md"),
310            "# customized by user\nkeep me\n",
311        )
312        .unwrap();
313
314        let actions = plan(home, &project);
315        let deletes: Vec<&PathBuf> = actions
316            .iter()
317            .filter_map(|a| match a {
318                Action::DeleteFile { path, .. } => Some(path),
319                _ => None,
320            })
321            .collect();
322        assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
323        assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
324        assert!(actions.iter().any(|a| matches!(
325            a,
326            Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
327        )));
328    }
329
330    #[test]
331    fn plan_strips_cursorrules_only_with_canonical_mdc() {
332        let tmp = tempfile::tempdir().unwrap();
333        let home = tmp.path();
334        let project = home.join("app");
335        std::fs::create_dir_all(&project).unwrap();
336        let rules = "<!-- lean-ctx -->\npointer\n<!-- /lean-ctx -->\n";
337        std::fs::write(project.join(".cursorrules"), rules).unwrap();
338
339        // Without the global mdc, .cursorrules is the carrier — report only.
340        let actions = plan(home, &project);
341        assert!(
342            !actions
343                .iter()
344                .any(|a| matches!(a, Action::StripBlocks { .. })),
345            "{actions:?}"
346        );
347
348        // With the canonical mdc, the block is a duplicate — strip.
349        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
350        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
351        let actions = plan(home, &project);
352        assert!(
353            actions.iter().any(|a| matches!(
354                a,
355                Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
356            )),
357            "{actions:?}"
358        );
359    }
360
361    #[test]
362    fn apply_strip_writes_backup_and_keeps_user_content() {
363        let tmp = tempfile::tempdir().unwrap();
364        let path = tmp.path().join(".cursorrules");
365        std::fs::write(
366            &path,
367            "my custom rule\n<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n",
368        )
369        .unwrap();
370
371        let msg = apply(&Action::StripBlocks {
372            path: path.clone(),
373            reason: String::new(),
374        });
375        assert!(msg.starts_with("stripped"), "{msg}");
376        let after = std::fs::read_to_string(&path).unwrap();
377        assert_eq!(after, "my custom rule\n");
378        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
379        assert!(bak.contains("ours"));
380    }
381
382    #[test]
383    fn apply_strip_deletes_file_that_was_only_ours() {
384        let tmp = tempfile::tempdir().unwrap();
385        let path = tmp.path().join(".cursorrules");
386        std::fs::write(&path, "<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n").unwrap();
387
388        let msg = apply(&Action::StripBlocks {
389            path: path.clone(),
390            reason: String::new(),
391        });
392        assert!(msg.starts_with("deleted"), "{msg}");
393        assert!(!path.exists());
394        assert!(path.with_extension("bak").exists());
395    }
396}