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
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
61/// Strips every lean-ctx-marked block (rules + compression) from `content`.
62pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
63    let mut out = content.to_string();
64    // Repeat until stable — a file can contain both block kinds (and in
65    // pathological cases several of the same kind).
66    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
91/// Strips only the compression block, keeping the `<!-- lean-ctx -->` pointer
92/// and every line of user content. Used to thin a shared AGENTS.md once each
93/// reader is covered by its own canonical carrier (#684).
94pub(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
111/// Dedicated lean-ctx rule files that may linger in a project / parent chain
112/// from older versions. Canonical copies live under `home` (global targets).
113fn 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
124/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
125pub(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    // 1. Owned dedicated duplicates in the project + parent chain.
130    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    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
158    // global mdc exists (Cursor loads both files every session).
159    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    // 4. Shared project AGENTS.md (#684): Cursor, Codex and other agents all
180    // auto-load it, so a compression block here duplicates each reader's own
181    // canonical carrier. Strip only the compression block (the pointer stays)
182    // once every reader present on this machine is covered elsewhere.
183    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
205/// Executes one action. Returns a human-readable result line.
206fn 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
261/// CLI entry: `lean-ctx rules dedup [--apply]`.
262pub 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        // User file mentioning lean-ctx is NOT owned.
344        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
345        // Marker buried mid-file (user prepended content) is NOT owned.
346        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        // Canonical global mdc (must never be planned for deletion).
379        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        // Stale project + parent copies.
382        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        // User-customized file — must only be reported.
387        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        // Without the global mdc, .cursorrules is the carrier — report only.
420        let actions = plan(home, &project);
421        assert!(
422            !actions
423                .iter()
424                .any(|a| matches!(a, Action::StripBlocks { .. })),
425            "{actions:?}"
426        );
427
428        // With the canonical mdc, the block is a duplicate — strip.
429        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        // Isolate codex resolution to this empty home (no codex present).
483        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
484
485        // Canonical global mdc carries the compression block → cursor covered.
486        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        // Project AGENTS.md still carries pointer + compression.
497        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        // No global mdc → cursor not covered → AGENTS.md must stay the carrier.
526        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        // Reported instead, so the user understands why it was left alone.
542        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}