Skip to main content

lean_ctx/core/
rules_dedup.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Collapse duplicated lean-ctx guidance (#578): the planner and fixer behind
3//! `lean-ctx rules dedup`, `setup`, `doctor --fix` and the hook refresh.
4//!
5//! A client should pay for lean-ctx rules exactly once per session. Older
6//! installs (and parent-directory walks in monorepos) left full rule copies
7//! in several auto-loaded files; `doctor overhead` detects the duplication,
8//! this module repairs it:
9//!
10//!  1. lean-ctx-OWNED dedicated rule files outside the canonical global
11//!     location (project/parent `.cursor/rules/lean-ctx.mdc`,
12//!     `.claude/rules/lean-ctx.md`, …) → deleted.
13//!  2. `.cursorrules` lean-ctx blocks → removed when the canonical global
14//!     Cursor mdc exists (Cursor auto-loads both; pointer lives in AGENTS.md).
15//!  3. Stale compression blocks in `.cursorrules` → removed under the same
16//!     condition (the global mdc carries the block).
17//!  4. Compression block in a shared `AGENTS.md` (#684) → removed (pointer
18//!     kept) once every AGENTS.md reader is covered by its own canonical
19//!     carrier; otherwise reported and left as the carrier.
20//!
21//! Only lean-ctx-owned files and lean-ctx-marked blocks are ever touched.
22//! Unmarked user content is reported, never modified. Default is a dry-run
23//! report; `--apply` executes with `.bak` backups for partial edits.
24
25use std::path::{Path, PathBuf};
26
27use crate::core::rules_canonical::{END_MARK as BLOCK_END, START_MARK as BLOCK_START};
28use crate::core::rules_channel::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
29
30/// One planned dedup action.
31#[derive(Debug, PartialEq, Eq)]
32pub 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::core::rules_canonical::START_MARK)
52        // CursorMdc has YAML frontmatter before the header.
53        || (content.trim_start().starts_with("---")
54            && content.contains(crate::core::rules_canonical::START_MARK));
55    if starts_with_header && content.contains("<!-- version:") {
56        return true;
57    }
58    is_owned_legacy_rules_file(content)
59}
60
61/// #1289: older installs wrote dedicated rule files with versioned markers
62/// (`<!-- lean-ctx-rules-v9 -->`) and no `<!-- version: N -->` line. Those
63/// files never matched the canonical ownership test above, so stale guidance
64/// (e.g. "Write, Delete, Glob → use normally" contradicting the shadow-mode
65/// Glob deny) survived every update and dedup run. A legacy file counts as
66/// owned when lean-ctx plausibly wrote all of it: a legacy rules marker sits
67/// in the first few lines and nothing but whitespace follows the last closing
68/// lean-ctx marker — user-extended files keep getting the Report instead.
69fn is_owned_legacy_rules_file(content: &str) -> bool {
70    let mut lines = content.trim_start().lines();
71    // Legacy files start with a `# lean-ctx …` heading (marker on the next
72    // line) or with the marker itself — a marker merely mentioned mid-text
73    // does not make a file lean-ctx-owned.
74    let first = lines.next().unwrap_or_default().trim();
75    let marker_line = if first.starts_with("# lean-ctx") {
76        lines.next().unwrap_or_default().trim()
77    } else {
78        first
79    };
80    if !marker_line.starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX) {
81        return false;
82    }
83    let Some(idx) = content.rfind("<!-- /lean-ctx") else {
84        return false;
85    };
86    let Some(end) = content[idx..].find("-->") else {
87        return false;
88    };
89    content[idx + end + 3..].trim().is_empty()
90}
91
92/// #1289/A3: the lean-ctx-written region of a legacy versioned rules file
93/// (`# lean-ctx …` heading + `<!-- lean-ctx-rules-vN -->` … `<!-- /lean-ctx -->`)
94/// that a user (or agent) extended below the closing marker. Returns the text
95/// after the region — the user's part — or `None` when the file does not start
96/// with such a region. Only the leading lean-ctx region is ever removed.
97fn strip_legacy_owned_prefix(content: &str) -> Option<String> {
98    let trimmed = content.trim_start();
99    let mut lines = trimmed.lines();
100    let first = lines.next()?.trim();
101    let marker_line = if first.starts_with("# lean-ctx") {
102        lines.next().unwrap_or_default().trim()
103    } else {
104        first
105    };
106    if !marker_line.starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX)
107        || marker_line.starts_with(BLOCK_START)
108    {
109        return None;
110    }
111    let mut offset = 0usize;
112    for line in trimmed.split_inclusive('\n') {
113        offset += line.len();
114        let t = line.trim();
115        if t == crate::core::rules_canonical::AGENTS_BLOCK_END
116            || t.starts_with("<!-- /lean-ctx-rules")
117        {
118            return Some(trimmed[offset..].to_string());
119        }
120    }
121    None
122}
123
124/// Line-based marker pair detection (GL #1158): prose mentions of a marker
125/// must not count as a block.
126fn has_marker_pair(content: &str, start: &str, end: &str) -> bool {
127    crate::marked_block::contains_marker_line(content, start)
128        && crate::marked_block::contains_marker_line(content, end)
129}
130
131fn has_marked_block(content: &str) -> bool {
132    has_marker_pair(content, BLOCK_START, BLOCK_END)
133        || has_marker_pair(content, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END)
134}
135
136/// Strips every lean-ctx-marked block (rules + compression) from `content`.
137pub(crate) fn strip_lean_ctx_blocks(content: &str) -> String {
138    let mut out = strip_legacy_owned_prefix(content).unwrap_or_else(|| content.to_string());
139    // Repeat until stable — a file can contain both block kinds (and in
140    // pathological cases several of the same kind).
141    loop {
142        let next = if has_marker_pair(&out, BLOCK_START, BLOCK_END) {
143            crate::marked_block::remove_content(&out, BLOCK_START, BLOCK_END)
144        } else if has_marker_pair(&out, COMPRESSION_BLOCK_START, COMPRESSION_BLOCK_END) {
145            crate::marked_block::remove_content(
146                &out,
147                COMPRESSION_BLOCK_START,
148                COMPRESSION_BLOCK_END,
149            )
150        } else {
151            break;
152        };
153        if next == out {
154            break;
155        }
156        out = next;
157    }
158    let trimmed = out.trim();
159    if trimmed.is_empty() {
160        String::new()
161    } else {
162        format!("{}\n", out.trim_end().trim_start_matches('\n'))
163    }
164}
165
166/// A file that lean-ctx wrote whole: owned by [`is_owned_rules_file`], or one
167/// that opens with a lean-ctx rules marker and never closes it (a whole-file
168/// block, as `rules_overhead::lean_ctx_block_tokens` counts it) — nothing in
169/// it lies outside the lean-ctx region.
170fn is_whole_lean_ctx_file(content: &str) -> bool {
171    if is_owned_rules_file(content) {
172        return true;
173    }
174    content
175        .trim_start()
176        .starts_with(crate::core::rules_canonical::RULES_MARKER_PREFIX)
177        && !content
178            .lines()
179            .any(|l| l.trim_start().starts_with("<!-- /lean-ctx"))
180}
181
182/// True when `content` holds lean-ctx-written text that [`strip_lean_ctx_blocks`]
183/// can remove without touching anything outside the markers.
184fn has_strippable_region(content: &str) -> bool {
185    has_marked_block(content) || strip_legacy_owned_prefix(content).is_some()
186}
187
188/// Retire a lean-ctx rules file that duplicates a host's single rules channel
189/// (A3): a wholly lean-ctx-owned file is deleted; a file a user extended keeps
190/// every line outside the lean-ctx markers (backup at `<file>.bak`). Returns a
191/// result line when something changed. Shared by `setup`/`init` writers and
192/// `rules dedup`, so no path ever deletes user text.
193pub fn retire_rules_file(path: &Path) -> Option<String> {
194    let content = std::fs::read_to_string(path).ok()?;
195    let action = if is_whole_lean_ctx_file(&content) {
196        Action::DeleteFile {
197            path: path.to_path_buf(),
198            reason: String::new(),
199        }
200    } else if has_strippable_region(&content) {
201        Action::StripBlocks {
202            path: path.to_path_buf(),
203            reason: String::new(),
204        }
205    } else {
206        return None;
207    };
208    let line = apply(&action);
209    (!line.starts_with("unchanged")).then_some(line)
210}
211
212/// Strips only the compression block, keeping the `<!-- lean-ctx -->` pointer
213/// and every line of user content. Used to thin a shared AGENTS.md once each
214/// reader is covered by its own canonical carrier (#684).
215pub(crate) fn strip_compression_block(content: &str) -> String {
216    if !(content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END)) {
217        return content.to_string();
218    }
219    let stripped = crate::marked_block::remove_content(
220        content,
221        COMPRESSION_BLOCK_START,
222        COMPRESSION_BLOCK_END,
223    );
224    let trimmed = stripped.trim_end();
225    if trimmed.is_empty() {
226        String::new()
227    } else {
228        format!("{trimmed}\n")
229    }
230}
231
232/// Dedicated lean-ctx rule files that may linger in a project / parent chain
233/// from older versions. Canonical copies live under `home` (global targets).
234fn project_owned_candidates(dir: &Path) -> Vec<PathBuf> {
235    vec![
236        dir.join(".cursor/rules/lean-ctx.mdc"),
237        dir.join(".claude/rules/lean-ctx.md"),
238        dir.join(".codebuddy/rules/lean-ctx.md"),
239        dir.join(".windsurf/rules/lean-ctx.md"),
240        dir.join(".cline/rules/lean-ctx.md"),
241        dir.join(".roo/rules/lean-ctx.md"),
242    ]
243}
244
245/// Plans one duplicate rules file: owned → delete, user-extended with a
246/// lean-ctx region → strip that region only, anything else → report.
247fn plan_duplicate_file(actions: &mut Vec<Action>, candidate: PathBuf, reason: &str) {
248    let Ok(content) = std::fs::read_to_string(&candidate) else {
249        return;
250    };
251    if is_whole_lean_ctx_file(&content) {
252        actions.push(Action::DeleteFile {
253            path: candidate,
254            reason: reason.into(),
255        });
256    } else if has_strippable_region(&content) {
257        actions.push(Action::StripBlocks {
258            path: candidate,
259            reason: format!("{reason}; text outside the lean-ctx markers is kept"),
260        });
261    } else if !content.trim().is_empty() {
262        actions.push(Action::Report {
263            path: candidate,
264            note: "contains custom edits — not lean-ctx-owned, left untouched".into(),
265        });
266    }
267}
268
269/// Global lean-ctx rule files that duplicate a host's single channel: Claude
270/// Code and CodeBuddy carry their rules in the `CLAUDE.md` / `CODEBUDDY.md`
271/// block and auto-load every `rules/*.md` on top (GL #555, A3).
272fn global_duplicate_candidates(home: &Path) -> Vec<PathBuf> {
273    vec![
274        crate::core::editor_registry::claude_rules_dir(home).join("lean-ctx.md"),
275        home.join(".codebuddy/rules/lean-ctx.md"),
276    ]
277}
278
279/// Plans the dedup for `project` (walking parents up to, excluding, `home`).
280pub fn plan(home: &Path, project: &Path) -> Vec<Action> {
281    let mut actions = Vec::new();
282    let canonical_cursor_mdc = home.join(".cursor/rules/lean-ctx.mdc");
283
284    // 0. Global always-loaded duplicates of the CLAUDE.md / CODEBUDDY.md block.
285    for candidate in global_duplicate_candidates(home) {
286        plan_duplicate_file(
287            &mut actions,
288            candidate,
289            "always-loaded duplicate of the host's CLAUDE.md/CODEBUDDY.md rules block",
290        );
291    }
292
293    // 1. Owned dedicated duplicates in the project + parent chain.
294    let mut dir = Some(project.to_path_buf());
295    while let Some(d) = dir {
296        if d == *home {
297            break;
298        }
299        for candidate in project_owned_candidates(&d) {
300            if candidate == canonical_cursor_mdc {
301                continue;
302            }
303            plan_duplicate_file(
304                &mut actions,
305                candidate,
306                "lean-ctx-owned duplicate of the global rules file",
307            );
308        }
309        // The parent walk stays inside `home`: a project outside it (a CI
310        // checkout, a test run with a temp HOME) must not reach directories
311        // that belong to a different home or user — only the project itself
312        // is checked there.
313        if !d.starts_with(home) {
314            break;
315        }
316        dir = d.parent().map(Path::to_path_buf);
317    }
318
319    // 2./3. `.cursorrules`: marked blocks are redundant once the canonical
320    // global mdc exists (Cursor loads both files every session).
321    let cursorrules = project.join(".cursorrules");
322    if let Ok(content) = std::fs::read_to_string(&cursorrules) {
323        if canonical_cursor_mdc.exists() && has_marked_block(&content) {
324            actions.push(Action::StripBlocks {
325                path: cursorrules,
326                reason: "global ~/.cursor/rules/lean-ctx.mdc already carries these blocks".into(),
327            });
328        } else if !canonical_cursor_mdc.exists() && content.contains("lean-ctx") {
329            actions.push(Action::Report {
330                path: cursorrules,
331                note: "no global Cursor mdc found — .cursorrules stays the carrier".into(),
332            });
333        } else if content.contains("lean-ctx") && !has_marked_block(&content) {
334            actions.push(Action::Report {
335                path: cursorrules,
336                note: "mentions lean-ctx without markers (manual rules) — review by hand".into(),
337            });
338        }
339    }
340
341    // 4. Shared project AGENTS.md (#684): Cursor, Codex and other agents all
342    // auto-load it, so a compression block here duplicates each reader's own
343    // canonical carrier. Strip only the compression block (the pointer stays)
344    // once every reader present on this machine is covered elsewhere.
345    let agents_md = project.join("AGENTS.md");
346    if let Ok(content) = std::fs::read_to_string(&agents_md) {
347        let has_compression =
348            content.contains(COMPRESSION_BLOCK_START) && content.contains(COMPRESSION_BLOCK_END);
349        if has_compression {
350            if crate::core::rules_channel::agents_md_can_thin(home) {
351                actions.push(Action::StripCompression {
352                    path: agents_md,
353                    reason: "every AGENTS.md reader already loads the compression block from its own canonical file".into(),
354                });
355            } else {
356                actions.push(Action::Report {
357                    path: agents_md,
358                    note: "an AGENTS.md reader has no other compression source — AGENTS.md stays the carrier".into(),
359                });
360            }
361        }
362    }
363
364    actions
365}
366
367/// Executes one action. Returns a human-readable result line.
368pub fn apply(action: &Action) -> String {
369    match action {
370        Action::DeleteFile { path, .. } => match std::fs::remove_file(path) {
371            Ok(()) => format!("deleted   {}", path.display()),
372            Err(e) => format!("FAILED    {} ({e})", path.display()),
373        },
374        Action::StripBlocks { path, .. } => {
375            let Ok(content) = std::fs::read_to_string(path) else {
376                return format!("FAILED    {} (unreadable)", path.display());
377            };
378            let stripped = strip_lean_ctx_blocks(&content);
379            if stripped == content {
380                return format!("unchanged {}", path.display());
381            }
382            let bak = path.with_extension("bak");
383            if let Err(e) = std::fs::write(&bak, &content) {
384                return format!("FAILED    {} (backup: {e})", path.display());
385            }
386            if stripped.is_empty() {
387                match std::fs::remove_file(path) {
388                    Ok(()) => format!(
389                        "deleted   {} (only lean-ctx blocks, backup: {})",
390                        path.display(),
391                        bak.display()
392                    ),
393                    Err(e) => format!("FAILED    {} ({e})", path.display()),
394                }
395            } else {
396                match std::fs::write(path, &stripped) {
397                    Ok(()) => format!("stripped  {} (backup: {})", path.display(), bak.display()),
398                    Err(e) => format!("FAILED    {} ({e})", path.display()),
399                }
400            }
401        }
402        Action::StripCompression { path, .. } => {
403            let Ok(content) = std::fs::read_to_string(path) else {
404                return format!("FAILED    {} (unreadable)", path.display());
405            };
406            let stripped = strip_compression_block(&content);
407            if stripped == content {
408                return format!("unchanged {}", path.display());
409            }
410            let bak = path.with_extension("bak");
411            if let Err(e) = std::fs::write(&bak, &content) {
412                return format!("FAILED    {} (backup: {e})", path.display());
413            }
414            match std::fs::write(path, &stripped) {
415                Ok(()) => format!("thinned   {} (backup: {})", path.display(), bak.display()),
416                Err(e) => format!("FAILED    {} ({e})", path.display()),
417            }
418        }
419        Action::Report { path, note } => format!("info      {} — {note}", path.display()),
420    }
421}
422
423/// Non-interactive auto-heal used by `lean-ctx setup` and `doctor --fix`
424/// (#578): applies every *fixable* dedup action (owned duplicates deleted,
425/// marked blocks stripped with `.bak` backups) and returns one result line per
426/// action. `Report`-only findings (user-maintained content) are never touched.
427pub fn auto_apply(home: &Path, project: &Path) -> Vec<String> {
428    plan(home, project)
429        .iter()
430        .filter(|a| !matches!(a, Action::Report { .. }))
431        .map(apply)
432        .collect()
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn owned_mdc() -> String {
440        format!(
441            "---\ndescription: lean-ctx\n---\n{}\n<!-- version: 1 -->\nbody\n",
442            crate::core::rules_canonical::START_MARK
443        )
444    }
445
446    #[test]
447    fn detects_owned_dedicated_files() {
448        assert!(is_owned_rules_file(&owned_mdc()));
449        assert!(is_owned_rules_file(&format!(
450            "{}\n<!-- version: 1 -->\nbody\n",
451            crate::core::rules_canonical::START_MARK
452        )));
453        // User file mentioning lean-ctx is NOT owned.
454        assert!(!is_owned_rules_file("# My rules\nuse lean-ctx tools\n"));
455        // Marker buried mid-file (user prepended content) is NOT owned.
456        assert!(!is_owned_rules_file(&format!(
457            "# my header\n{}\n<!-- version: 1 -->\n",
458            crate::core::rules_canonical::START_MARK
459        )));
460    }
461
462    #[test]
463    fn strip_removes_rules_and_compression_blocks() {
464        let content = format!(
465            "user line\n{BLOCK_START}\nour rules\n{BLOCK_END}\nmore user\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
466        );
467        let out = strip_lean_ctx_blocks(&content);
468        assert!(out.contains("user line"));
469        assert!(out.contains("more user"));
470        assert!(!out.contains("our rules"));
471        assert!(!out.contains("style"));
472        assert!(!out.contains("lean-ctx-compression"));
473    }
474
475    #[test]
476    fn strip_of_pure_block_file_yields_empty() {
477        let content = format!("{BLOCK_START}\nonly ours\n{BLOCK_END}\n");
478        assert_eq!(strip_lean_ctx_blocks(&content), "");
479    }
480
481    /// #1289: legacy versioned-marker files (rules-v9 era, no `<!-- version:`)
482    /// are owned when wholly lean-ctx-written; user-extended copies are not.
483    #[test]
484    fn legacy_marker_files_owned_only_when_wholly_lean_ctx() {
485        let pristine = "# lean-ctx — Context Engineering Layer\n\
486             <!-- lean-ctx-rules-v9 -->\n\n\
487             PREFER lean-ctx MCP tools.\n\
488             Write, Delete, Glob → use normally.\n\
489             <!-- /lean-ctx -->\n";
490        assert!(is_owned_rules_file(pristine));
491
492        let user_extended = format!("{pristine}\n## My own precedence rules\nkeep me\n");
493        assert!(!is_owned_rules_file(&user_extended));
494
495        // A user file that merely mentions the marker mid-text is not owned.
496        assert!(!is_owned_rules_file(
497            "my notes\nabout <!-- lean-ctx-rules-v9 --> markers\n<!-- /lean-ctx -->\n"
498        ));
499    }
500
501    #[test]
502    fn plan_deletes_project_and_parent_owned_files_only() {
503        let tmp = tempfile::tempdir().unwrap();
504        let home = tmp.path();
505        let parent = home.join("projects");
506        let project = parent.join("app");
507
508        // Canonical global mdc (must never be planned for deletion).
509        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
510        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
511        // Stale project + parent copies.
512        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
513        std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
514        std::fs::create_dir_all(parent.join(".cursor/rules")).unwrap();
515        std::fs::write(parent.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
516        // User-customized file — must only be reported.
517        std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
518        std::fs::write(
519            project.join(".claude/rules/lean-ctx.md"),
520            "# customized by user\nkeep me\n",
521        )
522        .unwrap();
523
524        let actions = plan(home, &project);
525        let deletes: Vec<&PathBuf> = actions
526            .iter()
527            .filter_map(|a| match a {
528                Action::DeleteFile { path, .. } => Some(path),
529                _ => None,
530            })
531            .collect();
532        assert_eq!(deletes.len(), 2, "project + parent copies: {actions:?}");
533        assert!(deletes.iter().all(|p| !p.starts_with(home.join(".cursor"))));
534        assert!(actions.iter().any(|a| matches!(
535            a,
536            Action::Report { path, .. } if path.ends_with(".claude/rules/lean-ctx.md")
537        )));
538    }
539
540    /// A project outside `home` (a CI checkout, a test run with a temp HOME)
541    /// must not let the parent walk reach another user's directories: before,
542    /// it ran to the filesystem root and rewrote the real checkout's rules.
543    #[test]
544    fn plan_parent_walk_stays_inside_home() {
545        let tmp = tempfile::tempdir().unwrap();
546        let home = tmp.path().join("home");
547        let outside = tmp.path().join("elsewhere");
548        let project = outside.join("app");
549        std::fs::create_dir_all(&home).unwrap();
550        std::fs::create_dir_all(outside.join(".cursor/rules")).unwrap();
551        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
552        std::fs::write(outside.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
553        std::fs::write(project.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
554
555        let paths: Vec<PathBuf> = plan(&home, &project)
556            .into_iter()
557            .filter_map(|a| match a {
558                Action::DeleteFile { path, .. } | Action::StripBlocks { path, .. } => Some(path),
559                _ => None,
560            })
561            .collect();
562        assert_eq!(paths, vec![project.join(".cursor/rules/lean-ctx.mdc")]);
563    }
564
565    #[test]
566    fn plan_strips_cursorrules_only_with_canonical_mdc() {
567        let tmp = tempfile::tempdir().unwrap();
568        let home = tmp.path();
569        let project = home.join("app");
570        std::fs::create_dir_all(&project).unwrap();
571        let rules = format!("{BLOCK_START}\npointer\n{BLOCK_END}\n");
572        std::fs::write(project.join(".cursorrules"), rules).unwrap();
573
574        // Without the global mdc, .cursorrules is the carrier — report only.
575        let actions = plan(home, &project);
576        assert!(
577            !actions
578                .iter()
579                .any(|a| matches!(a, Action::StripBlocks { .. })),
580            "{actions:?}"
581        );
582
583        // With the canonical mdc, the block is a duplicate — strip.
584        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
585        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), owned_mdc()).unwrap();
586        let actions = plan(home, &project);
587        assert!(
588            actions.iter().any(|a| matches!(
589                a,
590                Action::StripBlocks { path, .. } if path.ends_with(".cursorrules")
591            )),
592            "{actions:?}"
593        );
594    }
595
596    #[test]
597    fn apply_strip_writes_backup_and_keeps_user_content() {
598        let tmp = tempfile::tempdir().unwrap();
599        let path = tmp.path().join(".cursorrules");
600        std::fs::write(
601            &path,
602            format!("my custom rule\n{BLOCK_START}\nours\n{BLOCK_END}\n"),
603        )
604        .unwrap();
605
606        let msg = apply(&Action::StripBlocks {
607            path: path.clone(),
608            reason: String::new(),
609        });
610        assert!(msg.starts_with("stripped"), "{msg}");
611        let after = std::fs::read_to_string(&path).unwrap();
612        assert_eq!(after, "my custom rule\n");
613        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
614        assert!(bak.contains("ours"));
615    }
616
617    #[test]
618    fn strip_compression_keeps_pointer_and_user_content() {
619        let content = format!(
620            "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n",
621        );
622        let out = strip_compression_block(&content);
623        assert!(out.contains("# Agent Instructions"));
624        assert!(out.contains(BLOCK_START));
625        assert!(out.contains("pointer"));
626        assert!(!out.contains("lean-ctx-compression"));
627        assert!(!out.contains("OUTPUT STYLE"));
628    }
629
630    #[test]
631    fn plan_thins_agents_md_when_cursor_covered_and_no_codex() {
632        let _guard = crate::core::data_dir::test_env_lock();
633        let tmp = tempfile::tempdir().unwrap();
634        let home = tmp.path();
635        let project = home.join("app");
636        std::fs::create_dir_all(&project).unwrap();
637        // Isolate codex resolution to this empty home (no codex present).
638        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
639
640        // Canonical global mdc carries the compression block → cursor covered.
641        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
642        std::fs::write(
643            home.join(".cursor/rules/lean-ctx.mdc"),
644            format!(
645                "{}\n<!-- version: 1 -->\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n",
646                crate::core::rules_canonical::START_MARK
647            ),
648        )
649        .unwrap();
650
651        // Project AGENTS.md still carries pointer + compression.
652        std::fs::write(
653            project.join("AGENTS.md"),
654            format!(
655                "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
656            ),
657        )
658        .unwrap();
659
660        let actions = plan(home, &project);
661        assert!(
662            actions.iter().any(|a| matches!(
663                a,
664                Action::StripCompression { path, .. } if path.ends_with("AGENTS.md")
665            )),
666            "{actions:?}"
667        );
668        crate::test_env::remove_var("CODEX_HOME");
669    }
670
671    #[test]
672    fn plan_keeps_agents_md_carrier_when_cursor_uncovered() {
673        let _guard = crate::core::data_dir::test_env_lock();
674        let tmp = tempfile::tempdir().unwrap();
675        let home = tmp.path();
676        let project = home.join("app");
677        std::fs::create_dir_all(&project).unwrap();
678        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
679
680        // No global mdc → cursor not covered → AGENTS.md must stay the carrier.
681        std::fs::write(
682            project.join("AGENTS.md"),
683            format!(
684                "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
685            ),
686        )
687        .unwrap();
688
689        let actions = plan(home, &project);
690        assert!(
691            !actions
692                .iter()
693                .any(|a| matches!(a, Action::StripCompression { .. })),
694            "{actions:?}"
695        );
696        // Reported instead, so the user understands why it was left alone.
697        assert!(
698            actions.iter().any(|a| matches!(
699                a,
700                Action::Report { path, .. } if path.ends_with("AGENTS.md")
701            )),
702            "{actions:?}"
703        );
704        crate::test_env::remove_var("CODEX_HOME");
705    }
706
707    #[test]
708    fn apply_strip_compression_thins_and_backs_up() {
709        let tmp = tempfile::tempdir().unwrap();
710        let path = tmp.path().join("AGENTS.md");
711        std::fs::write(
712            &path,
713            format!(
714                "# Agent Instructions\n\n{BLOCK_START}\npointer\n{BLOCK_END}\n\n{COMPRESSION_BLOCK_START}\nstyle\n{COMPRESSION_BLOCK_END}\n"
715            ),
716        )
717        .unwrap();
718
719        let msg = apply(&Action::StripCompression {
720            path: path.clone(),
721            reason: String::new(),
722        });
723        assert!(msg.starts_with("thinned"), "{msg}");
724        let after = std::fs::read_to_string(&path).unwrap();
725        assert!(after.contains(BLOCK_START));
726        assert!(!after.contains("lean-ctx-compression"));
727        let bak = std::fs::read_to_string(path.with_extension("bak")).unwrap();
728        assert!(bak.contains("lean-ctx-compression"));
729    }
730
731    #[test]
732    fn apply_strip_deletes_file_that_was_only_ours() {
733        let tmp = tempfile::tempdir().unwrap();
734        let path = tmp.path().join(".cursorrules");
735        std::fs::write(&path, format!("{BLOCK_START}\nours\n{BLOCK_END}\n")).unwrap();
736
737        let msg = apply(&Action::StripBlocks {
738            path: path.clone(),
739            reason: String::new(),
740        });
741        assert!(msg.starts_with("deleted"), "{msg}");
742        assert!(!path.exists());
743        assert!(path.with_extension("bak").exists());
744    }
745}