Skip to main content

lean_ctx/core/
rules_canonical.rs

1//! Canonical rules source — single source of truth for all lean-ctx guidance.
2//!
3//! All content is declared as `pub const` at the top. Three profiles (LONGFORM,
4//! FULL, COMPACT) define which sections compose each output format. Four
5//! wrappers (Longform, Dedicated, Shared, Bare) select the profile and wrapping
6//! style. One `render()` function assembles everything, including the
7//! compression-level output-style prompt (Lite / Standard / Max).
8//!
9//! Profile economics (#578): every *injected* file bills its tokens on every
10//! single session, so FULL (dedicated rule files) and COMPACT (shared files +
11//! MCP instructions) stay tight. LONGFORM is carried only by the project
12//! `LEAN-CTX.md`, which agents open on demand — it keeps the verbose teaching
13//! sections (loop taxonomy, navigation paradox, recovery vocabulary, CEP).
14//!
15//! ***Every*** template, injected rule file, AGENTS.md block, and MCP
16//! instructions field MUST derive its content from this module.
17
18use crate::core::config::CompressionLevel;
19
20/// Stable HTML-comment anchor that marks the start of any lean-ctx rule
21/// section.  Never changes — used for find/replace in shared files and for
22/// ownership detection in dedicated files.  The version number follows on the
23/// next line (see `render`).
24pub const START_MARK: &str = "<!-- lean-ctx-rules -->";
25
26/// Prefix shared by every lean-ctx rules marker including legacy versioned
27/// formats (`<!-- lean-ctx-rules-v9 -->`). Use for substring detection when
28/// the exact constant would miss older installs.
29pub const RULES_MARKER_PREFIX: &str = "<!-- lean-ctx-rules";
30
31/// Start marker for lightweight AGENTS.md/CODEBUDDY.md/CLAUDE.md pointer
32/// blocks. These are deliberately separate from `START_MARK` / `<!-- lean-ctx-rules -->`
33/// because the pointer-only vs full-rules distinction drives duplicate detection
34/// in `doctor overhead` — a pointer-only file (`is_pointer_only`) must not be
35/// counted as a second source for its client.
36pub const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
37
38/// End marker for AGENTS.md/CODEBUDDY.md/CLAUDE.md pointer blocks.
39pub const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
40
41/// Owner banner placed as the first line of the project-level `LEAN-CTX.md`
42/// artifact (`<repo>/LEAN-CTX.md`, `rust/LEAN-CTX.md`). Marks the whole file as
43/// lean-ctx-owned so uninstall can remove it wholesale; the writer
44/// (`hooks::ensure_project_agents_integration`), the regenerator
45/// (`gen_rules` example) and the drift gate all share this one literal.
46pub const PROJECT_LEAN_CTX_OWNED_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
47
48/// Closing marker that ends a lean-ctx rule section.
49pub const END_MARK: &str = "<!-- /lean-ctx-rules -->";
50
51/// Markers of the heavy compression / output-style block — the per-turn payload
52/// that drives cross-channel duplication (#684/#548).
53///
54/// `render()` wraps the compression prompt in these markers for **persistent
55/// carriers** (the `Dedicated` and `Shared` wrappers, i.e. every injected rule
56/// file). This is the single carrier/marker model: coverage and dedup
57/// (`core::rules_channel`, `cli::rules_dedup`) detect and thin the payload by
58/// these markers, so the writer and the readers can never disagree again. The
59/// ephemeral `Bare` MCP-instructions channel deliberately omits the markers —
60/// its inclusion is *governed* by carrier coverage (`client_autoloads_compression`),
61/// so a per-session marker would be pure noise.
62pub const COMPRESSION_BLOCK_START: &str = "<!-- lean-ctx-compression -->";
63
64/// Closing marker of the compression / output-style block (see
65/// [`COMPRESSION_BLOCK_START`]).
66pub const COMPRESSION_BLOCK_END: &str = "<!-- /lean-ctx-compression -->";
67
68/// Current rules version (monotonically increasing integer).  Embedded as
69/// `<!-- version: {RULES_VERSION} -->` right after `START_MARK` so the
70/// injection layer can parse it and decide whether a file is up-to-date.
71///
72/// History: v3 (#609) adds the `AGENT_LOOP` taxonomy + `NAV_PARADOX` guidance to
73/// the FULL profile and a compact one-liner to COMPACT. v4 adds recovery guidance
74/// so agents learn the (MCP-optional) decompression paths proactively instead of
75/// re-reading compressed output line-by-line. v5 (#578) is the self-footprint
76/// diet: the injected FULL profile drops to ~470 tokens (the intent playbook
77/// absorbs the loop taxonomy and navigation paradox; the verbose
78/// `AGENT_LOOP`/`NAV_PARADOX`/`RECOVER`/`CEP` sections move to the LONGFORM
79/// profile that only the on-demand project `LEAN-CTX.md` carries).
80/// v6: HookCovered profile strengthened — ctx_compose-first priority, imperative
81/// session memory requirement, ctx_callgraph/ctx_search(semantic) promoted from
82/// optional to mandatory. Fixes agents falling back to native Read+Grep for
83/// exploration on hook-covered hosts (Cursor).
84/// Bumping it forces every committed `LEAN-CTX.md` artifact to be regenerated
85/// (see `tests/rules_drift.rs`) and every injected user file to resync.
86pub const RULES_VERSION: usize = 6;
87
88/// Banner placed at the top of dedicated rule files (non-shadow only).
89pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx ctx_* tools instead of native equivalents. \
90     This is NOT optional.";
91
92/// Weak-model adherence nudge (#1067 / GH #593). Smaller models (e.g. GLM 5.2 in
93/// Windsurf) often read the tool-mapping rules yet still answer from memory or
94/// reach for a built-in tool instead of emitting an actual MCP call — so
95/// `lean-ctx watch` stays empty and the layer looks "off". This one imperative
96/// line makes the *act of invoking* a ctx_* tool the explicit first step. It
97/// lives in the dedicated rule files of *every* agent (`FULL_NON_SHADOW`), which
98/// is what #1067 asks for; the Windsurf project template (`windsurfrules.txt`)
99/// carries its own copy. It is omitted where it would be dead weight: shadow
100/// mode enforces routing at the call layer, and the `Bare`/instructions channel
101/// is capped separately.
102pub const MUST_INVOKE: &str = "ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — \
103    describing a tool is not calling it.";
104
105/// Compact bullet-list mapping (for both dedicated and shared contexts).
106pub const BULLETS: &str = "\
107MANDATORY MAPPING:\n\
108• Read/cat -> ctx_read(path, mode)\n\
109• Grep -> ctx_search(pattern, path)\n\
110• Shell/bash -> ctx_shell(command)\n\
111• Glob/find -> ctx_glob(pattern)\n\
112• ls/find -> ctx_tree(path, depth)";
113
114/// One-line admonition reinforcing the tool preference.  Always right after
115/// `BULLETS` in both profiles.
116pub const NEVER: &str = "NEVER use native Read/Grep/Shell/Glob when a ctx_* equivalent \
117    exists. SELF-CORRECT: the moment you reach for one, stop and call the ctx_* tool instead.";
118
119/// Intent-to-tool playbook — maps common agent questions to the right tool.
120/// Since v5 it also absorbs the agent-loop phases (#609) and the navigation
121/// paradox one-liner, replacing the separate verbose sections in the injected
122/// profile (they stay verbatim in LONGFORM).
123pub const INTENT: &str = "\
124Tool selection by intent:\n\
125• Orient / understand code (call FIRST) -> ctx_compose\n\
126• Read a file -> ctx_read(path, mode=signatures|map|full); edit after reading -> ctx_patch\n\
127• Exact symbol -> ctx_symbol; pattern -> ctx_search; by meaning -> ctx_semantic_search\n\
128• Files by glob -> ctx_glob; structure -> ctx_tree; callers/impact -> ctx_callgraph\n\
129• Verify after edits -> ctx_shell(test/build); memory -> ctx_session / ctx_knowledge\n\
130Semantic questions -> search tools, not whole-file reads: reading more ≠ understanding more.";
131
132/// Anti-patterns that waste tokens and round-trips.
133pub const ANTI: &str = "\
134Anti-patterns — do NOT:\n\
135• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
136• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
137• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track call\n\
138  edges and file deps only; use ctx_search instead";
139
140/// Encourages parallel tool calls to reduce round-trips.
141pub const PARALLEL: &str = "\
142PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles \
143multiple lookups into one call.";
144
145/// Agent-loop tool taxonomy (#609). Names each phase of the gather → act →
146/// verify loop an agent actually runs in and the one lean-ctx tool that serves
147/// it. Since v5 (#578) LONGFORM-only — the injected profiles carry the phases
148/// folded into `INTENT`.
149pub const AGENT_LOOP: &str = "\
150AGENT LOOP (phase -> tool):\n\
151• Orient — understand before acting -> ctx_compose\n\
152• Find — exact symbol by name -> ctx_symbol\n\
153• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
154• Locate — a pattern across files -> ctx_search\n\
155• Trace — callers / callees / blast radius -> ctx_callgraph\n\
156• Verify — after an edit -> ctx_shell(test/build) + native lints";
157
158/// Navigation-paradox guidance (#609): reading more is not understanding more.
159/// Steers semantic questions to BM25 + meaning search and reserves the call/dep
160/// graph for genuinely hidden architectural edges. Since v5 LONGFORM-only —
161/// `INTENT` carries the one-line thesis in the injected profiles.
162pub const NAV_PARADOX: &str = "\
163NAVIGATION PARADOX: reading more ≠ understanding more.\n\
164• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_semantic_search (meaning), not whole-file reads\n\
165• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
166• Navigate structure (signatures, symbols) before reading entire files";
167
168/// One-line automation reminder.
169pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
170    ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. \
171    Full guide: LEAN-CTX.md";
172
173/// Recovery vocabulary (verbose, LONGFORM profile). lean-ctx compression is fully
174/// reversible (CCR), but agents otherwise only discover the escape hatch reactively
175/// from output hints — so they re-read compressed files line-by-line instead of
176/// expanding (the "too compressed" complaint). The MCP-free path ("read the shown
177/// file path with any tool") covers orgs that forbid MCP. Since v5 every injected
178/// profile (FULL + COMPACT/Bare) carries the terser [`RECOVER_COMPACT`] instead;
179/// the reactive footers in `ctx_read`/`archive`/`ctx_shell` still teach the
180/// `ctx_expand` path in context.
181pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
182    Need full/exact? Read the shown file path with any tool (no MCP), or \
183    ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
184
185/// Terse injected variant of [`RECOVER`] (FULL + COMPACT/Bare). The cold
186/// first-contact handshake renders the COMPACT profile, so this one-liner keeps
187/// the static char/token budget (`tests/intensive_benchmarks.rs`,
188/// `instructions.rs`); since v5 the FULL dedicated files carry it too (#578).
189/// Keeps the two primary MCP-optional paths and the "never line-by-line" rule.
190/// Must keep the `(no MCP)` clause (asserted in tests).
191pub const RECOVER_COMPACT: &str = "RECOVER: compression is reversible — read the shown path \
192    (no MCP) or ctx_read(raw=true), never re-read line-by-line.";
193
194/// Context Engineering Protocol version reference.
195pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
196     4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
197
198/// Output style rule.
199pub const INTELLIGENCE: &str =
200    "OUTPUT: never echo tool output, no narration comments, show only changed code.";
201
202/// LITM end-of-instructions preference line.
203pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
204     ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
205
206/// Minimal rules body for shadow mode (#963). Under shadow-mode interception
207/// native Read/Grep/Shell/Glob calls are transparently routed to ctx_*, so the
208/// tool-mapping and "use ctx_* instead of native" guidance is dead weight — the
209/// enforcement happens at the call layer, not in the prompt. Only the lean-ctx
210/// tools that have *no* native trigger to intercept still need advertising.
211pub const SHADOW_MINIMAL: &str = "\
212lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
213Exclusive tools (no native trigger): ctx_compose (understand code, call first), ctx_symbol (exact symbol), ctx_callgraph (callers), ctx_semantic_search (by meaning), ctx_knowledge / ctx_session (memory).";
214
215/// Hook-covered header (GL #1153): the honest replacement for the
216/// `CRITICAL`/`BULLETS`/`NEVER` mapping on hosts whose *installed hooks*
217/// already compress the native tools (Cursor: `preToolUse` rewrite covers
218/// Shell, redirect covers Read/Grep). There a "NEVER use native tools" rule
219/// fights the host's own tool guidance and is unenforceable — the model calls
220/// native tools anyway and the hooks compress them transparently. Saying so
221/// removes the instruction dissonance instead of losing the battle silently.
222pub const HOOK_COVERED_HEADER: &str = "\
223lean-ctx hooks cover this session: native Shell, Read and Grep are compressed \
224transparently (PreToolUse rewrite/redirect) — using them is fine for single known files.\n\
225CRITICAL: ALWAYS call ctx_compose FIRST to orient before scattering individual \
226Read/Grep calls. ACTUALLY EMIT the call — describing it is not calling it.";
227
228/// The tools worth an explicit MCP call on a hook-covered host: capabilities
229/// with *no* native equivalent the hooks could intercept. Kept in sync with
230/// [`SHADOW_MINIMAL`]'s exclusive-tools line (same rationale, different cause).
231pub const HOOK_COVERED_TOOLS: &str = "\
232MANDATORY ctx_* tools (no native equivalent):\n\
233• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
234• ctx_search(action=symbol|semantic) — exact definitions or search by meaning, not pattern\n\
235• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
236• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
237• ctx_expand — recover full text from [Archived]/compressed output";
238
239// ── Output-style compression prompts ───────────────────────────
240
241/// Lite compression — concise, bullet-point output.
242pub const LITE_PROMPT: &str = "\
243OUTPUT STYLE: concise
244- Bullet points over paragraphs
245- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
246- 1-sentence explanations max, then code/action
247- No repeating what the user said";
248
249/// Standard compression — dense, atomic fact lines, abbreviations.
250pub const STANDARD_PROMPT: &str = "\
251OUTPUT STYLE: dense
252- Each statement = one atomic fact line
253- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
254- Diff lines only (+/-/~), never repeat unchanged code
255- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
256- No narration, no filler, no hedging
257- BUDGET: ≤200 tokens per response unless code block required";
258
259/// Max compression — expert-terse, telegraph format, symbolic vocabulary.
260pub const MAX_PROMPT: &str = "\
261OUTPUT STYLE: expert-terse
262- Telegraph format: subject-verb-object, drop articles/prepositions
263- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
264- Code blocks: untouched (never compress code syntax)
265- Each line: max 80 chars
266- Zero narration, zero filler
267- BUDGET: ≤100 tokens per non-code response";
268
269/// Return the compression prompt text for a given level (empty string for Off).
270pub fn compression_text(level: CompressionLevel) -> &'static str {
271    match level {
272        CompressionLevel::Off => "",
273        CompressionLevel::Lite => LITE_PROMPT,
274        CompressionLevel::Standard => STANDARD_PROMPT,
275        CompressionLevel::Max => MAX_PROMPT,
276    }
277}
278
279/// The verbose teaching profile — only the on-demand project `LEAN-CTX.md`
280/// carries it (#578). Keeps every section, including the ones the injected
281/// profiles fold away (loop taxonomy, navigation paradox, verbose recovery,
282/// CEP protocol).
283const LONGFORM_NON_SHADOW: &[&str] = &[
284    CRITICAL,
285    MUST_INVOKE,
286    BULLETS,
287    NEVER,
288    INTENT,
289    AGENT_LOOP,
290    ANTI,
291    NAV_PARADOX,
292    PARALLEL,
293    AUTO,
294    RECOVER,
295    CEP,
296    INTELLIGENCE,
297    LITM_END,
298];
299
300/// The injected dedicated-file profile. Billed on every session, so v5 (#578)
301/// keeps it at ~470 tokens: INTENT absorbs loop + paradox, RECOVER_COMPACT
302/// replaces the verbose block, CEP moves to LONGFORM.
303const FULL_NON_SHADOW: &[&str] = &[
304    CRITICAL,
305    MUST_INVOKE,
306    BULLETS,
307    NEVER,
308    INTENT,
309    ANTI,
310    PARALLEL,
311    AUTO,
312    RECOVER_COMPACT,
313    INTELLIGENCE,
314    LITM_END,
315];
316
317// #963: shadow profiles collapse to the irreducible minimum. Every routing
318// section (INTENT/ANTI/PARALLEL/AUTO/CEP/LITM_END) is redundant once native
319// calls are intercepted; only SHADOW_MINIMAL (exclusive tools) plus the output
320// style survive. Footprint reduction is provable via the #959 delta harness.
321const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
322
323// GL #1153: the hook-covered profile — for hosts whose installed lean-ctx
324// hooks already compress the native tools (Cursor). Like shadow, the
325// tool-mapping ("NEVER use native …") is dropped: it is unenforceable against
326// the host's own tool guidance and the hooks make it unnecessary. Unlike
327// shadow, the coverage is partial (hooks see Shell/Read/Grep but not e.g.
328// semantic questions), so the exclusive-capability advert is a full section
329// and the recovery line stays.
330const HOOK_COVERED_NON_SHADOW: &[&str] = &[
331    HOOK_COVERED_HEADER,
332    HOOK_COVERED_TOOLS,
333    PARALLEL,
334    RECOVER_COMPACT,
335    INTELLIGENCE,
336];
337
338const COMPACT_NON_SHADOW: &[&str] = &[
339    CRITICAL,
340    BULLETS,
341    NEVER,
342    INTENT,
343    ANTI,
344    PARALLEL,
345    RECOVER_COMPACT,
346];
347
348const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
349
350/// Selects the profile (LONGFORM / FULL / COMPACT) and the wrapping style
351/// (markers, headers, footers) for `render()`.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum Wrapper {
354    /// **On-demand long form** (project `LEAN-CTX.md`). LONGFORM profile with
355    /// the same marker wrapping as `Dedicated`. Not auto-loaded by any client
356    /// — agents open it on demand via the AGENTS.md pointer, so it can afford
357    /// the verbose teaching sections.
358    Longform,
359
360    /// **Dedicated rule file.**  FULL profile.  Wrapped with `START_MARK`,
361    /// `<!-- version: N -->`, and `END_MARK`.  Non-shadow includes the
362    /// `CRITICAL` banner before the body.  The whole file is lean-ctx–owned;
363    /// the injection layer detects staleness by parsing the version comment.
364    Dedicated,
365
366    /// **Shared file section** (appended to AGENTS.md, GEMINI.md, etc.).
367    /// COMPACT profile.  Same marker wrapping for find/replace within a
368    /// larger shared file.  Non-shadow includes `## Tool Mapping` header.
369    Shared,
370
371    /// **MCP session instructions.**  COMPACT profile.  No markers or
372    /// headers — bare content used inline in per-session MCP instructions.
373    Bare,
374
375    /// **Hook-covered dedicated rule file** (GL #1153). For hosts whose
376    /// installed lean-ctx hooks already compress the native tools (Cursor:
377    /// PreToolUse rewrite/redirect). Same marker/version wrapping as
378    /// `Dedicated`, but the body swaps the unenforceable tool-mapping for the
379    /// honest hook-coverage note plus the exclusive-capability advert.
380    /// Shadow mode collapses it to the same minimal profile as `Dedicated`.
381    HookCovered,
382}
383
384/// Render lean-ctx rules for a given wrapper, shadow mode, and compression level.
385///
386/// * `shadow` — when true, tool-mapping sections (BULLETS, NEVER,
387///   CRITICAL banner, "## Tool Mapping" header) are omitted.
388/// * `wrapper` — selects the profile (FULL / COMPACT) and wrapping style.
389/// * `level` — selects the output-style compression prompt (Lite / Standard /
390///   Max) which is appended to the body. `Off` omits it.
391pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
392    let profile = match (wrapper, shadow) {
393        (Wrapper::Longform, false) => LONGFORM_NON_SHADOW,
394        // Shadow collapses Longform + Dedicated + HookCovered to the same
395        // minimal profile (interception supersedes hook coverage).
396        (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => FULL_SHADOW,
397        (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
398        (Wrapper::HookCovered, false) => HOOK_COVERED_NON_SHADOW,
399        (_, false) => COMPACT_NON_SHADOW,
400        (_, true) => COMPACT_SHADOW,
401    };
402
403    let mut body = profile.join("\n\n");
404
405    // Append the compression / output-style prompt for active levels. Persistent
406    // carriers (Dedicated, Shared) wrap it in the canonical COMPRESSION_BLOCK
407    // markers so coverage/dedup (rules_channel, rules_dedup) can detect and thin
408    // it; the ephemeral Bare MCP channel keeps it unmarked (#684/#548).
409    let compression = compression_text(level);
410    if !compression.is_empty() {
411        body.push('\n');
412        if matches!(wrapper, Wrapper::Bare) {
413            body.push_str(compression);
414        } else {
415            body.push_str(COMPRESSION_BLOCK_START);
416            body.push('\n');
417            body.push_str(compression);
418            body.push('\n');
419            body.push_str(COMPRESSION_BLOCK_END);
420        }
421    }
422
423    if matches!(wrapper, Wrapper::Bare) {
424        return body;
425    }
426
427    let version_line = format!("<!-- version: {RULES_VERSION} -->");
428
429    format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
430}
431
432/// Unmarked render of the hook-covered profile for ephemeral channels
433/// (the mcp.json `instructions` snapshot on hook-covered hosts, GL #1153).
434/// The `Bare` counterpart of `Wrapper::HookCovered`: same body, no markers —
435/// per-session channels are governed by carrier coverage, so markers would be
436/// noise (see [`COMPRESSION_BLOCK_START`]). Shadow collapses to the regular
437/// bare shadow profile (interception supersedes hook coverage).
438pub fn render_hook_covered_bare(shadow: bool, level: CompressionLevel) -> String {
439    if shadow {
440        return render(true, Wrapper::Bare, level);
441    }
442    let mut body = HOOK_COVERED_NON_SHADOW.join("\n\n");
443    let compression = compression_text(level);
444    if !compression.is_empty() {
445        body.push('\n');
446        body.push_str(compression);
447    }
448    body
449}
450// ============================================================
451// RULES FILE — centralized interface for reading rule files
452// ============================================================
453
454/// A parsed lean-ctx rules section from a file on disk.
455///
456/// Handles version detection, content boundary discovery, and prefix/suffix
457/// extraction.  This is the **only** place that parses `START_MARK` / version
458/// comments — every consumer (injection, drift detection, status reporting)
459/// goes through this struct.
460#[derive(Debug)]
461pub struct RulesFile<'a> {
462    content: &'a str,
463    /// Byte offset of `START_MARK` (or the first old-format marker found).
464    start: Option<usize>,
465    /// Byte offset of `END_MARK`.
466    end: Option<usize>,
467    /// Parsed version number (0 if no `<!-- version: N -->` comment found).
468    version: usize,
469}
470
471/// Parse the version number from the first `<!-- version: N -->` comment
472/// found at or after `search_start`.
473fn parse_version_number(s: &str) -> Option<usize> {
474    let prefix = "<!-- version: ";
475    let vs = s.find(prefix)?;
476    let num_start = vs + prefix.len();
477    let end = s[num_start..].find(" -->")?;
478    s[num_start..num_start + end].parse().ok()
479}
480
481impl<'a> RulesFile<'a> {
482    /// Parse `content`, scanning for `START_MARK` and version comment.
483    ///
484    /// * `START_MARK` not found → `has_content() = false`, version = 0.
485    /// * `START_MARK` found but no version → `has_content() = true`, version = 0
486    ///   (assume older than current → needs update).
487    pub fn parse(content: &'a str) -> Self {
488        let start = content.find(START_MARK);
489        let version = start
490            .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
491            .unwrap_or(0);
492        let end = content.find(END_MARK);
493        RulesFile {
494            content,
495            start,
496            end,
497            version,
498        }
499    }
500
501    /// Whether the file carries any lean-ctx rules content.
502    pub fn has_content(&self) -> bool {
503        self.start.is_some()
504    }
505
506    /// The detected version (0 if no version marker — treat as older than
507    /// `RULES_VERSION`).
508    pub fn version(&self) -> usize {
509        self.version
510    }
511
512    /// Whether the file's version is at least `RULES_VERSION`.
513    pub fn is_current(&self) -> bool {
514        self.version >= RULES_VERSION
515    }
516
517    /// Content before the first `START_MARK` (user content / frontmatter).
518    /// Returns an empty string if no start marker was found.
519    pub fn prefix(&self) -> &'a str {
520        self.start.map_or("", |s| self.content[..s].trim())
521    }
522
523    /// Content after the last `END_MARK` (user content after the lean-ctx
524    /// block).  Returns an empty string if no end marker was found.
525    pub fn suffix(&self) -> &'a str {
526        self.end
527            .map_or("", |e| self.content[e + END_MARK.len()..].trim())
528    }
529
530    /// The lean-ctx block on disk, from `START_MARK` through `END_MARK`
531    /// (inclusive), if both markers are present.
532    fn block(&self) -> Option<&'a str> {
533        match (self.start, self.end) {
534            (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
535            _ => None,
536        }
537    }
538
539    /// Whether the on-disk block is already byte-identical (ignoring surrounding
540    /// whitespace) to a fresh [`render`] for these parameters.
541    ///
542    /// [`is_current`](Self::is_current) only compares the embedded
543    /// `<!-- version: N -->` against [`RULES_VERSION`], so a change that keeps
544    /// the version but alters the rendered body — toggling `shadow_mode`,
545    /// switching `compression_level`, or editing a canonical section without a
546    /// version bump — would otherwise be skipped by the injector. Callers pair
547    /// this with `is_current()` to detect that content/compression drift (#548).
548    pub fn block_matches_render(
549        &self,
550        shadow: bool,
551        wrapper: Wrapper,
552        level: CompressionLevel,
553    ) -> bool {
554        match self.block() {
555            Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
556            None => false,
557        }
558    }
559
560    /// Merge freshly-rendered rules into this file.
561    ///
562    /// * If a lean-ctx section exists → replaces content between `START_MARK`
563    ///   and `END_MARK`, preserving user content before/after.
564    /// * If no section exists → appends fresh content at the end.
565    pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
566        let fresh = render(shadow, wrapper, level);
567        if self.start.is_some() {
568            let before = self.prefix();
569            let after = self.suffix();
570            let mut out = String::new();
571            if !before.is_empty() {
572                out.push_str(before);
573                out.push('\n');
574                out.push('\n');
575            }
576            out.push_str(&fresh);
577            if !after.is_empty() {
578                out.push('\n');
579                out.push('\n');
580                out.push_str(after);
581            }
582            if !out.ends_with('\n') {
583                out.push('\n');
584            }
585            out
586        } else {
587            // No existing section — append.
588            let trimmed = self.content.trim_end();
589            let mut out = trimmed.to_string();
590            if !out.is_empty() {
591                out.push('\n');
592                out.push('\n');
593            }
594            out.push_str(&fresh);
595            out
596        }
597    }
598
599    /// Create initial rules content (no existing section to merge with).
600    pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
601        render(shadow, wrapper, level)
602    }
603
604    // ── Delete ─────────────────────────────────────────────────
605
606    /// Strip the lean-ctx section, keeping only user content before/after.
607    pub fn without_section(&self) -> String {
608        if let Some(start_pos) = self.start {
609            let before = self.content[..start_pos].trim();
610            let after = self.suffix();
611            let mut out = String::new();
612            if !before.is_empty() {
613                out.push_str(before);
614                out.push('\n');
615            }
616            if !after.is_empty() {
617                out.push('\n');
618                out.push_str(after);
619            }
620            out
621        } else {
622            self.content.to_string()
623        }
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    // --- Constants ---
632
633    #[test]
634    fn bullets_uses_ctx_shell() {
635        assert!(BULLETS.contains("ctx_shell"));
636        assert!(!BULLETS.contains("lean-ctx -c"));
637        assert!(!BULLETS.contains("ctx_edit"));
638    }
639
640    #[test]
641    fn sections_not_empty() {
642        assert!(!BULLETS.is_empty());
643        assert!(!NEVER.is_empty());
644        assert!(!INTENT.is_empty());
645        assert!(!ANTI.is_empty());
646        assert!(!PARALLEL.is_empty());
647        assert!(!AUTO.is_empty());
648        assert!(!CEP.is_empty());
649        assert!(!INTELLIGENCE.is_empty());
650        assert!(!LITM_END.is_empty());
651        assert!(!CRITICAL.is_empty());
652    }
653
654    #[test]
655    fn intent_contains_ctx_compose() {
656        assert!(INTENT.contains("ctx_compose"));
657    }
658
659    #[test]
660    fn anti_contains_do_not() {
661        assert!(ANTI.contains("do NOT"));
662    }
663
664    #[test]
665    fn parallel_contains_parallel() {
666        assert!(PARALLEL.contains("PARALLEL"));
667    }
668
669    // --- Agent loop + navigation paradox (#609) ---
670
671    #[test]
672    fn agent_loop_names_every_phase() {
673        for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
674            assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
675        }
676        assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
677    }
678
679    #[test]
680    fn nav_paradox_steers_semantic_vs_graph() {
681        assert!(
682            NAV_PARADOX.contains("ctx_semantic_search"),
683            "semantic route"
684        );
685        assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
686        assert!(
687            NAV_PARADOX.contains("≠"),
688            "must carry the reading≠understanding thesis"
689        );
690    }
691
692    #[test]
693    fn longform_carries_loop_and_paradox_injected_full_does_not() {
694        // v5 (#578): the verbose teaching sections live only in the on-demand
695        // LEAN-CTX.md (Longform); the injected dedicated files fold the loop
696        // phases + paradox thesis into INTENT.
697        let long = render(false, Wrapper::Longform, CompressionLevel::Off);
698        assert!(
699            long.contains("AGENT LOOP"),
700            "LONGFORM must carry AGENT_LOOP"
701        );
702        assert!(
703            long.contains("NAVIGATION PARADOX"),
704            "LONGFORM must carry NAV_PARADOX"
705        );
706        assert!(long.contains(CEP), "LONGFORM must carry CEP");
707
708        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
709        assert!(
710            !full.contains("AGENT LOOP (phase -> tool):"),
711            "injected FULL must not inline the multi-line AGENT_LOOP block"
712        );
713        assert!(
714            !full.contains("NAVIGATION PARADOX: reading"),
715            "injected FULL must not inline the multi-line NAV_PARADOX block"
716        );
717        assert!(
718            full.contains('≠'),
719            "INTENT must keep the reading≠understanding thesis in FULL"
720        );
721        for phase_tool in ["ctx_compose", "ctx_symbol", "ctx_search", "ctx_callgraph"] {
722            assert!(
723                full.contains(phase_tool),
724                "FULL keeps the loop tools via INTENT: {phase_tool}"
725            );
726        }
727    }
728
729    #[test]
730    fn injected_profiles_stay_lean() {
731        // The whole point of v5 (#578): injected files bill every session.
732        // chars/4 ≈ tokens — the dedicated body must stay around ~470 tok and
733        // the Longform must be a strict superset.
734        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
735        eprintln!(
736            "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
737            full.len(),
738            full.len() / 4,
739            render(false, Wrapper::Longform, CompressionLevel::Off).len(),
740            render(false, Wrapper::Bare, CompressionLevel::Off).len(),
741        );
742        assert!(
743            full.len() <= 2100,
744            "injected dedicated rules must stay ≤2100 chars (~525 tok), got {} chars (~{} tok)",
745            full.len(),
746            full.len() / 4
747        );
748        let long = render(false, Wrapper::Longform, CompressionLevel::Off);
749        assert!(
750            long.len() > full.len(),
751            "Longform ({}) must carry more than the injected profile ({})",
752            long.len(),
753            full.len()
754        );
755        let compact = render(false, Wrapper::Bare, CompressionLevel::Off);
756        assert!(
757            compact.len() < full.len(),
758            "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
759            compact.len(),
760            full.len()
761        );
762    }
763
764    #[test]
765    fn compact_profile_has_no_multiline_teaching_sections() {
766        // COMPACT (shared + Bare) keeps the per-session channel lean: no
767        // multi-line AGENT_LOOP/NAV_PARADOX blocks; INTENT carries the thesis.
768        let out = render(false, Wrapper::Shared, CompressionLevel::Off);
769        assert!(
770            !out.contains("AGENT LOOP (phase -> tool):"),
771            "COMPACT must not inline the multi-line AGENT_LOOP block"
772        );
773        assert!(
774            !out.contains("NAVIGATION PARADOX: reading"),
775            "COMPACT must not inline the multi-line NAV_PARADOX block"
776        );
777        assert!(
778            out.contains('≠'),
779            "COMPACT keeps the reading≠understanding thesis via INTENT"
780        );
781    }
782
783    #[test]
784    fn shadow_omits_loop_and_paradox() {
785        // #963: shadow collapses to the irreducible minimum — the routing
786        // taxonomy is redundant once native calls are intercepted.
787        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
788            let out = render(true, wrapper, CompressionLevel::Off);
789            assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
790            assert!(
791                !out.contains("NAVIGATION PARADOX"),
792                "{wrapper:?} shadow drops paradox"
793            );
794        }
795    }
796
797    #[test]
798    fn recover_reaches_every_non_shadow_carrier() {
799        // The recovery vocabulary must reach every non-shadow carrier so agents
800        // never re-read compressed output line-by-line, and every carrier must
801        // keep the MCP-free path ("read the shown path") for orgs that ban MCP.
802        // v5 (#578): only Longform carries the verbose RECOVER; every injected
803        // profile (Dedicated FULL + Shared/Bare COMPACT) carries the terse
804        // RECOVER_COMPACT one-liner.
805        let long = render(false, Wrapper::Longform, CompressionLevel::Off);
806        assert!(
807            long.contains(RECOVER),
808            "Longform must carry the verbose RECOVER verbatim"
809        );
810        for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
811            let out = render(false, wrapper, CompressionLevel::Off);
812            assert!(
813                out.contains(RECOVER_COMPACT),
814                "{wrapper:?} must carry RECOVER_COMPACT verbatim"
815            );
816            assert!(
817                !out.contains(RECOVER),
818                "{wrapper:?} must not inline the verbose RECOVER block"
819            );
820        }
821        for wrapper in [
822            Wrapper::Longform,
823            Wrapper::Dedicated,
824            Wrapper::Shared,
825            Wrapper::Bare,
826        ] {
827            assert!(
828                render(false, wrapper, CompressionLevel::Off).contains("(no MCP)"),
829                "{wrapper:?} recovery line must keep the MCP-free path"
830            );
831        }
832        // Shadow stays minimal; the reactive footers still cover recovery there.
833        for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
834            let out = render(true, wrapper, CompressionLevel::Off);
835            assert!(
836                !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
837                "{wrapper:?} shadow drops all RECOVER guidance"
838            );
839        }
840    }
841
842    // --- render() — Dedicated ---
843
844    #[test]
845    fn dedicated_has_markers_and_version() {
846        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
847        assert!(out.contains(START_MARK));
848        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
849        assert!(out.contains(END_MARK));
850        assert!(out.contains(BULLETS));
851        assert!(out.contains(NEVER));
852        assert!(out.contains("CRITICAL"));
853    }
854
855    #[test]
856    fn dedicated_shadow_is_minimal() {
857        // #963: shadow drops the whole tool-mapping AND routing playbook —
858        // interception makes them redundant. Only the exclusive-tool advert and
859        // the output style remain.
860        let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
861        assert!(out.contains(START_MARK));
862        assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
863        assert!(!out.contains(NEVER), "no NEVER in shadow");
864        assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
865        assert!(
866            !out.contains("Tool selection by intent"),
867            "routing INTENT block is redundant under interception"
868        );
869        assert!(
870            !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
871            "ANTI/PARALLEL routing guidance is dropped in shadow"
872        );
873        assert!(
874            out.contains("shadow mode") && out.contains("ctx_compose"),
875            "shadow keeps the exclusive-tool advert"
876        );
877        assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
878    }
879
880    #[test]
881    fn shadow_is_smaller_than_non_shadow() {
882        // The whole point of #963: the shadow body must be a strict reduction.
883        let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
884        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
885        assert!(
886            shadow.len() < full.len(),
887            "shadow ({}) must be smaller than non-shadow ({})",
888            shadow.len(),
889            full.len()
890        );
891    }
892
893    #[test]
894    fn dedicated_litm_structure() {
895        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
896        let lines: Vec<&str> = out.lines().collect();
897        let first_5 = lines[..5.min(lines.len())].join("\n");
898        assert!(
899            first_5.contains("CRITICAL") || first_5.contains("MUST"),
900            "LITM: MUST/CRITICAL instruction near start"
901        );
902        // LITM_END or NEVER should appear in the final content lines (before END_MARK).
903        let tail = lines[lines.len().saturating_sub(8)..].join("\n");
904        assert!(
905            tail.contains("PREFERENCE") || tail.contains("NEVER"),
906            "LITM: reinforcement near end, tail={tail:?}"
907        );
908    }
909
910    #[test]
911    fn dedicated_carries_weak_model_invoke_nudge() {
912        // #1067/GH #593: the "actually CALL ctx_*" nudge must ride every dedicated
913        // rule file (Windsurf, Cursor, Claude, …) in non-shadow mode, and must be
914        // absent where it is dead weight: shadow mode (call-layer routing) and the
915        // Bare/instructions channel (separately capped).
916        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off);
917        assert!(
918            dedicated.contains(MUST_INVOKE),
919            "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
920        );
921        assert!(
922            !render(true, Wrapper::Dedicated, CompressionLevel::Off).contains(MUST_INVOKE),
923            "shadow mode must not carry the nudge (routing is enforced at the call layer)"
924        );
925        assert!(
926            !render(false, Wrapper::Bare, CompressionLevel::Off).contains(MUST_INVOKE),
927            "Bare/instructions channel is capped separately and carries no copy"
928        );
929    }
930
931    // --- render() — Shared ---
932
933    #[test]
934    fn shared_has_markers_and_header() {
935        let out = render(false, Wrapper::Shared, CompressionLevel::Off);
936        assert!(out.contains(START_MARK));
937        assert!(out.contains(END_MARK));
938        assert!(out.contains("MANDATORY MAPPING"));
939        assert!(out.contains(BULLETS));
940    }
941
942    #[test]
943    fn shared_shadow_omits_mapping() {
944        let out = render(true, Wrapper::Shared, CompressionLevel::Off);
945        assert!(out.contains(START_MARK));
946        assert!(
947            !out.contains("MANDATORY MAPPING"),
948            "shadow must not have header"
949        );
950        assert!(
951            !out.contains("MANDATORY MAPPING"),
952            "shadow must not contain BULLETS"
953        );
954    }
955
956    // --- render() — Bare ---
957
958    #[test]
959    fn bare_has_no_markers() {
960        let out = render(false, Wrapper::Bare, CompressionLevel::Off);
961        assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
962        assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
963        assert!(!out.contains("<!-- version:"), "Bare must not have version");
964        assert!(out.contains(BULLETS));
965        assert!(out.contains(NEVER));
966    }
967
968    #[test]
969    fn bare_shadow_only_read_modes() {
970        let out = render(true, Wrapper::Bare, CompressionLevel::Off);
971        assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
972        assert!(
973            !out.contains("MANDATORY MAPPING"),
974            "shadow Bare must not have BULLETS"
975        );
976    }
977
978    // --- Compression level tests ---
979
980    #[test]
981    fn render_includes_lite_prompt() {
982        let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
983        assert!(out.contains("OUTPUT STYLE: concise"));
984        assert!(out.contains("Bullet points"));
985    }
986
987    #[test]
988    fn render_includes_standard_prompt() {
989        let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
990        assert!(out.contains("OUTPUT STYLE: dense"));
991        assert!(out.contains("atomic fact"));
992    }
993
994    #[test]
995    fn render_includes_max_prompt() {
996        let out = render(false, Wrapper::Bare, CompressionLevel::Max);
997        assert!(out.contains("OUTPUT STYLE: expert-terse"));
998        assert!(out.contains("Telegraph"));
999    }
1000
1001    #[test]
1002    fn render_off_excludes_compression() {
1003        let out = render(false, Wrapper::Bare, CompressionLevel::Off);
1004        assert!(!out.contains("OUTPUT STYLE:"));
1005    }
1006
1007    #[test]
1008    fn compression_text_matches_level() {
1009        assert!(compression_text(CompressionLevel::Off).is_empty());
1010        assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1011        assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1012        assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1013    }
1014
1015    // --- Compression marker model (#548 B2) ---
1016
1017    #[test]
1018    fn carrier_wrappers_wrap_compression_in_markers() {
1019        // Persistent carriers must delimit the compression payload so coverage
1020        // and dedup can detect/thin it (#684/#548).
1021        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1022            let out = render(false, wrapper, CompressionLevel::Standard);
1023            assert!(
1024                out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1025                "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1026            );
1027            // The marked region must actually contain the prompt body.
1028            let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1029            let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1030            assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1031            assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1032        }
1033    }
1034
1035    #[test]
1036    fn bare_wrapper_emits_compression_without_markers() {
1037        // The ephemeral MCP channel keeps the payload unmarked — its inclusion is
1038        // governed by carrier coverage, so per-session markers would be noise.
1039        let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
1040        assert!(out.contains("OUTPUT STYLE: dense"));
1041        assert!(!out.contains(COMPRESSION_BLOCK_START));
1042        assert!(!out.contains(COMPRESSION_BLOCK_END));
1043    }
1044
1045    #[test]
1046    fn compression_off_emits_no_markers_in_any_wrapper() {
1047        for wrapper in [
1048            Wrapper::Longform,
1049            Wrapper::Dedicated,
1050            Wrapper::Shared,
1051            Wrapper::Bare,
1052        ] {
1053            let out = render(false, wrapper, CompressionLevel::Off);
1054            assert!(
1055                !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1056                "{wrapper:?}: Off must emit no compression markers"
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn rendered_carrier_block_is_seen_as_carrying_compression() {
1063        // The detection helper that coverage/dedup rely on must agree with the
1064        // writer's output (the bug this slice fixes: it previously never did).
1065        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1066        assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1067        assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1068    }
1069
1070    // --- Wrapper round-trip ---
1071
1072    #[test]
1073    fn all_wrappers_produce_output() {
1074        for shadow in [false, true] {
1075            for wrapper in [
1076                Wrapper::Longform,
1077                Wrapper::Dedicated,
1078                Wrapper::Shared,
1079                Wrapper::Bare,
1080            ] {
1081                let out = render(shadow, wrapper, CompressionLevel::Off);
1082                assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1083            }
1084        }
1085    }
1086
1087    // --- RulesFile ---
1088
1089    #[test]
1090    fn rules_file_parses_version() {
1091        let content = format!(
1092            "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1093        );
1094        let f = RulesFile::parse(&content);
1095        assert!(f.has_content());
1096        assert_eq!(f.version(), RULES_VERSION);
1097        assert!(f.is_current());
1098        assert!(f.prefix().contains("stuff before"));
1099        assert!(f.suffix().contains("stuff after"));
1100    }
1101
1102    #[test]
1103    fn rules_file_no_version_defaults_to_zero() {
1104        let content = format!("{START_MARK}\nbody\n{END_MARK}");
1105        let f = RulesFile::parse(&content);
1106        assert!(f.has_content());
1107        assert_eq!(f.version(), 0);
1108        assert!(!f.is_current());
1109    }
1110
1111    #[test]
1112    fn rules_file_no_start_marker_no_content() {
1113        let f = RulesFile::parse("just user stuff");
1114        assert!(!f.has_content());
1115        assert_eq!(f.version(), 0);
1116    }
1117
1118    #[test]
1119    fn block_matches_render_true_for_fresh_render() {
1120        let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1121        let content = format!("user before\n{fresh}\nuser after");
1122        let f = RulesFile::parse(&content);
1123        assert!(f.is_current(), "fresh render carries the current version");
1124        assert!(
1125            f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
1126            "an unchanged block must compare equal to a fresh render"
1127        );
1128    }
1129
1130    #[test]
1131    fn block_matches_render_false_on_compression_change() {
1132        // Body rendered at Off, then asked whether it matches a Max render:
1133        // the version is identical but the compression payload differs (#548).
1134        let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1135        let f = RulesFile::parse(&content);
1136        assert!(f.is_current());
1137        assert!(
1138            !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
1139            "a compression-level change must be detected as drift"
1140        );
1141    }
1142
1143    #[test]
1144    fn block_matches_render_false_on_shadow_change() {
1145        let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1146        let f = RulesFile::parse(&content);
1147        assert!(
1148            !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
1149            "a shadow-mode toggle must be detected as drift"
1150        );
1151    }
1152
1153    #[test]
1154    fn block_matches_render_false_without_block() {
1155        let f = RulesFile::parse("plain user content, no markers");
1156        assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
1157    }
1158
1159    #[test]
1160    fn rules_file_merged_replaces_section() {
1161        let content =
1162            format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1163        let f = RulesFile::parse(&content);
1164        let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
1165        assert!(merged.contains("before"), "prefix preserved");
1166        assert!(merged.contains("after"), "suffix preserved");
1167        assert!(!merged.contains("old"), "old content replaced");
1168        assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1169    }
1170
1171    #[test]
1172    fn rules_file_merged_appends_when_no_section() {
1173        let content = "user content";
1174        let f = RulesFile::parse(content);
1175        assert!(!f.has_content());
1176        let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
1177        assert!(merged.contains("user content"));
1178        assert!(merged.contains(BULLETS));
1179    }
1180
1181    #[test]
1182    fn rules_file_without_section_strips_content() {
1183        let content =
1184            format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1185        let f = RulesFile::parse(&content);
1186        let stripped = f.without_section();
1187        assert!(stripped.contains("header"));
1188        assert!(stripped.contains("footer"));
1189        assert!(!stripped.contains("body"));
1190        assert!(!stripped.contains(START_MARK));
1191    }
1192
1193    #[test]
1194    fn rules_file_without_section_noop_when_no_content() {
1195        let content = "just user text";
1196        let f = RulesFile::parse(content);
1197        assert_eq!(f.without_section(), content);
1198    }
1199
1200    #[test]
1201    fn bullets_lead_with_four_core_redirects() {
1202        // Most-used routes (Read/Grep/Shell/Glob) lead; ls->ctx_tree trails.
1203        let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1204        let search = BULLETS
1205            .find("ctx_search")
1206            .expect("ctx_search mapping present");
1207        let shell = BULLETS
1208            .find("ctx_shell")
1209            .expect("ctx_shell mapping present");
1210        let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1211        let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1212        assert!(
1213            read < search && search < shell && shell < glob && glob < tree,
1214            "core redirects (read<search<shell<glob) must precede ctx_tree"
1215        );
1216    }
1217
1218    #[test]
1219    fn never_carries_self_correction() {
1220        // Self-correction reinforces the redirect harder than a bare prohibition.
1221        assert!(
1222            NEVER.contains("SELF-CORRECT"),
1223            "NEVER must teach self-correction"
1224        );
1225        assert!(
1226            NEVER.contains("call"),
1227            "NEVER must spell out the corrective action"
1228        );
1229    }
1230
1231    #[test]
1232    fn critical_names_ctx_family() {
1233        assert!(
1234            CRITICAL.contains("ctx_*"),
1235            "CRITICAL must name the ctx_* family"
1236        );
1237    }
1238
1239    // --- HookCovered profile (GL #1153) ---
1240
1241    #[test]
1242    fn hook_covered_drops_unenforceable_mapping() {
1243        // The whole point: on a hook-covered host the "NEVER use native"
1244        // mapping fights the host's own tool guidance. The profile must
1245        // acknowledge the hooks instead of demanding the impossible.
1246        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1247        assert!(
1248            !out.contains("MANDATORY MAPPING") && !out.contains(NEVER) && !out.contains(CRITICAL),
1249            "HookCovered must not carry the native-tool prohibition"
1250        );
1251        assert!(
1252            out.contains(HOOK_COVERED_HEADER),
1253            "must state that hooks compress native tools"
1254        );
1255        assert!(
1256            out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1257            "must advertise the exclusive capabilities"
1258        );
1259    }
1260
1261    #[test]
1262    fn hook_covered_keeps_markers_version_and_recovery() {
1263        // Coverage detection (rules_channel::carries_full_rules /
1264        // client_autoloads_rules) and the injector's drift check both key on
1265        // the canonical markers — HookCovered must stay a first-class carrier.
1266        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1267        assert!(out.contains(START_MARK) && out.contains(END_MARK));
1268        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1269        assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1270        assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1271    }
1272
1273    #[test]
1274    fn hook_covered_is_leaner_than_full() {
1275        let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1276        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1277        assert!(
1278            covered.len() < full.len(),
1279            "HookCovered ({}) must be a strict reduction of FULL ({})",
1280            covered.len(),
1281            full.len()
1282        );
1283    }
1284
1285    #[test]
1286    fn hook_covered_shadow_collapses_to_minimal() {
1287        // Interception supersedes hook coverage — same minimal profile as
1288        // Dedicated shadow.
1289        let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off);
1290        let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
1291        assert_eq!(covered_shadow, dedicated_shadow);
1292    }
1293
1294    #[test]
1295    fn hook_covered_wraps_compression_in_markers() {
1296        // The compression payload keeps the carrier markers so cross-channel
1297        // dedup (cursor_compression_covered) recognises the mdc as covered.
1298        let out = render(false, Wrapper::HookCovered, CompressionLevel::Standard);
1299        assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1300        assert!(out.contains("OUTPUT STYLE: dense"));
1301    }
1302}