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
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/// One planned dedup action.
29#[derive(Debug, PartialEq, Eq)]
30pub(crate) enum Action {
31    /// Delete a wholly lean-ctx-owned duplicate rules file.
32    DeleteFile { path: PathBuf, reason: String },
33    /// Strip lean-ctx-marked blocks from a shared file (keeps user content).
34    StripBlocks { path: PathBuf, reason: String },
35    /// Strip only the compression block from a shared file, keeping the
36    /// `<!-- lean-ctx -->` pointer and all user content (#684 — thins a shared
37    /// AGENTS.md whose readers are all covered by their own canonical carrier).
38    StripCompression { path: PathBuf, reason: String },
39    /// Informational only — lean-ctx guidance in user-maintained content.
40    Report { path: PathBuf, note: String },
41}
42
43/// A file is "lean-ctx-owned" when lean-ctx wrote the whole file: dedicated
44/// rule files start with the canonical header and carry a rules-version
45/// marker, project LEAN-CTX.md carries its ownership marker.
46fn 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        // CursorMdc has YAML frontmatter before the header.
51        || (content.trim_start().starts_with("---")
52            && content.contains(crate::core::rules_canonical::START_MARK));
53    starts_with_header && content.contains("<!-- version:")
54}
55
56/// Line-based marker pair detection (GL #1158): prose mentions of a marker
57/// must not count as a block.
58fn has_marker_pair(content: &str, start: &str, end: &str) -> bool {
59    crate::marked_block::contains_marker_line(content, start)
60        && crate::marked_block::contains_marker_line(content, end)
61}
62
63fn has_marked_block(content: &str) -> bool {
64    has_marker_pair(content, BLOCK_START, BLOCK_END)
65        || has_marker_pair(content, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END)
66}
67
68/// Strips every lean-ctx-marked block (rules + compression) from `content`.
69pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
70    let mut out = content.to_string();
71    // Repeat until stable — a file can contain both block kinds (and in
72    // pathological cases several of the same kind).
73    loop {
74        let next = if has_marker_pair(&out, BLOCK_START, BLOCK_END) {
75            crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
76        } else if has_marker_pair(&out, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END) {
77            crate::marked_block::remove_content(
78                &out,
79                COMPRESSION_BLOCK_START,
80                COMPRESSION_BLOCK_END,
81            )
82        } else {
83            break;
84        };
85        if next == out {
86            break;
87        }
88        out = next;
89    }
90    let trimmed = out.trim_end();
91    if trimmed.is_empty() {
92        String::new()
93    } else {
94        format!("{trimmed}\n")
95    }
96}
97
98/// Strips only the compression block, keeping the `<!-- lean-ctx -->` pointer
99/// and every line of user content. Used to thin a shared AGENTS.md once each
100/// reader is covered by its own canonical carrier (#684).
101pub(crate) fn strip_compression_block(content: &str) -> String {
102    if !(content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END)) {
103        return content.to_string();
104    }
105    let stripped = crate::marked_block::remove_content(
106        content,
107        COMPRESSION_BLOCK_START,
108        COMPRESSION_BLOCK_END,
109    );
110    let trimmed = stripped.trim_end();
111    if trimmed.is_empty() {
112        String::new()
113    } else {
114        format!("{trimmed}\n")
115    }
116}
117
118/// Dedicated lean-ctx rule files that may linger in a project / parent chain
119/// from older versions. Canonical copies live under `home` (global targets).
120fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
121    vec![
122        dir.join(".cursor/rules/lean-ctx.mdc"),
123        dir.join(".claude/rules/lean-ctx.md"),
124        dir.join(".codebuddy/rules/lean-ctx.md"),
125        dir.join(".windsurf/rules/lean-ctx.md"),
126        dir.join(".cline/rules/lean-ctx.md"),
127        dir.join(".roo/rules/lean-ctx.md"),
128    ]
129}
130
131/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
132pub(crate) fn plan(home: &Path, project: &Path) -> Vec<Action> {
133    let mut actions = Vec::new();
134    let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
135
136    // 1. Owned dedicated duplicates in the project + parent chain.
137    let mut dir = Some(project.to_path_buf());
138    while let Some(d) = dir {
139        if d == *home {
140            break;
141        }
142        for candidate in project_owned_candidates(&d) {
143            if candidate == canonical_cursor_mdc {
144                continue;
145            }
146            let Ok(content) = std::fs::read_to_string(&candidate) else {
147                continue;
148            };
149            if is_owned_rules_file(&content) {
150                actions.push(Action::DeleteFile {
151                    path: candidate,
152                    reason: "lean-ctx-owned duplicate of the global rules file".into(),
153                });
154            } else if !content.trim().is_empty() {
155                actions.push(Action::Report {
156                    path: candidate,
157                    note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
158                });
159            }
160        }
161        dir = d.parent().map(Path::to_path_buf);
162    }
163
164    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
165    // global mdc exists (Cursor loads both files every session).
166    let cursorrules = project.join(".cursorrules");
167    if let Ok(content) = std::fs::read_to_string(&cursorrules) {
168        if canonical_cursor_mdc.exists() && has_marked_block(&content) {
169            actions.push(Action::StripBlocks {
170                path: cursorrules,
171                reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
172            });
173        } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
174            actions.push(Action::Report {
175                path: cursorrules,
176                note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
177            });
178        } else if content.contains("lean-ctx") && !has_marked_block(&content) {
179            actions.push(Action::Report {
180                path: cursorrules,
181                note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
182            });
183        }
184    }
185
186    // 4. Shared project AGENTS.md (#684): Cursor, Codex and other agents all
187    // auto-load it, so a compression block here duplicates each reader's own
188    // canonical carrier. Strip only the compression block (the pointer stays)
189    // once every reader present on this machine is covered elsewhere.
190    let agents_md = project.join("AGENTS.md");
191    if let Ok(content) = std::fs::read_to_string(&agents_md) {
192        let has_compression =
193            content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END);
194        if has_compression {
195            if crate::core::rules_channel::agents_md_can_thin(home) {
196                actions.push(Action::StripCompression {
197                    path: agents_md,
198                    reason: "every AGENTS.md reader already loads the compression block from its own canonical file".into(),
199                });
200            } else {
201                actions.push(Action::Report {
202                    path: agents_md,
203                    note: "an AGENTS.md reader has no other compression source — AGENTS.md stays the carrier".into(),
204                });
205            }
206        }
207    }
208
209    actions
210}
211
212/// Executes one action. Returns a human-readable result line.
213fn apply(action: &Action) -> String {
214    match action {
215        Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
216            Ok(()) => format!("deleted   {}", path.display()),
217            Err(e) => format!("FAILED    {} ({e})", path.display()),
218        },
219        Action::StripBlocks { path, .. } => {
220            let Ok(content) = std::fs::read_to_string(path) else {
221                return format!("FAILED    {} (unreadable)", path.display());
222            };
223            let stripped = strip_lean_ctx_blocks(&content);
224            if stripped == content {
225                return format!("unchanged {}", path.display());
226            }
227            let bak = path.with_extension("bak");
228            if let Err(e) = std::fs::write(&bak, &content) {
229                return format!("FAILED    {} (backup: {e})", path.display());
230            }
231            if stripped.is_empty() {
232                match std::fs::remove_file(path) {
233                    Ok(()) => format!(
234                        "deleted   {} (only lean-ctx blocks, backup: {})",
235                        path.display(),
236                        bak.display()
237                    ),
238                    Err(e) => format!("FAILED    {} ({e})", path.display()),
239                }
240            } else {
241                match std::fs::write(path, &stripped) {
242                    Ok(()) => format!("stripped  {} (backup: {})", path.display(), bak.display()),
243                    Err(e) => format!("FAILED    {} ({e})", path.display()),
244                }
245            }
246        }
247        Action::StripCompression { path, .. } => {
248            let Ok(content) = std::fs::read_to_string(path) else {
249                return format!("FAILED    {} (unreadable)", path.display());
250            };
251            let stripped = strip_compression_block(&content);
252            if stripped == content {
253                return format!("unchanged {}", path.display());
254            }
255            let bak = path.with_extension("bak");
256            if let Err(e) = std::fs::write(&bak, &content) {
257                return format!("FAILED    {} (backup: {e})", path.display());
258            }
259            match std::fs::write(path, &stripped) {
260                Ok(()) => format!("thinned   {} (backup: {})", path.display(), bak.display()),
261                Err(e) => format!("FAILED    {} ({e})", path.display()),
262            }
263        }
264        Action::Report { path, note } => format!("info      {} — {note}", path.display()),
265    }
266}
267
268/// Non-interactive auto-heal used by `lean-ctx setup` and `doctor --fix`
269/// (#578): applies every *fixable* dedup action (owned duplicates deleted,
270/// marked blocks stripped with `.bak` backups) and returns one result line per
271/// action. `Report`-only findings (user-maintained content) are never touched.
272pub fn auto_apply(home: &Path, project: &Path) -> Vec<String> {
273    plan(home, project)
274        .iter()
275        .filter(|a| !matches!(a, Action::Report { .. }))
276        .map(apply)
277        .collect()
278}
279
280/// CLI entry: `lean-ctx rules dedup [--apply]`.
281pub fn run(apply_changes: bool) -> i32 {
282    let Some(home) = dirs::home_dir() else {
283        eprintln!("Error: could not determine home directory");
284        return 1;
285    };
286    let project = std::env::current_dir().unwrap_or_else(|_| home.clone());
287    let actions = plan(&home, &project);
288
289    if actions.is_empty() {
290        println!("No duplicated lean-ctx rules found — every client pays once.");
291        return 0;
292    }
293
294    println!(
295        "{} (project: {})\n",
296        if apply_changes {
297            "Deduplicating lean-ctx rules"
298        } else {
299            "Dedup plan (dry-run — pass --apply to execute)"
300        },
301        project.display()
302    );
303
304    let mut fixable = 0usize;
305    for action in &actions {
306        match action {
307            Action::DeleteFile { path, reason } => {
308                fixable += 1;
309                if apply_changes {
310                    println!("  {}", apply(action));
311                } else {
312                    println!("  delete    {}\n            ({reason})", path.display());
313                }
314            }
315            Action::StripBlocks { path, reason } => {
316                fixable += 1;
317                if apply_changes {
318                    println!("  {}", apply(action));
319                } else {
320                    println!("  strip     {}\n            ({reason})", path.display());
321                }
322            }
323            Action::StripCompression { path, reason } => {
324                fixable += 1;
325                if apply_changes {
326                    println!("  {}", apply(action));
327                } else {
328                    println!("  thin      {}\n            ({reason})", path.display());
329                }
330            }
331            Action::Report { .. } => println!("  {}", apply(action)),
332        }
333    }
334
335    if !apply_changes && fixable > 0 {
336        println!("\nRun `lean-ctx rules dedup --apply` to fix {fixable} duplicate(s).");
337    }
338    if apply_changes && fixable > 0 {
339        println!("\nDone. Verify with `lean-ctx doctor overhead`.");
340    }
341    0
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn owned_mdc() -> String {
349        format!(
350            "---\ndescription: lean-ctx\n---\n{}\n<!-- version: 1 -->\nbody\n",
351            crate::core::rules_canonical::START_MARK
352        )
353    }
354
355    #[test]
356    fn detects_owned_dedicated_files() {
357        assert!(is_owned_rules_file(&owned_mdc()));
358        assert!(is_owned_rules_file(&format!(
359            "{}\n<!-- version: 1 -->\nbody\n",
360            crate::core::rules_canonical::START_MARK
361        )));
362        // User file mentioning lean-ctx is NOT owned.
363        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
364        // Marker buried mid-file (user prepended content) is NOT owned.
365        assert!(!is_owned_rules_file(&format!(
366            "# my header\n{}\n<!-- version: 1 -->\n",
367            crate::core::rules_canonical::START_MARK
368        )));
369    }
370
371    #[test]
372    fn strip_removes_rules_and_compression_blocks() {
373        let content = format!(
374            "user line\n{BLOCK_START}\nour rules\n{BLOCK_END}\nmore user\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
375        );
376        let out = strip_lean_ctx_blocks(&content);
377        assert!(out.contains("user line"));
378        assert!(out.contains("more user"));
379        assert!(!out.contains("our rules"));
380        assert!(!out.contains("style"));
381        assert!(!out.contains("lean-ctx-compression"));
382    }
383
384    #[test]
385    fn strip_of_pure_block_file_yields_empty() {
386        let content = format!("{BLOCK_START}\nonly ours\n{BLOCK_END}\n");
387        assert_eq!(strip_lean_ctx_blocks(&content), "");
388    }
389
390    #[test]
391    fn plan_deletes_project_and_parent_owned_files_only() {
392        let tmp = tempfile::tempdir().unwrap();
393        let home = tmp.path();
394        let parent = home.join("projects");
395        let project = parent.join("app");
396
397        // Canonical global mdc (must never be planned for deletion).
398        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
399        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
400        // Stale project + parent copies.
401        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
402        std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
403        std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
404        std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
405        // User-customized file — must only be reported.
406        std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
407        std::fs::write(
408            project.join(".claude/rules/lean-ctx.md"),
409            "# customized by user\nkeep me\n",
410        )
411        .unwrap();
412
413        let actions = plan(home, &project);
414        let deletes: Vec<&PathBuf> = actions
415            .iter()
416            .filter_map(|a| match a {
417                Action::DeleteFile { path, .. } => Some(path),
418                _ => None,
419            })
420            .collect();
421        assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
422        assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
423        assert!(actions.iter().any(|a| matches!(
424            a,
425            Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
426        )));
427    }
428
429    #[test]
430    fn plan_strips_cursorrules_only_with_canonical_mdc() {
431        let tmp = tempfile::tempdir().unwrap();
432        let home = tmp.path();
433        let project = home.join("app");
434        std::fs::create_dir_all(&project).unwrap();
435        let rules = format!("{BLOCK_START}\npointer\n{BLOCK_END}\n");
436        std::fs::write(project.join(".cursorrules"), rules).unwrap();
437
438        // Without the global mdc, .cursorrules is the carrier — report only.
439        let actions = plan(home, &project);
440        assert!(
441            !actions
442                .iter()
443                .any(|a| matches!(a, Action::StripBlocks { .. })),
444            "{actions:?}"
445        );
446
447        // With the canonical mdc, the block is a duplicate — strip.
448        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
449        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
450        let actions = plan(home, &project);
451        assert!(
452            actions.iter().any(|a| matches!(
453                a,
454                Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
455            )),
456            "{actions:?}"
457        );
458    }
459
460    #[test]
461    fn apply_strip_writes_backup_and_keeps_user_content() {
462        let tmp = tempfile::tempdir().unwrap();
463        let path = tmp.path().join(".cursorrules");
464        std::fs::write(
465            &path,
466            format!("my custom rule\n{BLOCK_START}\nours\n{BLOCK_END}\n"),
467        )
468        .unwrap();
469
470        let msg = apply(&Action::StripBlocks {
471            path: path.clone(),
472            reason: String::new(),
473        });
474        assert!(msg.starts_with("stripped"), "{msg}");
475        let after = std::fs::read_to_string(&path).unwrap();
476        assert_eq!(after, "my custom rule\n");
477        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
478        assert!(bak.contains("ours"));
479    }
480
481    #[test]
482    fn strip_compression_keeps_pointer_and_user_content() {
483        let content = format!(
484            "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n",
485        );
486        let out = strip_compression_block(&content);
487        assert!(out.contains("# Agent Instructions"));
488        assert!(out.contains(BLOCK_START));
489        assert!(out.contains("pointer"));
490        assert!(!out.contains("lean-ctx-compression"));
491        assert!(!out.contains("OUTPUT STYLE"));
492    }
493
494    #[test]
495    fn plan_thins_agents_md_when_cursor_covered_and_no_codex() {
496        let _guard = crate::core::data_dir::test_env_lock();
497        let tmp = tempfile::tempdir().unwrap();
498        let home = tmp.path();
499        let project = home.join("app");
500        std::fs::create_dir_all(&project).unwrap();
501        // Isolate codex resolution to this empty home (no codex present).
502        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
503
504        // Canonical global mdc carries the compression block → cursor covered.
505        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
506        std::fs::write(
507            home.join(".cursor/rules/lean-ctx.mdc"),
508            format!(
509                "{}\n<!-- version: 1 -->\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
510                crate::core::rules_canonical::START_MARK
511            ),
512        )
513        .unwrap();
514
515        // Project AGENTS.md still carries pointer + compression.
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_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
520            ),
521        )
522        .unwrap();
523
524        let actions = plan(home, &project);
525        assert!(
526            actions.iter().any(|a| matches!(
527                a,
528                Action::StripCompression { path, .. } if path.ends_with("AGENTS.md")
529            )),
530            "{actions:?}"
531        );
532        crate::test_env::remove_var("CODEX_HOME");
533    }
534
535    #[test]
536    fn plan_keeps_agents_md_carrier_when_cursor_uncovered() {
537        let _guard = crate::core::data_dir::test_env_lock();
538        let tmp = tempfile::tempdir().unwrap();
539        let home = tmp.path();
540        let project = home.join("app");
541        std::fs::create_dir_all(&project).unwrap();
542        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
543
544        // No global mdc → cursor not covered → AGENTS.md must stay the carrier.
545        std::fs::write(
546            project.join("AGENTS.md"),
547            format!(
548                "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
549            ),
550        )
551        .unwrap();
552
553        let actions = plan(home, &project);
554        assert!(
555            !actions
556                .iter()
557                .any(|a| matches!(a, Action::StripCompression { .. })),
558            "{actions:?}"
559        );
560        // Reported instead, so the user understands why it was left alone.
561        assert!(
562            actions.iter().any(|a| matches!(
563                a,
564                Action::Report { path, .. } if path.ends_with("AGENTS.md")
565            )),
566            "{actions:?}"
567        );
568        crate::test_env::remove_var("CODEX_HOME");
569    }
570
571    #[test]
572    fn apply_strip_compression_thins_and_backs_up() {
573        let tmp = tempfile::tempdir().unwrap();
574        let path = tmp.path().join("AGENTS.md");
575        std::fs::write(
576            &path,
577            format!(
578                "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
579            ),
580        )
581        .unwrap();
582
583        let msg = apply(&Action::StripCompression {
584            path: path.clone(),
585            reason: String::new(),
586        });
587        assert!(msg.starts_with("thinned"), "{msg}");
588        let after = std::fs::read_to_string(&path).unwrap();
589        assert!(after.contains(BLOCK_START));
590        assert!(!after.contains("lean-ctx-compression"));
591        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
592        assert!(bak.contains("lean-ctx-compression"));
593    }
594
595    #[test]
596    fn apply_strip_deletes_file_that_was_only_ours() {
597        let tmp = tempfile::tempdir().unwrap();
598        let path = tmp.path().join(".cursorrules");
599        std::fs::write(&path, format!("{BLOCK_START}\nours\n{BLOCK_END}\n")).unwrap();
600
601        let msg = apply(&Action::StripBlocks {
602            path: path.clone(),
603            reason: String::new(),
604        });
605        assert!(msg.starts_with("deleted"), "{msg}");
606        assert!(!path.exists());
607        assert!(path.with_extension("bak").exists());
608    }
609}