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", "opencode", "codewhale", "muse"];
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    carries_rules_block(content) || content.contains(COMPRESSION_BLOCK_START)
38}
39
40/// Version marker of the Claude Code `CLAUDE.md` block
41/// (`<!-- lean-ctx-claude-vN -->`, `hooks/agents/claude.rs`). The block sits
42/// between the pointer markers but carries the full Claude tool mapping, so
43/// it is a rules channel, not a pointer.
44pub const CLAUDE_BLOCK_MARKER_PREFIX: &str = "<!-- lean-ctx-claude-v";
45
46fn has_marker_line_with_prefix(content: &str, prefix: &str) -> bool {
47    content
48        .lines()
49        .any(|line| line.trim_start().starts_with(prefix))
50}
51
52/// True when `content` carries a lean-ctx *rules* block (tool mapping), as
53/// opposed to only the compression payload or the pointer: the canonical
54/// block, a legacy versioned block (`<!-- lean-ctx-rules-v9 -->`, #1289) or
55/// the Claude Code `CLAUDE.md` block. These are the payloads the MCP
56/// instructions skeleton repeats, so two of them in one host session are a
57/// duplicate rules channel.
58pub fn carries_rules_block(content: &str) -> bool {
59    content.contains(crate::core::rules_canonical::START_MARK)
60        || has_marker_line_with_prefix(content, crate::core::rules_canonical::RULES_MARKER_PREFIX)
61        || has_marker_line_with_prefix(content, CLAUDE_BLOCK_MARKER_PREFIX)
62}
63
64/// Claude Code auto-loads `CLAUDE.md` from its state dir (`~/.claude`, or
65/// `$CLAUDE_CONFIG_DIR`); covered once that file carries the lean-ctx block.
66pub fn claude_md_carries_block(home: &Path) -> bool {
67    std::fs::read_to_string(crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md"))
68        .is_ok_and(|c| has_marker_line_with_prefix(&c, CLAUDE_BLOCK_MARKER_PREFIX))
69}
70
71/// True when `content` contains a lean-ctx block but only the lightweight
72/// pointer (no canonical rules, no compression payload).
73pub fn is_pointer_only(content: &str) -> bool {
74    content.contains("<!-- lean-ctx") && !carries_full_rules(content)
75}
76
77fn file_has_compression(path: &Path) -> bool {
78    std::fs::read_to_string(path).is_ok_and(|c| c.contains(COMPRESSION_BLOCK_START))
79}
80
81/// Cursor auto-loads `~/.cursor/rules/lean-ctx.mdc`; it is "covered" for the
82/// compression payload once that canonical file carries the block.
83pub fn cursor_compression_covered(home: &Path) -> bool {
84    file_has_compression(&home.join(".cursor/rules/lean-ctx.mdc"))
85}
86
87/// True when the installed Cursor hooks already compress the native tools
88/// (GL #1153): `~/.cursor/hooks.json` carries lean-ctx `preToolUse` entries
89/// for BOTH the Shell rewrite and the Read/Grep redirect. Only then is the
90/// "use ctx_* instead of native" mapping dead weight — with partial or no
91/// hook coverage the full guidance stays.
92pub fn cursor_hooks_cover_native_tools(home: &Path) -> bool {
93    cursor_hooks_json_covers(&home.join(".cursor/hooks.json"))
94}
95
96/// Path-based core of [`cursor_hooks_cover_native_tools`], so the rules
97/// injector can derive the hooks.json location from the mdc target path (the
98/// two always live under the same `.cursor/` dir).
99///
100/// Conservative by construction: unreadable/invalid JSON, a missing file, or
101/// a redirect that was manually removed all mean "not covered".
102pub fn cursor_hooks_json_covers(hooks_json: &Path) -> bool {
103    let Ok(content) = std::fs::read_to_string(hooks_json) else {
104        return false;
105    };
106    let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
107        return false;
108    };
109    let Some(pre) = v.pointer("/hooks/preToolUse").and_then(|p| p.as_array()) else {
110        return false;
111    };
112    let has_lean_ctx_hook = |suffix: &str| {
113        pre.iter().any(|e| {
114            e.get("command")
115                .and_then(|c| c.as_str())
116                .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
117        })
118    };
119    has_lean_ctx_hook("hook rewrite") && has_lean_ctx_hook("hook redirect")
120}
121
122/// For the MCP `instructions` block: is `client_name` a host whose installed
123/// lean-ctx hooks already compress the native tools? Drives the hook-aware
124/// anchor wording (GL #1153) — repeating "ctx_* replaces native tools" to a
125/// hook-covered Cursor re-creates exactly the instruction dissonance the
126/// HookCovered profile removes.
127pub fn client_hook_covered(client_name: &str, home: &Path) -> bool {
128    let lower = client_name.to_lowercase();
129    if lower.contains("cursor") {
130        return cursor_hooks_cover_native_tools(home);
131    }
132    if lower.contains("codex") {
133        return codex_hooks_cover_native_tools(home);
134    }
135    if lower.contains("windsurf") {
136        return windsurf_hooks_cover_native_tools(home);
137    }
138    false
139}
140
141/// Codex hooks cover native tools when `~/.codex/hooks.json` has lean-ctx
142/// `codex-pretooluse` in PreToolUse. The pretooluse handler provides
143/// deny/redirect/rewrite for all tool types at runtime (mode-aware);
144/// a separate `hook deny` entry is not installed or required.
145fn codex_hooks_cover_native_tools(home: &Path) -> bool {
146    let hooks_json = home.join(".codex").join("hooks.json");
147    let Ok(content) = std::fs::read_to_string(&hooks_json) else {
148        return false;
149    };
150    let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
151        return false;
152    };
153    let Some(pre) = v.pointer("/hooks/PreToolUse").and_then(|p| p.as_array()) else {
154        return false;
155    };
156    let has_lean_ctx = |suffix: &str| {
157        pre.iter().any(|e| {
158            let in_command = e
159                .get("command")
160                .and_then(|c| c.as_str())
161                .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix));
162            let in_hooks_array = e
163                .get("hooks")
164                .and_then(|h| h.as_array())
165                .is_some_and(|hooks| {
166                    hooks.iter().any(|h| {
167                        h.get("command")
168                            .and_then(|c| c.as_str())
169                            .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
170                    })
171                });
172            in_command || in_hooks_array
173        })
174    };
175    // codex-pretooluse handles deny/redirect/rewrite for all tool types
176    // at runtime (mode-aware). No separate "hook deny" entry needed (#codex-ux).
177    has_lean_ctx("hook codex-pretooluse")
178}
179
180/// Windsurf hooks cover native tools when
181/// `~/.codeium/windsurf/hooks.json` has lean-ctx `rewrite` + `redirect`
182/// in `pre_mcp_tool_use`.
183fn windsurf_hooks_cover_native_tools(home: &Path) -> bool {
184    let hooks_json = home.join(".codeium").join("windsurf").join("hooks.json");
185    let Ok(content) = std::fs::read_to_string(&hooks_json) else {
186        return false;
187    };
188    let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
189        return false;
190    };
191    let Some(pre) = v
192        .pointer("/hooks/pre_mcp_tool_use")
193        .and_then(|p| p.as_array())
194    else {
195        return false;
196    };
197    let has_lean_ctx = |suffix: &str| {
198        pre.iter().any(|e| {
199            e.get("command")
200                .and_then(|c| c.as_str())
201                .is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
202        })
203    };
204    has_lean_ctx("hook rewrite") && has_lean_ctx("hook redirect")
205}
206
207/// Codex's per-user config dir (`~/.codex`, or `$CODEX_HOME`).
208fn codex_dir(home: &Path) -> std::path::PathBuf {
209    crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
210}
211
212/// Codex is present on this machine when its config dir exists.
213pub fn codex_present(home: &Path) -> bool {
214    codex_dir(home).exists()
215}
216
217/// Codex auto-loads `~/.codex/AGENTS.md`; covered once it carries the block.
218pub fn codex_compression_covered(home: &Path) -> bool {
219    file_has_compression(&codex_dir(home).join("AGENTS.md"))
220}
221
222/// Decide whether the shared project `AGENTS.md` may drop its compression block
223/// (keeping only the `<!-- lean-ctx -->` pointer). Safe ⇔ EVERY `AGENTS.md`
224/// reader present on this machine already receives the compression payload from
225/// its own canonical file.
226///
227/// Conservative by construction (#684, "thin only if covered"): if any reader
228/// would lose the guidance, `AGENTS.md` stays the full carrier.
229pub fn agents_md_can_thin(home: &Path) -> bool {
230    if !cursor_compression_covered(home) {
231        return false;
232    }
233    if codex_present(home) && !codex_compression_covered(home) {
234        return false;
235    }
236    true
237}
238
239/// For the MCP `instructions` block: does `client_name` already auto-load the
240/// compression payload from a rule file? If so, repeating the output-style
241/// block in the per-session instructions is pure cross-channel duplication and
242/// can be dropped (the file copy governs).
243pub fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
244    let lower = client_name.to_lowercase();
245    if lower.is_empty() {
246        return false;
247    }
248    if lower.contains("cursor") {
249        return cursor_compression_covered(home);
250    }
251    if lower.contains("codex") {
252        return codex_present(home) && codex_compression_covered(home);
253    }
254    if lower.contains("opencode") {
255        let opencode_dir = home.join(".config/opencode");
256        let dedicated = opencode_dir.join("rules/lean-ctx.md");
257        let shared = opencode_dir.join("AGENTS.md");
258        return file_has_compression(&dedicated) || file_has_compression(&shared);
259    }
260    false
261}
262
263fn file_has_canonical_rules(path: &Path) -> bool {
264    std::fs::read_to_string(path)
265        .is_ok_and(|c| crate::core::rules_canonical::RulesFile::parse(&c).has_content())
266}
267
268/// For the MCP `instructions` block: does `client_name` already auto-load the
269/// *canonical rules* block (tool mapping, intent playbook, recovery line, …)
270/// from its own rule file? If so, repeating the whole skeleton in the
271/// per-session instructions bills the same guidance twice on every session
272/// (#578) — the builder collapses it to a one-line anchor instead.
273///
274/// Carrier per client (kept in sync with `rules_inject::targets`):
275///   * Cursor → `~/.cursor/rules/lean-ctx.mdc` (canonical rules block)
276///   * Codex → `$CODEX_HOME/instructions.md` (canonical rules block)
277///   * OpenCode → `~/.config/opencode/rules/lean-ctx.md` (dedicated) or
278///     `~/.config/opencode/AGENTS.md` (shared)
279///
280///   * Claude Code → `CLAUDE.md` in its state dir (the `lean-ctx-claude-vN`
281///     block, `hooks/agents/claude.rs`)
282///   * Gemini CLI → `~/.gemini/GEMINI.md` (shared) or the dedicated file
283///
284/// Claude Code (A3, one rules channel per host): the `CLAUDE.md` block is the
285/// single channel. It carries the Claude-specific guidance (Replace-mode
286/// denials, the native-Read edit gate, auto memory) that cannot move into
287/// the instructions — Claude Code cuts initialize `instructions` at 2048
288/// chars, and the skeleton alone already fills ~1900. Repeating the mapping
289/// there billed it twice per turn (instructions 468 tok → anchor path 112).
290///
291/// Conservative by construction: only clients whose auto-loaded carrier holds
292/// the canonical block *right now* count. Any stale/removed file falls back to
293/// the full skeleton.
294pub fn client_autoloads_rules(client_name: &str, home: &Path) -> bool {
295    let lower = client_name.to_lowercase();
296    if lower.is_empty() {
297        return false;
298    }
299    let client_id =
300        crate::core::client_capabilities::ClientMcpCapabilities::detect(client_name).client_id;
301    if dedicated_session_hook_carries_rules(&client_id, home) {
302        return true;
303    }
304    match client_id.as_str() {
305        "claude-code" => return claude_md_carries_block(home),
306        "gemini-cli" => {
307            return file_has_canonical_rules(&home.join(".gemini/GEMINI.md"))
308                || file_has_canonical_rules(&crate::rules_inject::gemini_dedicated_rules_path(
309                    home,
310                ));
311        }
312        _ => {}
313    }
314    if lower.contains("cursor") {
315        return file_has_canonical_rules(&home.join(".cursor/rules/lean-ctx.mdc"));
316    }
317    if lower.contains("codex") {
318        return codex_present(home)
319            && file_has_canonical_rules(&codex_dir(home).join("instructions.md"));
320    }
321    if lower.contains("opencode") {
322        // OpenCode loads rules from either:
323        // - ~/.config/opencode/AGENTS.md (shared mode)
324        // - ~/.config/opencode/rules/lean-ctx.md (dedicated mode)
325        let opencode_dir = home.join(".config/opencode");
326        let dedicated = opencode_dir.join("rules/lean-ctx.md");
327        let shared = opencode_dir.join("AGENTS.md");
328        return file_has_canonical_rules(&dedicated) || file_has_canonical_rules(&shared);
329    }
330    false
331}
332
333/// Dedicated mode (#343, A3): no rules file is written; the SessionStart
334/// `hook observe` injects the rules instead. When that hook is installed for
335/// the host, it is the single rules channel and the MCP instructions anchor.
336fn dedicated_session_hook_carries_rules(client_id: &str, home: &Path) -> bool {
337    let cfg = crate::core::config::Config::load();
338    if !cfg.dedicated_session_context_active() || cfg.declines_rule_steering() {
339        return false;
340    }
341    let settings = match client_id {
342        "claude-code" => crate::core::editor_registry::claude_state_dir(home).join("settings.json"),
343        "codex" => codex_dir(home).join("hooks.json"),
344        "codebuddy" => {
345            crate::core::editor_registry::codebuddy_state_dir(home).join("settings.json")
346        }
347        _ => return false,
348    };
349    session_start_runs_observe(&settings)
350}
351
352/// True when the host's hook config runs `lean-ctx hook observe` on SessionStart
353/// (`{"hooks": {"SessionStart": [{"hooks": [{"command": …}]}]}}`).
354fn session_start_runs_observe(settings: &Path) -> bool {
355    let Ok(content) = std::fs::read_to_string(settings) else {
356        return false;
357    };
358    let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
359        return false;
360    };
361    let Some(groups) = v.pointer("/hooks/SessionStart").and_then(|g| g.as_array()) else {
362        return false;
363    };
364    groups.iter().any(|group| {
365        group
366            .get("hooks")
367            .and_then(|h| h.as_array())
368            .is_some_and(|hooks| {
369                hooks.iter().any(|hook| {
370                    hook.get("command")
371                        .and_then(|c| c.as_str())
372                        .is_some_and(|c| c.contains("hook observe"))
373                })
374            })
375    })
376}
377
378#[cfg(test)]
379pub mod tests {
380    use super::*;
381
382    const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
383
384    fn compression_block() -> String {
385        format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
386    }
387
388    fn pointer_block() -> String {
389        format!(
390            "{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
391            crate::core::rules_canonical::AGENTS_BLOCK_START,
392            crate::core::rules_canonical::AGENTS_BLOCK_END,
393        )
394    }
395
396    #[test]
397    fn full_rules_detected_for_canonical_header_and_compression() {
398        let comp = compression_block();
399        let ptr = pointer_block();
400        assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
401        assert!(carries_full_rules(&comp));
402        assert!(carries_full_rules(&format!("{ptr}{comp}")));
403    }
404
405    #[test]
406    fn pointer_only_block_is_not_full() {
407        let ptr = pointer_block();
408        assert!(!carries_full_rules(&ptr));
409        assert!(is_pointer_only(&ptr));
410    }
411
412    #[test]
413    fn plain_user_content_is_neither_full_nor_pointer() {
414        let user = "# My project rules\njust some notes\n";
415        assert!(!carries_full_rules(user));
416        assert!(!is_pointer_only(user));
417    }
418
419    #[test]
420    fn cursor_coverage_follows_mdc_block() {
421        let comp = compression_block();
422        let tmp = tempfile::tempdir().unwrap();
423        let home = tmp.path();
424        assert!(!cursor_compression_covered(home));
425
426        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
427        std::fs::write(
428            home.join(".cursor/rules/lean-ctx.mdc"),
429            format!("{FULL_HEADER}\n{comp}"),
430        )
431        .unwrap();
432        assert!(cursor_compression_covered(home));
433    }
434
435    #[test]
436    fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
437        let comp = compression_block();
438        // Serialize CODEX_HOME mutation (tests share the process environment).
439        let _guard = crate::core::data_dir::test_env_lock();
440        let tmp = tempfile::tempdir().unwrap();
441        let home = tmp.path();
442
443        // No canonical mdc yet → AGENTS.md must stay the carrier.
444        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
445        assert!(!agents_md_can_thin(home));
446
447        // Cursor covered, codex absent → safe to thin (the common case).
448        // CODEX_HOME points at this isolated home so a real `~/.codex` on the
449        // test machine cannot leak in.
450        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
451        std::fs::write(
452            home.join(".cursor/rules/lean-ctx.mdc"),
453            format!("{FULL_HEADER}\n{comp}"),
454        )
455        .unwrap();
456        assert!(agents_md_can_thin(home));
457
458        // Codex present but uncovered → must NOT thin (codex would lose it).
459        std::fs::create_dir_all(home.join(".codex")).unwrap();
460        assert!(codex_present(home));
461        assert!(!agents_md_can_thin(home));
462
463        // Codex now covered by its own global AGENTS.md → safe to thin again.
464        std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
465        assert!(agents_md_can_thin(home));
466        crate::test_env::remove_var("CODEX_HOME");
467    }
468
469    #[test]
470    fn client_autoloads_rules_requires_canonical_block_on_disk() {
471        let _guard = crate::core::data_dir::test_env_lock();
472        let tmp = tempfile::tempdir().unwrap();
473        let home = tmp.path();
474        crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
475
476        // Nothing installed → nobody is covered.
477        assert!(!client_autoloads_rules("cursor", home));
478        assert!(!client_autoloads_rules("codex", home));
479        assert!(!client_autoloads_rules("", home));
480        assert!(!client_autoloads_rules("claude-code", home));
481
482        // Cursor covered once the mdc carries the canonical block.
483        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
484        std::fs::write(
485            home.join(".cursor/rules/lean-ctx.mdc"),
486            format!("{FULL_HEADER}\nbody\n"),
487        )
488        .unwrap();
489        assert!(client_autoloads_rules("cursor", home));
490        assert!(client_autoloads_rules("cursor-vscode", home));
491
492        // A pointer-only / non-canonical file must NOT count.
493        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), "user notes\n").unwrap();
494        assert!(!client_autoloads_rules("cursor", home));
495
496        // Codex covered via $CODEX_HOME/instructions.md.
497        std::fs::create_dir_all(home.join(".codex")).unwrap();
498        std::fs::write(
499            home.join(".codex/instructions.md"),
500            format!("{FULL_HEADER}\nbody\n"),
501        )
502        .unwrap();
503        assert!(client_autoloads_rules("codex", home));
504        crate::test_env::remove_var("CODEX_HOME");
505    }
506
507    #[test]
508    fn cursor_hook_coverage_requires_both_pretooluse_entries() {
509        let tmp = tempfile::tempdir().unwrap();
510        let home = tmp.path();
511        // No hooks.json at all.
512        assert!(!cursor_hooks_cover_native_tools(home));
513        assert!(!client_hook_covered("cursor", home));
514
515        std::fs::create_dir_all(home.join(".cursor")).unwrap();
516        let hooks = home.join(".cursor/hooks.json");
517
518        // Rewrite only (Shell covered, Read/Grep not) → NOT covered.
519        std::fs::write(
520            &hooks,
521            r#"{"version":1,"hooks":{"preToolUse":[
522                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"}
523            ]}}"#,
524        )
525        .unwrap();
526        assert!(!cursor_hooks_cover_native_tools(home));
527
528        // Rewrite + redirect → covered (exactly what install_cursor_hook_config writes).
529        std::fs::write(
530            &hooks,
531            r#"{"version":1,"hooks":{"preToolUse":[
532                {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
533                {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
534            ]}}"#,
535        )
536        .unwrap();
537        assert!(cursor_hooks_cover_native_tools(home));
538        assert!(client_hook_covered("cursor", home));
539        assert!(client_hook_covered("cursor-vscode", home));
540        // Other clients never count as hook-covered via Cursor's hooks.json.
541        assert!(!client_hook_covered("codex", home));
542        assert!(!client_hook_covered("", home));
543
544        // Foreign hooks (not lean-ctx) must not count.
545        std::fs::write(
546            &hooks,
547            r#"{"version":1,"hooks":{"preToolUse":[
548                {"matcher":"Shell","command":"/opt/other hook rewrite"},
549                {"matcher":"Read|Grep","command":"/opt/other hook redirect"}
550            ]}}"#,
551        )
552        .unwrap();
553        assert!(!cursor_hooks_cover_native_tools(home));
554
555        // Invalid JSON → fail closed (full guidance).
556        std::fs::write(&hooks, "{ not json").unwrap();
557        assert!(!cursor_hooks_cover_native_tools(home));
558    }
559
560    #[test]
561    fn codex_hook_coverage_requires_pretooluse() {
562        let tmp = tempfile::tempdir().unwrap();
563        let home = tmp.path();
564
565        assert!(!client_hook_covered("codex-cli", home));
566
567        let codex_dir = home.join(".codex");
568        std::fs::create_dir_all(&codex_dir).expect("create .codex dir");
569        let hooks = codex_dir.join("hooks.json");
570
571        // No hooks → not covered.
572        std::fs::write(&hooks, r#"{"hooks":{"PreToolUse":[]}}"#).expect("write empty hooks");
573        assert!(!codex_hooks_cover_native_tools(home));
574
575        // codex-pretooluse alone is sufficient (it handles deny/redirect
576        // at runtime, no separate deny entry needed).
577        std::fs::write(
578            &hooks,
579            r#"{"hooks":{"PreToolUse":[
580                {"hooks":[{"command":"/usr/local/bin/lean-ctx hook codex-pretooluse","type":"command","timeout":15}],"matcher":"Bash|Read|Grep|Glob"}
581            ]}}"#,
582        )
583        .expect("write pretooluse hooks");
584        assert!(codex_hooks_cover_native_tools(home));
585        assert!(client_hook_covered("codex-cli", home));
586        assert!(client_hook_covered("codex", home));
587        // Cursor must NOT match via Codex hooks.
588        assert!(!client_hook_covered("cursor", home));
589
590        // Legacy setup with both deny + pretooluse also covered.
591        std::fs::write(
592            &hooks,
593            r#"{"hooks":{"PreToolUse":[
594                {"command":"/usr/local/bin/lean-ctx hook deny","matcher":"Read|Grep|Glob"},
595                {"hooks":[{"command":"/usr/local/bin/lean-ctx hook codex-pretooluse","type":"command","timeout":15}],"matcher":"Bash"}
596            ]}}"#,
597        )
598        .expect("write legacy hooks");
599        assert!(codex_hooks_cover_native_tools(home));
600    }
601
602    #[test]
603    fn windsurf_hook_coverage_requires_rewrite_and_redirect() {
604        let tmp = tempfile::tempdir().unwrap();
605        let home = tmp.path();
606
607        assert!(!client_hook_covered("windsurf", home));
608
609        let ws_dir = home.join(".codeium").join("windsurf");
610        std::fs::create_dir_all(&ws_dir).expect("create .codeium/windsurf dir");
611        let hooks = ws_dir.join("hooks.json");
612
613        // rewrite only → NOT covered.
614        std::fs::write(
615            &hooks,
616            r#"{"hooks":{"pre_mcp_tool_use":[
617                {"command":"/usr/local/bin/lean-ctx hook rewrite"}
618            ]}}"#,
619        )
620        .expect("write rewrite-only hooks");
621        assert!(!windsurf_hooks_cover_native_tools(home));
622
623        // Both rewrite + redirect → covered.
624        std::fs::write(
625            &hooks,
626            r#"{"hooks":{"pre_mcp_tool_use":[
627                {"command":"/usr/local/bin/lean-ctx hook rewrite"},
628                {"command":"/usr/local/bin/lean-ctx hook redirect"}
629            ]}}"#,
630        )
631        .expect("write full hooks");
632        assert!(windsurf_hooks_cover_native_tools(home));
633        assert!(client_hook_covered("windsurf", home));
634        // Other clients must NOT match.
635        assert!(!client_hook_covered("cursor", home));
636        assert!(!client_hook_covered("codex", home));
637    }
638
639    #[test]
640    fn opencode_autoloads_rules_from_shared_agents_md() {
641        let tmp = tempfile::tempdir().unwrap();
642        let home = tmp.path();
643        assert!(!client_autoloads_rules("opencode", home));
644
645        std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
646        std::fs::write(
647            home.join(".config/opencode/AGENTS.md"),
648            format!("{FULL_HEADER}\nbody\n"),
649        )
650        .unwrap();
651        assert!(client_autoloads_rules("opencode", home));
652    }
653
654    #[test]
655    fn opencode_autoloads_rules_from_dedicated_rules_file() {
656        let tmp = tempfile::tempdir().unwrap();
657        let home = tmp.path();
658
659        std::fs::create_dir_all(home.join(".config/opencode/rules")).unwrap();
660        std::fs::write(
661            home.join(".config/opencode/rules/lean-ctx.md"),
662            format!("{FULL_HEADER}\nbody\n"),
663        )
664        .unwrap();
665        assert!(client_autoloads_rules("opencode", home));
666    }
667
668    #[test]
669    fn client_autoloads_compression_is_client_aware() {
670        let comp = compression_block();
671        let tmp = tempfile::tempdir().unwrap();
672        let home = tmp.path();
673        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
674        std::fs::write(
675            home.join(".cursor/rules/lean-ctx.mdc"),
676            format!("{FULL_HEADER}\n{comp}"),
677        )
678        .unwrap();
679
680        assert!(client_autoloads_compression("Cursor", home));
681        assert!(client_autoloads_compression("cursor-vscode", home));
682        // Empty / unknown clients never auto-load a file copy.
683        assert!(!client_autoloads_compression("", home));
684        assert!(!client_autoloads_compression("some-other-agent", home));
685    }
686
687    #[test]
688    fn render_output_is_detected_as_compression_coverage() {
689        // The slice's core guarantee (#548 B2): the bytes the writer (`render`)
690        // emits into a carrier file are recognised by the coverage detection the
691        // MCP cross-channel dedup depends on. Before the unified marker model,
692        // render embedded the prompt inline (no markers) so this was always
693        // false → Cursor was billed for the compression block twice (rule file +
694        // every MCP session).
695        use crate::core::config::CompressionLevel;
696        use crate::core::rules_canonical::{Wrapper, render};
697        use crate::core::tool_profiles::ToolProfile;
698
699        let tmp = tempfile::tempdir().unwrap();
700        let home = tmp.path();
701        assert!(!cursor_compression_covered(home));
702
703        let tp = ToolProfile::Power;
704        let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard, &tp);
705        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
706        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
707
708        assert!(cursor_compression_covered(home));
709        assert!(client_autoloads_compression("cursor", home));
710
711        let off = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp);
712        std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
713        assert!(!cursor_compression_covered(home));
714    }
715}