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/// True when the installed Cursor hooks already compress the native tools
58/// (GL #1153): `~/.cursor/hooks.json` carries lean-ctx `preToolUse` entries
59/// for BOTH the Shell rewrite and the Read/Grep redirect. Only then is the
60/// "use ctx_* instead of native" mapping dead weight — with partial or no
61/// hook coverage the full guidance stays.
62pub fn cursor_hooks_cover_native_tools(home: &Path) -> bool {
63    cursor_hooks_json_covers(&home.join(".cursor/hooks.json"))
64}
65
66/// Path-based core of [`cursor_hooks_cover_native_tools`], so the rules
67/// injector can derive the hooks.json location from the mdc target path (the
68/// two always live under the same `.cursor/` dir).
69///
70/// Conservative by construction: unreadable/invalid JSON, a missing file, or
71/// a redirect that was manually removed all mean "not covered".
72pub fn cursor_hooks_json_covers(hooks_json: &Path) -> bool {
73    let Ok(content) = std::fs::read_to_string(hooks_json) else {
74        return false;
75    };
76    let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
77        return false;
78    };
79    let Some(pre) = v.pointer("/hooks/preToolUse").and_then(|p| p.as_array()) else {
80        return false;
81    };
82    let has_lean_ctx_hook = |suffix: &str| {
83        pre.iter().any(|e| {
84            e.get("command")
85                .and_then(|c| c.as_str())
86                .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
87        })
88    };
89    has_lean_ctx_hook("hook rewrite") && has_lean_ctx_hook("hook redirect")
90}
91
92/// For the MCP `instructions` block: is `client_name` a host whose installed
93/// lean-ctx hooks already compress the native tools? Drives the hook-aware
94/// anchor wording (GL #1153) — repeating "ctx_* replaces native tools" to a
95/// hook-covered Cursor re-creates exactly the instruction dissonance the
96/// HookCovered profile removes.
97pub fn client_hook_covered(client_name: &str, home: &Path) -> bool {
98    let lower = client_name.to_lowercase();
99    lower.contains("cursor") && cursor_hooks_cover_native_tools(home)
100}
101
102/// Codex's per-user config dir (`~/.codex`, or `$CODEX_HOME`).
103fn codex_dir(home: &Path) -> std::path::PathBuf {
104    crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
105}
106
107/// Codex is present on this machine when its config dir exists.
108pub fn codex_present(home: &Path) -> bool {
109    codex_dir(home).exists()
110}
111
112/// Codex auto-loads `~/.codex/AGENTS.md`; covered once it carries the block.
113pub fn codex_compression_covered(home: &Path) -> bool {
114    file_has_compression(&codex_dir(home).join("AGENTS.md"))
115}
116
117/// Decide whether the shared project `AGENTS.md` may drop its compression block
118/// (keeping only the `<!-- lean-ctx -->` pointer). Safe ⇔ EVERY `AGENTS.md`
119/// reader present on this machine already receives the compression payload from
120/// its own canonical file.
121///
122/// Conservative by construction (#684, "thin only if covered"): if any reader
123/// would lose the guidance, `AGENTS.md` stays the full carrier.
124pub fn agents_md_can_thin(home: &Path) -> bool {
125    if !cursor_compression_covered(home) {
126        return false;
127    }
128    if codex_present(home) && !codex_compression_covered(home) {
129        return false;
130    }
131    true
132}
133
134/// For the MCP `instructions` block: does `client_name` already auto-load the
135/// compression payload from a rule file? If so, repeating the output-style
136/// block in the per-session instructions is pure cross-channel duplication and
137/// can be dropped (the file copy governs).
138pub fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
139    let lower = client_name.to_lowercase();
140    if lower.is_empty() {
141        return false;
142    }
143    if lower.contains("cursor") {
144        return cursor_compression_covered(home);
145    }
146    if lower.contains("codex") {
147        return codex_present(home) && codex_compression_covered(home);
148    }
149    false
150}
151
152fn file_has_canonical_rules(path: &Path) -> bool {
153    std::fs::read_to_string(path)
154        .is_ok_and(|c| crate::core::rules_canonical::RulesFile::parse(&c).has_content())
155}
156
157/// For the MCP `instructions` block: does `client_name` already auto-load the
158/// *canonical rules* block (tool mapping, intent playbook, recovery line, …)
159/// from its own rule file? If so, repeating the whole skeleton in the
160/// per-session instructions bills the same guidance twice on every session
161/// (#578) — the builder collapses it to a one-line anchor instead.
162///
163/// Carrier per client (kept in sync with `rules_inject::targets`):
164///   * Cursor → `~/.cursor/rules/lean-ctx.mdc` (canonical rules block)
165///   * Codex → `$CODEX_HOME/instructions.md` (canonical rules block)
166///
167/// Claude Code deliberately does NOT count: its `CLAUDE.md` block is the
168/// custom tool-mapping summary (`hooks/agents/claude.rs`), not the canonical
169/// set — dropping the skeleton there would lose the intent playbook.
170///
171/// Conservative by construction: only clients whose auto-loaded carrier holds
172/// the canonical block *right now* count. Any stale/removed file falls back to
173/// the full skeleton.
174pub fn client_autoloads_rules(client_name: &str, home: &Path) -> bool {
175    let lower = client_name.to_lowercase();
176    if lower.is_empty() {
177        return false;
178    }
179    if lower.contains("cursor") {
180        return file_has_canonical_rules(&home.join(".cursor/rules/lean-ctx.mdc"));
181    }
182    if lower.contains("codex") {
183        return codex_present(home)
184            && file_has_canonical_rules(&codex_dir(home).join("instructions.md"));
185    }
186    false
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
194
195    fn compression_block() -> String {
196        format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
197    }
198
199    fn pointer_block() -> String {
200        format!(
201            "{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
202            crate::core::rules_canonical::AGENTS_BLOCK_START,
203            crate::core::rules_canonical::AGENTS_BLOCK_END,
204        )
205    }
206
207    #[test]
208    fn full_rules_detected_for_canonical_header_and_compression() {
209        let comp = compression_block();
210        let ptr = pointer_block();
211        assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
212        assert!(carries_full_rules(&comp));
213        assert!(carries_full_rules(&format!("{ptr}{comp}")));
214    }
215
216    #[test]
217    fn pointer_only_block_is_not_full() {
218        let ptr = pointer_block();
219        assert!(!carries_full_rules(&ptr));
220        assert!(is_pointer_only(&ptr));
221    }
222
223    #[test]
224    fn plain_user_content_is_neither_full_nor_pointer() {
225        let user = "# My project rules\njust some notes\n";
226        assert!(!carries_full_rules(user));
227        assert!(!is_pointer_only(user));
228    }
229
230    #[test]
231    fn cursor_coverage_follows_mdc_block() {
232        let comp = compression_block();
233        let tmp = tempfile::tempdir().unwrap();
234        let home = tmp.path();
235        assert!(!cursor_compression_covered(home));
236
237        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
238        std::fs::write(
239            home.join(".cursor/rules/lean-ctx.mdc"),
240            format!("{FULL_HEADER}\n{comp}"),
241        )
242        .unwrap();
243        assert!(cursor_compression_covered(home));
244    }
245
246    #[test]
247    fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
248        let comp = compression_block();
249        // Serialize CODEX_HOME mutation (tests share the process environment).
250        let _guard = crate::core::data_dir::test_env_lock();
251        let tmp = tempfile::tempdir().unwrap();
252        let home = tmp.path();
253
254        // No canonical mdc yet → AGENTS.md must stay the carrier.
255        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
256        assert!(!agents_md_can_thin(home));
257
258        // Cursor covered, codex absent → safe to thin (the common case).
259        // CODEX_HOME points at this isolated home so a real `~/.codex` on the
260        // test machine cannot leak in.
261        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
262        std::fs::write(
263            home.join(".cursor/rules/lean-ctx.mdc"),
264            format!("{FULL_HEADER}\n{comp}"),
265        )
266        .unwrap();
267        assert!(agents_md_can_thin(home));
268
269        // Codex present but uncovered → must NOT thin (codex would lose it).
270        std::fs::create_dir_all(home.join(".codex")).unwrap();
271        assert!(codex_present(home));
272        assert!(!agents_md_can_thin(home));
273
274        // Codex now covered by its own global AGENTS.md → safe to thin again.
275        std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
276        assert!(agents_md_can_thin(home));
277        crate::test_env::remove_var("CODEX_HOME");
278    }
279
280    #[test]
281    fn client_autoloads_rules_requires_canonical_block_on_disk() {
282        let _guard = crate::core::data_dir::test_env_lock();
283        let tmp = tempfile::tempdir().unwrap();
284        let home = tmp.path();
285        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
286
287        // Nothing installed → nobody is covered.
288        assert!(!client_autoloads_rules("cursor", home));
289        assert!(!client_autoloads_rules("codex", home));
290        assert!(!client_autoloads_rules("", home));
291        assert!(!client_autoloads_rules("claude-code", home));
292
293        // Cursor covered once the mdc carries the canonical block.
294        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
295        std::fs::write(
296            home.join(".cursor/rules/lean-ctx.mdc"),
297            format!("{FULL_HEADER}\nbody\n"),
298        )
299        .unwrap();
300        assert!(client_autoloads_rules("cursor", home));
301        assert!(client_autoloads_rules("cursor-vscode", home));
302
303        // A pointer-only / non-canonical file must NOT count.
304        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), "user notes\n").unwrap();
305        assert!(!client_autoloads_rules("cursor", home));
306
307        // Codex covered via $CODEX_HOME/instructions.md.
308        std::fs::create_dir_all(home.join(".codex")).unwrap();
309        std::fs::write(
310            home.join(".codex/instructions.md"),
311            format!("{FULL_HEADER}\nbody\n"),
312        )
313        .unwrap();
314        assert!(client_autoloads_rules("codex", home));
315        crate::test_env::remove_var("CODEX_HOME");
316    }
317
318    #[test]
319    fn cursor_hook_coverage_requires_both_pretooluse_entries() {
320        let tmp = tempfile::tempdir().unwrap();
321        let home = tmp.path();
322        // No hooks.json at all.
323        assert!(!cursor_hooks_cover_native_tools(home));
324        assert!(!client_hook_covered("cursor", home));
325
326        std::fs::create_dir_all(home.join(".cursor")).unwrap();
327        let hooks = home.join(".cursor/hooks.json");
328
329        // Rewrite only (Shell covered, Read/Grep not) → NOT covered.
330        std::fs::write(
331            &hooks,
332            r#"{"version":1,"hooks":{"preToolUse":[
333                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"}
334            ]}}"#,
335        )
336        .unwrap();
337        assert!(!cursor_hooks_cover_native_tools(home));
338
339        // Rewrite + redirect → covered (exactly what install_cursor_hook_config writes).
340        std::fs::write(
341            &hooks,
342            r#"{"version":1,"hooks":{"preToolUse":[
343                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
344                {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
345            ]}}"#,
346        )
347        .unwrap();
348        assert!(cursor_hooks_cover_native_tools(home));
349        assert!(client_hook_covered("cursor", home));
350        assert!(client_hook_covered("cursor-vscode", home));
351        // Other clients never count as hook-covered via Cursor's hooks.json.
352        assert!(!client_hook_covered("codex", home));
353        assert!(!client_hook_covered("", home));
354
355        // Foreign hooks (not lean-ctx) must not count.
356        std::fs::write(
357            &hooks,
358            r#"{"version":1,"hooks":{"preToolUse":[
359                {"matcher":"Shell","command":"/opt/other hook rewrite"},
360                {"matcher":"Read|Grep","command":"/opt/other hook redirect"}
361            ]}}"#,
362        )
363        .unwrap();
364        assert!(!cursor_hooks_cover_native_tools(home));
365
366        // Invalid JSON → fail closed (full guidance).
367        std::fs::write(&hooks, "{ not json").unwrap();
368        assert!(!cursor_hooks_cover_native_tools(home));
369    }
370
371    #[test]
372    fn client_autoloads_compression_is_client_aware() {
373        let comp = compression_block();
374        let tmp = tempfile::tempdir().unwrap();
375        let home = tmp.path();
376        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
377        std::fs::write(
378            home.join(".cursor/rules/lean-ctx.mdc"),
379            format!("{FULL_HEADER}\n{comp}"),
380        )
381        .unwrap();
382
383        assert!(client_autoloads_compression("Cursor", home));
384        assert!(client_autoloads_compression("cursor-vscode", home));
385        // Empty / unknown clients never auto-load a file copy.
386        assert!(!client_autoloads_compression("", home));
387        assert!(!client_autoloads_compression("some-other-agent", home));
388    }
389
390    #[test]
391    fn render_output_is_detected_as_compression_coverage() {
392        // The slice's core guarantee (#548 B2): the bytes the writer (`render`)
393        // emits into a carrier file are recognised by the coverage detection the
394        // MCP cross-channel dedup depends on. Before the unified marker model,
395        // render embedded the prompt inline (no markers) so this was always
396        // false → Cursor was billed for the compression block twice (rule file +
397        // every MCP session).
398        use crate::core::config::CompressionLevel;
399        use crate::core::rules_canonical::{Wrapper, render};
400
401        let tmp = tempfile::tempdir().unwrap();
402        let home = tmp.path();
403        assert!(!cursor_compression_covered(home));
404
405        // Exactly what `rules_content`/inject writes to the Cursor mdc (frontmatter
406        // is irrelevant to substring detection).
407        let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard);
408        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
409        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
410
411        assert!(cursor_compression_covered(home));
412        assert!(client_autoloads_compression("cursor", home));
413
414        // An Off render carries no payload, so it must NOT count as coverage.
415        let off = render(false, Wrapper::Dedicated, CompressionLevel::Off);
416        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
417        assert!(!cursor_compression_covered(home));
418    }
419}