Skip to main content

lean_ctx/core/
rules_channel.rs

1//! Cross-channel rule deduplication policy (#684).
2//!
3//! lean-ctx publishes its guidance through several "channels":
4//!   * per-client global rule files (`~/.cursor/rules/lean-ctx.mdc`, …),
5//!   * the shared project `AGENTS.md` (Cursor, Codex and other agents all
6//!     auto-load it),
7//!   * the MCP server `instructions` block (sent on every `initialize`).
8//!
9//! Several agents read more than one channel, so the *same* guidance can be
10//! billed two or three times per session. This module centralises the policy
11//! that decides, per client, which channel is the single canonical carrier — so
12//! the writers (`compression` inject, hooks), the repair command
13//! (`lean-ctx rules dedup`) and the honest accounting (`doctor overhead`) all
14//! agree on one source of truth.
15
16use std::path::Path;
17
18/// Markers of the heavy compression / output-style block — the per-turn payload
19/// that actually drives cross-channel duplication. Defined in `rules_canonical`
20/// (the single marker source of truth) and re-exported here so the coverage/dedup
21/// readers and the `render()` writer can never disagree (#548).
22pub use crate::core::rules_canonical::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
23
24/// The agents that auto-load the shared project `AGENTS.md`. Kept in sync with
25/// `core::rules_overhead::collect_rules_files`, which attributes `AGENTS.md` to
26/// the same set.
27pub const AGENTS_MD_READERS: &[&str] = &["cursor", "codex"];
28
29/// True when `content` carries a *full* lean-ctx payload — the canonical rule
30/// set (the `RULES_MARKER` header) or the compression/output-style block —
31/// rather than just the lightweight `<!-- lean-ctx -->` cross-reference pointer.
32///
33/// A pointer-only file (a thinned `AGENTS.md` / `.cursorrules` that merely says
34/// "the full rules live in the canonical file") does not duplicate guidance and
35/// must not be counted as a second source for its client.
36pub fn carries_full_rules(content: &str) -> bool {
37    content.contains(crate::core::rules_canonical::START_MARK)
38        || content.contains(COMPRESSION_BLOCK_START)
39}
40
41/// True when `content` contains a lean-ctx block but only the lightweight
42/// pointer (no canonical rules, no compression payload).
43pub fn is_pointer_only(content: &str) -> bool {
44    content.contains("<!-- lean-ctx") && !carries_full_rules(content)
45}
46
47fn file_has_compression(path: &Path) -> bool {
48    std::fs::read_to_string(path).is_ok_and(|c| c.contains(COMPRESSION_BLOCK_START))
49}
50
51/// Cursor auto-loads `~/.cursor/rules/lean-ctx.mdc`; it is "covered" for the
52/// compression payload once that canonical file carries the block.
53pub fn cursor_compression_covered(home: &Path) -> bool {
54    file_has_compression(&home.join(".cursor/rules/lean-ctx.mdc"))
55}
56
57/// Codex's per-user config dir (`~/.codex`, or `$CODEX_HOME`).
58fn codex_dir(home: &Path) -> std::path::PathBuf {
59    crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
60}
61
62/// Codex is present on this machine when its config dir exists.
63pub fn codex_present(home: &Path) -> bool {
64    codex_dir(home).exists()
65}
66
67/// Codex auto-loads `~/.codex/AGENTS.md`; covered once it carries the block.
68pub fn codex_compression_covered(home: &Path) -> bool {
69    file_has_compression(&codex_dir(home).join("AGENTS.md"))
70}
71
72/// Decide whether the shared project `AGENTS.md` may drop its compression block
73/// (keeping only the `<!-- lean-ctx -->` pointer). Safe ⇔ EVERY `AGENTS.md`
74/// reader present on this machine already receives the compression payload from
75/// its own canonical file.
76///
77/// Conservative by construction (#684, "thin only if covered"): if any reader
78/// would lose the guidance, `AGENTS.md` stays the full carrier.
79pub fn agents_md_can_thin(home: &Path) -> bool {
80    if !cursor_compression_covered(home) {
81        return false;
82    }
83    if codex_present(home) && !codex_compression_covered(home) {
84        return false;
85    }
86    true
87}
88
89/// For the MCP `instructions` block: does `client_name` already auto-load the
90/// compression payload from a rule file? If so, repeating the output-style
91/// block in the per-session instructions is pure cross-channel duplication and
92/// can be dropped (the file copy governs).
93pub fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
94    let lower = client_name.to_lowercase();
95    if lower.is_empty() {
96        return false;
97    }
98    if lower.contains("cursor") {
99        return cursor_compression_covered(home);
100    }
101    if lower.contains("codex") {
102        return codex_present(home) && codex_compression_covered(home);
103    }
104    false
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
112
113    fn compression_block() -> String {
114        format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
115    }
116
117    fn pointer_block() -> String {
118        format!(
119            "{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
120            crate::core::rules_canonical::AGENTS_BLOCK_START,
121            crate::core::rules_canonical::AGENTS_BLOCK_END,
122        )
123    }
124
125    #[test]
126    fn full_rules_detected_for_canonical_header_and_compression() {
127        let comp = compression_block();
128        let ptr = pointer_block();
129        assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
130        assert!(carries_full_rules(&comp));
131        assert!(carries_full_rules(&format!("{ptr}{comp}")));
132    }
133
134    #[test]
135    fn pointer_only_block_is_not_full() {
136        let ptr = pointer_block();
137        assert!(!carries_full_rules(&ptr));
138        assert!(is_pointer_only(&ptr));
139    }
140
141    #[test]
142    fn plain_user_content_is_neither_full_nor_pointer() {
143        let user = "# My project rules\njust some notes\n";
144        assert!(!carries_full_rules(user));
145        assert!(!is_pointer_only(user));
146    }
147
148    #[test]
149    fn cursor_coverage_follows_mdc_block() {
150        let comp = compression_block();
151        let tmp = tempfile::tempdir().unwrap();
152        let home = tmp.path();
153        assert!(!cursor_compression_covered(home));
154
155        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
156        std::fs::write(
157            home.join(".cursor/rules/lean-ctx.mdc"),
158            format!("{FULL_HEADER}\n{comp}"),
159        )
160        .unwrap();
161        assert!(cursor_compression_covered(home));
162    }
163
164    #[test]
165    fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
166        let comp = compression_block();
167        // Serialize CODEX_HOME mutation (tests share the process environment).
168        let _guard = crate::core::data_dir::test_env_lock();
169        let tmp = tempfile::tempdir().unwrap();
170        let home = tmp.path();
171
172        // No canonical mdc yet → AGENTS.md must stay the carrier.
173        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
174        assert!(!agents_md_can_thin(home));
175
176        // Cursor covered, codex absent → safe to thin (the common case).
177        // CODEX_HOME points at this isolated home so a real `~/.codex` on the
178        // test machine cannot leak in.
179        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
180        std::fs::write(
181            home.join(".cursor/rules/lean-ctx.mdc"),
182            format!("{FULL_HEADER}\n{comp}"),
183        )
184        .unwrap();
185        assert!(agents_md_can_thin(home));
186
187        // Codex present but uncovered → must NOT thin (codex would lose it).
188        std::fs::create_dir_all(home.join(".codex")).unwrap();
189        assert!(codex_present(home));
190        assert!(!agents_md_can_thin(home));
191
192        // Codex now covered by its own global AGENTS.md → safe to thin again.
193        std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
194        assert!(agents_md_can_thin(home));
195        crate::test_env::remove_var("CODEX_HOME");
196    }
197
198    #[test]
199    fn client_autoloads_compression_is_client_aware() {
200        let comp = compression_block();
201        let tmp = tempfile::tempdir().unwrap();
202        let home = tmp.path();
203        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
204        std::fs::write(
205            home.join(".cursor/rules/lean-ctx.mdc"),
206            format!("{FULL_HEADER}\n{comp}"),
207        )
208        .unwrap();
209
210        assert!(client_autoloads_compression("Cursor", home));
211        assert!(client_autoloads_compression("cursor-vscode", home));
212        // Empty / unknown clients never auto-load a file copy.
213        assert!(!client_autoloads_compression("", home));
214        assert!(!client_autoloads_compression("some-other-agent", home));
215    }
216
217    #[test]
218    fn render_output_is_detected_as_compression_coverage() {
219        // The slice's core guarantee (#548 B2): the bytes the writer (`render`)
220        // emits into a carrier file are recognised by the coverage detection the
221        // MCP cross-channel dedup depends on. Before the unified marker model,
222        // render embedded the prompt inline (no markers) so this was always
223        // false → Cursor was billed for the compression block twice (rule file +
224        // every MCP session).
225        use crate::core::config::CompressionLevel;
226        use crate::core::rules_canonical::{Wrapper, render};
227
228        let tmp = tempfile::tempdir().unwrap();
229        let home = tmp.path();
230        assert!(!cursor_compression_covered(home));
231
232        // Exactly what `rules_content`/inject writes to the Cursor mdc (frontmatter
233        // is irrelevant to substring detection).
234        let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard);
235        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
236        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
237
238        assert!(cursor_compression_covered(home));
239        assert!(client_autoloads_compression("cursor", home));
240
241        // An Off render carries no payload, so it must NOT count as coverage.
242        let off = render(false, Wrapper::Dedicated, CompressionLevel::Off);
243        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
244        assert!(!cursor_compression_covered(home));
245    }
246}