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(".codebuddy/rules/lean-ctx.md"),
89        dir.join(".windsurf/rules/lean-ctx.md"),
90        dir.join(".cline/rules/lean-ctx.md"),
91        dir.join(".roo/rules/lean-ctx.md"),
92    ]
93}
94
95/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
96pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
97    let mut actions = Vec::new();
98    let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
99
100    // 1. Owned dedicated duplicates in the project + parent chain.
101    let mut dir = Some(project.to_path_buf());
102    while let Some(d) = dir {
103        if d == *home {
104            break;
105        }
106        for candidate in project_owned_candidates(&d) {
107            if candidate == canonical_cursor_mdc {
108                continue;
109            }
110            let Ok(content) = std::fs::read_to_string(&candidate) else {
111                continue;
112            };
113            if is_owned_rules_file(&content) {
114                actions.push(Action::DeleteFile {
115                    path: candidate,
116                    reason: "lean-ctx-owned duplicate of the global rules file".into(),
117                });
118            } else if !content.trim().is_empty() {
119                actions.push(Action::Report {
120                    path: candidate,
121                    note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
122                });
123            }
124        }
125        dir = d.parent().map(Path::to_path_buf);
126    }
127
128    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
129    // global mdc exists (Cursor loads both files every session).
130    let cursorrules = project.join(".cursorrules");
131    if let Ok(content) = std::fs::read_to_string(&cursorrules) {
132        if canonical_cursor_mdc.exists() && has_marked_block(&content) {
133            actions.push(Action::StripBlocks {
134                path: cursorrules,
135                reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
136            });
137        } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
138            actions.push(Action::Report {
139                path: cursorrules,
140                note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
141            });
142        } else if content.contains("lean-ctx") && !has_marked_block(&content) {
143            actions.push(Action::Report {
144                path: cursorrules,
145                note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
146            });
147        }
148    }
149
150    actions
151}
152
153/// Executes one action. Returns a human-readable result line.
154fn apply(action: &Action) -> String {
155    match action {
156        Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
157            Ok(()) => format!("deleted   {}", path.display()),
158            Err(e) => format!("FAILED    {} ({e})", path.display()),
159        },
160        Action::StripBlocks { path, .. } => {
161            let Ok(content) = std::fs::read_to_string(path) else {
162                return format!("FAILED    {} (unreadable)", path.display());
163            };
164            let stripped = strip_lean_ctx_blocks(&content);
165            if stripped == content {
166                return format!("unchanged {}", path.display());
167            }
168            let bak = path.with_extension("bak");
169            if let Err(e) = std::fs::write(&bak, &content) {
170                return format!("FAILED    {} (backup: {e})", path.display());
171            }
172            if stripped.is_empty() {
173                match std::fs::remove_file(path) {
174                    Ok(()) => format!(
175                        "deleted   {} (only lean-ctx blocks, backup: {})",
176                        path.display(),
177                        bak.display()
178                    ),
179                    Err(e) => format!("FAILED    {} ({e})", path.display()),
180                }
181            } else {
182                match std::fs::write(path, &stripped) {
183                    Ok(()) => format!("stripped  {} (backup: {})", path.display(), bak.display()),
184                    Err(e) => format!("FAILED    {} ({e})", path.display()),
185                }
186            }
187        }
188        Action::Report { path, note } => format!("info      {} — {note}", path.display()),
189    }
190}
191
192/// CLI entry: `lean-ctx rules dedup [--apply]`.
193pub fn run(apply_changes: bool) -> i32 {
194    let Some(home) = dirs::home_dir() else {
195        eprintln!("Error: could not determine home directory");
196        return 1;
197    };
198    let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
199    let actions = plan(&home, &project);
200
201    if actions.is_empty() {
202        println!("No duplicated lean-ctx rules found — every client pays once.");
203        return 0;
204    }
205
206    println!(
207        "{} (project: {})\n",
208        if apply_changes {
209            "Deduplicating lean-ctx rules"
210        } else {
211            "Dedup plan (dry-run — pass --apply to execute)"
212        },
213        project.display()
214    );
215
216    let mut fixable = 0usize;
217    for action in &actions {
218        match action {
219            Action::DeleteFile { path, reason } => {
220                fixable += 1;
221                if apply_changes {
222                    println!("  {}", apply(action));
223                } else {
224                    println!("  delete    {}\n            ({reason})", path.display());
225                }
226            }
227            Action::StripBlocks { path, reason } => {
228                fixable += 1;
229                if apply_changes {
230                    println!("  {}", apply(action));
231                } else {
232                    println!("  strip     {}\n            ({reason})", path.display());
233                }
234            }
235            Action::Report { .. } => println!("  {}", apply(action)),
236        }
237    }
238
239    if !apply_changes && fixable > 0 {
240        println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
241    }
242    if apply_changes && fixable > 0 {
243        println!("\nDone. Verify with `lean-ctx doctor overhead`.");
244    }
245    0
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn owned_mdc() -> String {
253        format!(
254            "---\ndescription: lean-ctx\n---\n{}\n<!-- lean-ctx-rules-v9 -->\nbody\n",
255            crate::rules_inject::RULES_MARKER
256        )
257    }
258
259    #[test]
260    fn detects_owned_dedicated_files() {
261        assert!(is_owned_rules_file(&owned_mdc()));
262        assert!(is_owned_rules_file(&format!(
263            "{}\n<!-- lean-ctx-rules-v11 -->\nbody\n",
264            crate::rules_inject::RULES_MARKER
265        )));
266        // User file mentioning lean-ctx is NOT owned.
267        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
268        // Marker buried mid-file (user prepended content) is NOT owned.
269        assert!(!is_owned_rules_file(&format!(
270            "# my header\n{}\n<!-- lean-ctx-rules-v11 -->\n",
271            crate::rules_inject::RULES_MARKER
272        )));
273    }
274
275    #[test]
276    fn strip_removes_rules_and_compression_blocks() {
277        let content = "user line\n<!-- lean-ctx -->\nour rules\n<!-- /lean-ctx -->\nmore user\n<!-- lean-ctx-compression -->\nstyle\n<!-- /lean-ctx-compression -->\n";
278        let out = strip_lean_ctx_blocks(content);
279        assert!(out.contains("user line"));
280        assert!(out.contains("more user"));
281        assert!(!out.contains("our rules"));
282        assert!(!out.contains("style"));
283        assert!(!out.contains("lean-ctx-compression"));
284    }
285
286    #[test]
287    fn strip_of_pure_block_file_yields_empty() {
288        let content = "<!-- lean-ctx -->\nonly ours\n<!-- /lean-ctx -->\n";
289        assert_eq!(strip_lean_ctx_blocks(content), "");
290    }
291
292    #[test]
293    fn plan_deletes_project_and_parent_owned_files_only() {
294        let tmp = tempfile::tempdir().unwrap();
295        let home = tmp.path();
296        let parent = home.join("projects");
297        let project = parent.join("app");
298
299        // Canonical global mdc (must never be planned for deletion).
300        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
301        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
302        // Stale project + parent copies.
303        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
304        std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
305        std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
306        std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
307        // User-customized file — must only be reported.
308        std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
309        std::fs::write(
310            project.join(".claude/rules/lean-ctx.md"),
311            "# customized by user\nkeep me\n",
312        )
313        .unwrap();
314
315        let actions = plan(home, &project);
316        let deletes: Vec<&PathBuf> = actions
317            .iter()
318            .filter_map(|a| match a {
319                Action::DeleteFile { path, .. } => Some(path),
320                _ => None,
321            })
322            .collect();
323        assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
324        assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
325        assert!(actions.iter().any(|a| matches!(
326            a,
327            Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
328        )));
329    }
330
331    #[test]
332    fn plan_strips_cursorrules_only_with_canonical_mdc() {
333        let tmp = tempfile::tempdir().unwrap();
334        let home = tmp.path();
335        let project = home.join("app");
336        std::fs::create_dir_all(&project).unwrap();
337        let rules = "<!-- lean-ctx -->\npointer\n<!-- /lean-ctx -->\n";
338        std::fs::write(project.join(".cursorrules"), rules).unwrap();
339
340        // Without the global mdc, .cursorrules is the carrier — report only.
341        let actions = plan(home, &project);
342        assert!(
343            !actions
344                .iter()
345                .any(|a| matches!(a, Action::StripBlocks { .. })),
346            "{actions:?}"
347        );
348
349        // With the canonical mdc, the block is a duplicate — strip.
350        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
351        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
352        let actions = plan(home, &project);
353        assert!(
354            actions.iter().any(|a| matches!(
355                a,
356                Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
357            )),
358            "{actions:?}"
359        );
360    }
361
362    #[test]
363    fn apply_strip_writes_backup_and_keeps_user_content() {
364        let tmp = tempfile::tempdir().unwrap();
365        let path = tmp.path().join(".cursorrules");
366        std::fs::write(
367            &path,
368            "my custom rule\n<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n",
369        )
370        .unwrap();
371
372        let msg = apply(&Action::StripBlocks {
373            path: path.clone(),
374            reason: String::new(),
375        });
376        assert!(msg.starts_with("stripped"), "{msg}");
377        let after = std::fs::read_to_string(&path).unwrap();
378        assert_eq!(after, "my custom rule\n");
379        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
380        assert!(bak.contains("ours"));
381    }
382
383    #[test]
384    fn apply_strip_deletes_file_that_was_only_ours() {
385        let tmp = tempfile::tempdir().unwrap();
386        let path = tmp.path().join(".cursorrules");
387        std::fs::write(&path, "<!-- lean-ctx -->\nours\n<!-- /lean-ctx -->\n").unwrap();
388
389        let msg = apply(&Action::StripBlocks {
390            path: path.clone(),
391            reason: String::new(),
392        });
393        assert!(msg.starts_with("deleted"), "{msg}");
394        assert!(!path.exists());
395        assert!(path.with_extension("bak").exists());
396    }
397}