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 = 8;
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_search(action=symbol); pattern -> ctx_search; by meaning -> ctx_search(action=semantic)\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_search(action=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_search(action=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_search(action=semantic) (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_search(action=symbol) (exact symbol), ctx_callgraph (callers), ctx_search(action=semantic) (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 = "\
223CRITICAL: ALWAYS prefer lean-ctx ctx_* tools over native equivalents — ctx_* tools \
224provide superior caching, compression, and session memory. Hooks compress native \
225Shell/Read/Grep as fallback, but direct ctx_* calls are the primary path.\n\
226ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — describing a tool 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 MAPPING (always use ctx_* instead of native):\n\
233• Read/cat -> ctx_read(path, mode) — cached, 10 modes, re-reads ~13 tokens\n\
234• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic for definitions/meaning\n\
235• Shell/bash -> ctx_shell(command) — 95+ compression patterns\n\
236• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
237• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
238• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
239• ctx_expand — recover full text from [Archived]/compressed output";
240
241// ── Output-style compression prompts ───────────────────────────
242
243/// Lite compression — concise, bullet-point output.
244pub const LITE_PROMPT: &str = "\
245OUTPUT STYLE: concise
246- Bullet points over paragraphs
247- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
248- 1-sentence explanations max, then code/action
249- No repeating what the user said";
250
251/// Standard compression — dense, atomic fact lines, abbreviations.
252pub const STANDARD_PROMPT: &str = "\
253OUTPUT STYLE: dense
254- Each statement = one atomic fact line
255- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
256- Diff lines only (+/-/~), never repeat unchanged code
257- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
258- No narration, no filler, no hedging
259- BUDGET: ≤200 tokens per response unless code block required";
260
261/// Max compression — expert-terse, telegraph format, symbolic vocabulary.
262pub const MAX_PROMPT: &str = "\
263OUTPUT STYLE: expert-terse
264- Telegraph format: subject-verb-object, drop articles/prepositions
265- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
266- Code blocks: untouched (never compress code syntax)
267- Each line: max 80 chars
268- Zero narration, zero filler
269- BUDGET: ≤100 tokens per non-code response";
270
271/// Return the compression prompt text for a given level (empty string for Off).
272pub fn compression_text(level: CompressionLevel) -> &'static str {
273    match level {
274        CompressionLevel::Off => "",
275        CompressionLevel::Lite => LITE_PROMPT,
276        CompressionLevel::Standard => STANDARD_PROMPT,
277        CompressionLevel::Max => MAX_PROMPT,
278    }
279}
280
281// The verbose teaching profile — only the on-demand project `LEAN-CTX.md`
282// Static profile arrays removed (#756): replaced by the profile-aware
283// section-builder functions below (longform_non_shadow_sections, etc.).
284
285// ── Profile-aware section assembly (#756) ──────────────────────
286//
287// Each function returns Vec<String> with the same section ordering as the
288// static arrays above, but replaces tool-referencing constants with their
289// dynamic equivalents from `rules_sections`.
290
291fn s(c: &str) -> String {
292    c.to_string()
293}
294
295fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
296    use super::rules_sections as rs;
297    let mut v = vec![
298        s(CRITICAL),
299        s(MUST_INVOKE),
300        s(BULLETS),
301        s(NEVER),
302        rs::intent_section(p),
303        s(AGENT_LOOP), // verbose teaching — LONGFORM only
304        rs::anti_section(p),
305        s(NAV_PARADOX), // verbose teaching — LONGFORM only
306        s(PARALLEL),
307        s(AUTO),
308        s(RECOVER),
309        s(CEP),
310        s(INTELLIGENCE),
311        rs::litm_end_section(p),
312    ];
313    if let Some(fb) = rs::ctx_call_fallback(p) {
314        v.push(fb);
315    }
316    v
317}
318
319fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
320    use super::rules_sections as rs;
321    let mut v = vec![
322        s(CRITICAL),
323        s(MUST_INVOKE),
324        s(BULLETS),
325        s(NEVER),
326        rs::intent_section(p),
327        rs::anti_section(p),
328        s(PARALLEL),
329        s(AUTO),
330        s(RECOVER_COMPACT),
331        s(INTELLIGENCE),
332        rs::litm_end_section(p),
333    ];
334    if let Some(fb) = rs::ctx_call_fallback(p) {
335        v.push(fb);
336    }
337    v
338}
339
340fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
341    use super::rules_sections as rs;
342    vec![rs::shadow_minimal_section(p), s(INTELLIGENCE)]
343}
344
345fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
346    use super::rules_sections as rs;
347    let mut v = vec![
348        s(HOOK_COVERED_HEADER),
349        rs::hook_covered_tools_section(p),
350        s(PARALLEL),
351        s(RECOVER_COMPACT),
352        s(INTELLIGENCE),
353    ];
354    if let Some(fb) = rs::ctx_call_fallback(p) {
355        v.push(fb);
356    }
357    v
358}
359
360fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
361    use super::rules_sections as rs;
362    let mut v = vec![
363        s(CRITICAL),
364        s(BULLETS),
365        s(NEVER),
366        rs::intent_section(p),
367        rs::anti_section(p),
368        s(PARALLEL),
369        s(RECOVER_COMPACT),
370    ];
371    if let Some(fb) = rs::ctx_call_fallback(p) {
372        v.push(fb);
373    }
374    v
375}
376
377fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
378    use super::rules_sections as rs;
379    vec![rs::shadow_minimal_section(p)]
380}
381
382/// Selects the profile (LONGFORM / FULL / COMPACT) and the wrapping style
383/// (markers, headers, footers) for `render()`.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum Wrapper {
386    /// **On-demand long form** (project `LEAN-CTX.md`). LONGFORM profile with
387    /// the same marker wrapping as `Dedicated`. Not auto-loaded by any client
388    /// — agents open it on demand via the AGENTS.md pointer, so it can afford
389    /// the verbose teaching sections.
390    Longform,
391
392    /// **Dedicated rule file.**  FULL profile.  Wrapped with `START_MARK`,
393    /// `<!-- version: N -->`, and `END_MARK`.  Non-shadow includes the
394    /// `CRITICAL` banner before the body.  The whole file is lean-ctx–owned;
395    /// the injection layer detects staleness by parsing the version comment.
396    Dedicated,
397
398    /// **Shared file section** (appended to AGENTS.md, GEMINI.md, etc.).
399    /// COMPACT profile.  Same marker wrapping for find/replace within a
400    /// larger shared file.  Non-shadow includes `## Tool Mapping` header.
401    Shared,
402
403    /// **MCP session instructions.**  COMPACT profile.  No markers or
404    /// headers — bare content used inline in per-session MCP instructions.
405    Bare,
406
407    /// **Hook-covered dedicated rule file** (GL #1153). For hosts whose
408    /// installed lean-ctx hooks already compress the native tools (Cursor:
409    /// PreToolUse rewrite/redirect). Same marker/version wrapping as
410    /// `Dedicated`, but the body swaps the unenforceable tool-mapping for the
411    /// honest hook-coverage note plus the exclusive-capability advert.
412    /// Shadow mode collapses it to the same minimal profile as `Dedicated`.
413    HookCovered,
414}
415
416/// Render lean-ctx rules for a given wrapper, shadow mode, compression level,
417/// and tool profile (#756).
418///
419/// * `shadow` — when true, tool-mapping sections (BULLETS, NEVER,
420///   CRITICAL banner, "## Tool Mapping" header) are omitted.
421/// * `wrapper` — selects the profile (FULL / COMPACT) and wrapping style.
422/// * `level` — selects the output-style compression prompt (Lite / Standard /
423///   Max) which is appended to the body. `Off` omits it.
424/// * `tool_profile` — filters tool references in dynamic sections so agents
425///   only see tools they can actually call.
426pub fn render(
427    shadow: bool,
428    wrapper: Wrapper,
429    level: CompressionLevel,
430    tool_profile: &super::tool_profiles::ToolProfile,
431) -> String {
432    use super::rules_sections as rs;
433
434    let sections: Vec<String> = match (wrapper, shadow) {
435        (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
436        (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
437            full_shadow_sections(tool_profile)
438        }
439        (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
440        (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
441        (_, false) => compact_non_shadow_sections(tool_profile),
442        (_, true) => compact_shadow_sections(tool_profile),
443    };
444
445    // Suppress empty sections (a section builder may return "" when the
446    // profile hides all tools it would mention).
447    let mut body: String = sections
448        .into_iter()
449        .filter(|s| !s.is_empty())
450        .collect::<Vec<_>>()
451        .join("\n\n");
452    let _ = rs::intent_section; // anchor — ensures the module is linked
453
454    // Append the compression / output-style prompt for active levels. Persistent
455    // carriers (Dedicated, Shared) wrap it in the canonical COMPRESSION_BLOCK
456    // markers so coverage/dedup (rules_channel, rules_dedup) can detect and thin
457    // it; the ephemeral Bare MCP channel keeps it unmarked (#684/#548).
458    let compression = compression_text(level);
459    if !compression.is_empty() {
460        body.push('\n');
461        if matches!(wrapper, Wrapper::Bare) {
462            body.push_str(compression);
463        } else {
464            body.push_str(COMPRESSION_BLOCK_START);
465            body.push('\n');
466            body.push_str(compression);
467            body.push('\n');
468            body.push_str(COMPRESSION_BLOCK_END);
469        }
470    }
471
472    if matches!(wrapper, Wrapper::Bare) {
473        return body;
474    }
475
476    let version_line = format!("<!-- version: {RULES_VERSION} -->");
477
478    format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
479}
480
481/// Unmarked render of the hook-covered profile for ephemeral channels
482/// (the mcp.json `instructions` snapshot on hook-covered hosts, GL #1153).
483/// The `Bare` counterpart of `Wrapper::HookCovered`: same body, no markers —
484/// per-session channels are governed by carrier coverage, so markers would be
485/// noise (see [`COMPRESSION_BLOCK_START`]). Shadow collapses to the regular
486/// bare shadow profile (interception supersedes hook coverage).
487pub fn render_hook_covered_bare(
488    shadow: bool,
489    level: CompressionLevel,
490    tool_profile: &super::tool_profiles::ToolProfile,
491) -> String {
492    if shadow {
493        return render(true, Wrapper::Bare, level, tool_profile);
494    }
495    let sections = hook_covered_non_shadow_sections(tool_profile);
496    let mut body: String = sections
497        .into_iter()
498        .filter(|s| !s.is_empty())
499        .collect::<Vec<_>>()
500        .join("\n\n");
501    let compression = compression_text(level);
502    if !compression.is_empty() {
503        body.push('\n');
504        body.push_str(compression);
505    }
506    body
507}
508// ============================================================
509// RULES FILE — centralized interface for reading rule files
510// ============================================================
511
512/// A parsed lean-ctx rules section from a file on disk.
513///
514/// Handles version detection, content boundary discovery, and prefix/suffix
515/// extraction.  This is the **only** place that parses `START_MARK` / version
516/// comments — every consumer (injection, drift detection, status reporting)
517/// goes through this struct.
518#[derive(Debug)]
519pub struct RulesFile<'a> {
520    content: &'a str,
521    /// Byte offset of `START_MARK` (or the first old-format marker found).
522    start: Option<usize>,
523    /// Byte offset of `END_MARK`.
524    end: Option<usize>,
525    /// Parsed version number (0 if no `<!-- version: N -->` comment found).
526    version: usize,
527}
528
529/// Parse the version number from the first `<!-- version: N -->` comment
530/// found at or after `search_start`.
531fn parse_version_number(s: &str) -> Option<usize> {
532    let prefix = "<!-- version: ";
533    let vs = s.find(prefix)?;
534    let num_start = vs + prefix.len();
535    let end = s[num_start..].find(" -->")?;
536    s[num_start..num_start + end].parse().ok()
537}
538
539impl<'a> RulesFile<'a> {
540    /// Parse `content`, scanning for `START_MARK` and version comment.
541    ///
542    /// * `START_MARK` not found → `has_content() = false`, version = 0.
543    /// * `START_MARK` found but no version → `has_content() = true`, version = 0
544    ///   (assume older than current → needs update).
545    pub fn parse(content: &'a str) -> Self {
546        let start = content.find(START_MARK);
547        let version = start
548            .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
549            .unwrap_or(0);
550        let end = content.find(END_MARK);
551        RulesFile {
552            content,
553            start,
554            end,
555            version,
556        }
557    }
558
559    /// Whether the file carries any lean-ctx rules content.
560    pub fn has_content(&self) -> bool {
561        self.start.is_some()
562    }
563
564    /// The detected version (0 if no version marker — treat as older than
565    /// `RULES_VERSION`).
566    pub fn version(&self) -> usize {
567        self.version
568    }
569
570    /// Whether the file's version is at least `RULES_VERSION`.
571    pub fn is_current(&self) -> bool {
572        self.version >= RULES_VERSION
573    }
574
575    /// Content before the first `START_MARK` (user content / frontmatter).
576    /// Returns an empty string if no start marker was found.
577    pub fn prefix(&self) -> &'a str {
578        self.start.map_or("", |s| self.content[..s].trim())
579    }
580
581    /// Content after the last `END_MARK` (user content after the lean-ctx
582    /// block).  Returns an empty string if no end marker was found.
583    pub fn suffix(&self) -> &'a str {
584        self.end
585            .map_or("", |e| self.content[e + END_MARK.len()..].trim())
586    }
587
588    /// The lean-ctx block on disk, from `START_MARK` through `END_MARK`
589    /// (inclusive), if both markers are present.
590    fn block(&self) -> Option<&'a str> {
591        match (self.start, self.end) {
592            (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
593            _ => None,
594        }
595    }
596
597    /// Whether the on-disk block is already byte-identical (ignoring surrounding
598    /// whitespace) to a fresh [`render`] for these parameters.
599    ///
600    /// [`is_current`](Self::is_current) only compares the embedded
601    /// `<!-- version: N -->` against [`RULES_VERSION`], so a change that keeps
602    /// the version but alters the rendered body — toggling `shadow_mode`,
603    /// switching `compression_level`, or editing a canonical section without a
604    /// version bump — would otherwise be skipped by the injector. Callers pair
605    /// this with `is_current()` to detect that content/compression drift (#548).
606    pub fn block_matches_render(
607        &self,
608        shadow: bool,
609        wrapper: Wrapper,
610        level: CompressionLevel,
611        tool_profile: &super::tool_profiles::ToolProfile,
612    ) -> bool {
613        match self.block() {
614            Some(block) => block.trim() == render(shadow, wrapper, level, tool_profile).trim(),
615            None => false,
616        }
617    }
618
619    /// Merge freshly-rendered rules into this file.
620    ///
621    /// * If a lean-ctx section exists → replaces content between `START_MARK`
622    ///   and `END_MARK`, preserving user content before/after.
623    /// * If no section exists → appends fresh content at the end.
624    pub fn merged(
625        &self,
626        shadow: bool,
627        wrapper: Wrapper,
628        level: CompressionLevel,
629        tool_profile: &super::tool_profiles::ToolProfile,
630    ) -> String {
631        let fresh = render(shadow, wrapper, level, tool_profile);
632        if self.start.is_some() {
633            let before = self.prefix();
634            let after = self.suffix();
635            let mut out = String::new();
636            if !before.is_empty() {
637                out.push_str(before);
638                out.push('\n');
639                out.push('\n');
640            }
641            out.push_str(&fresh);
642            if !after.is_empty() {
643                out.push('\n');
644                out.push('\n');
645                out.push_str(after);
646            }
647            if !out.ends_with('\n') {
648                out.push('\n');
649            }
650            out
651        } else {
652            // No existing section — append.
653            let trimmed = self.content.trim_end();
654            let mut out = trimmed.to_string();
655            if !out.is_empty() {
656                out.push('\n');
657                out.push('\n');
658            }
659            out.push_str(&fresh);
660            out
661        }
662    }
663
664    /// Create initial rules content (no existing section to merge with).
665    pub fn initial(
666        shadow: bool,
667        wrapper: Wrapper,
668        level: CompressionLevel,
669        tool_profile: &super::tool_profiles::ToolProfile,
670    ) -> String {
671        render(shadow, wrapper, level, tool_profile)
672    }
673
674    // ── Delete ─────────────────────────────────────────────────
675
676    /// Strip the lean-ctx section, keeping only user content before/after.
677    pub fn without_section(&self) -> String {
678        if let Some(start_pos) = self.start {
679            let before = self.content[..start_pos].trim();
680            let after = self.suffix();
681            let mut out = String::new();
682            if !before.is_empty() {
683                out.push_str(before);
684                out.push('\n');
685            }
686            if !after.is_empty() {
687                out.push('\n');
688                out.push_str(after);
689            }
690            out
691        } else {
692            self.content.to_string()
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    fn tp() -> super::super::tool_profiles::ToolProfile {
702        super::super::tool_profiles::ToolProfile::Power
703    }
704
705    // --- Constants ---
706
707    #[test]
708    fn bullets_uses_ctx_shell() {
709        assert!(BULLETS.contains("ctx_shell"));
710        assert!(!BULLETS.contains("lean-ctx -c"));
711        assert!(!BULLETS.contains("ctx_edit"));
712    }
713
714    #[test]
715    fn sections_not_empty() {
716        assert!(!BULLETS.is_empty());
717        assert!(!NEVER.is_empty());
718        assert!(!INTENT.is_empty());
719        assert!(!ANTI.is_empty());
720        assert!(!PARALLEL.is_empty());
721        assert!(!AUTO.is_empty());
722        assert!(!CEP.is_empty());
723        assert!(!INTELLIGENCE.is_empty());
724        assert!(!LITM_END.is_empty());
725        assert!(!CRITICAL.is_empty());
726    }
727
728    #[test]
729    fn intent_contains_ctx_compose() {
730        assert!(INTENT.contains("ctx_compose"));
731    }
732
733    #[test]
734    fn anti_contains_do_not() {
735        assert!(ANTI.contains("do NOT"));
736    }
737
738    #[test]
739    fn parallel_contains_parallel() {
740        assert!(PARALLEL.contains("PARALLEL"));
741    }
742
743    // --- Agent loop + navigation paradox (#609) ---
744
745    #[test]
746    fn agent_loop_names_every_phase() {
747        for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
748            assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
749        }
750        assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
751    }
752
753    #[test]
754    fn nav_paradox_steers_semantic_vs_graph() {
755        assert!(
756            NAV_PARADOX.contains("ctx_search(action=semantic)"),
757            "semantic route must use folded action (#509)"
758        );
759        assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
760        assert!(
761            NAV_PARADOX.contains("≠"),
762            "must carry the reading≠understanding thesis"
763        );
764    }
765
766    #[test]
767    fn longform_carries_loop_and_paradox_injected_full_does_not() {
768        // v5 (#578): the verbose teaching sections live only in the on-demand
769        // LEAN-CTX.md (Longform); the injected dedicated files fold the loop
770        // phases + paradox thesis into INTENT.
771        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
772        assert!(
773            long.contains("AGENT LOOP"),
774            "LONGFORM must carry AGENT_LOOP"
775        );
776        assert!(
777            long.contains("NAVIGATION PARADOX"),
778            "LONGFORM must carry NAV_PARADOX"
779        );
780        assert!(long.contains(CEP), "LONGFORM must carry CEP");
781
782        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
783        assert!(
784            !full.contains("AGENT LOOP (phase -> tool):"),
785            "injected FULL must not inline the multi-line AGENT_LOOP block"
786        );
787        assert!(
788            !full.contains("NAVIGATION PARADOX: reading"),
789            "injected FULL must not inline the multi-line NAV_PARADOX block"
790        );
791        assert!(
792            full.contains('≠'),
793            "INTENT must keep the reading≠understanding thesis in FULL"
794        );
795        // #509: ctx_symbol folded into ctx_search(action=symbol)
796        for phase_tool in [
797            "ctx_compose",
798            "ctx_search(action=symbol)",
799            "ctx_search",
800            "ctx_callgraph",
801        ] {
802            assert!(
803                full.contains(phase_tool),
804                "FULL keeps the loop tools via INTENT: {phase_tool}"
805            );
806        }
807    }
808
809    #[test]
810    fn injected_profiles_stay_lean() {
811        // The whole point of v5 (#578): injected files bill every session.
812        // chars/4 ≈ tokens — the dedicated body must stay around ~470 tok and
813        // the Longform must be a strict superset.
814        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
815        eprintln!(
816            "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
817            full.len(),
818            full.len() / 4,
819            render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
820            render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
821        );
822        assert!(
823            full.len() <= 2400,
824            "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
825            full.len(),
826            full.len() / 4
827        );
828        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
829        assert!(
830            long.len() > full.len(),
831            "Longform ({}) must carry more than the injected profile ({})",
832            long.len(),
833            full.len()
834        );
835        let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
836        assert!(
837            compact.len() < full.len(),
838            "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
839            compact.len(),
840            full.len()
841        );
842    }
843
844    #[test]
845    fn compact_profile_has_no_multiline_teaching_sections() {
846        // COMPACT (shared + Bare) keeps the per-session channel lean: no
847        // multi-line AGENT_LOOP/NAV_PARADOX blocks; INTENT carries the thesis.
848        let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
849        assert!(
850            !out.contains("AGENT LOOP (phase -> tool):"),
851            "COMPACT must not inline the multi-line AGENT_LOOP block"
852        );
853        assert!(
854            !out.contains("NAVIGATION PARADOX: reading"),
855            "COMPACT must not inline the multi-line NAV_PARADOX block"
856        );
857        assert!(
858            out.contains('≠'),
859            "COMPACT keeps the reading≠understanding thesis via INTENT"
860        );
861    }
862
863    #[test]
864    fn shadow_omits_loop_and_paradox() {
865        // #963: shadow collapses to the irreducible minimum — the routing
866        // taxonomy is redundant once native calls are intercepted.
867        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
868            let out = render(true, wrapper, CompressionLevel::Off, &tp());
869            assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
870            assert!(
871                !out.contains("NAVIGATION PARADOX"),
872                "{wrapper:?} shadow drops paradox"
873            );
874        }
875    }
876
877    #[test]
878    fn recover_reaches_every_non_shadow_carrier() {
879        // The recovery vocabulary must reach every non-shadow carrier so agents
880        // never re-read compressed output line-by-line, and every carrier must
881        // keep the MCP-free path ("read the shown path") for orgs that ban MCP.
882        // v5 (#578): only Longform carries the verbose RECOVER; every injected
883        // profile (Dedicated FULL + Shared/Bare COMPACT) carries the terse
884        // RECOVER_COMPACT one-liner.
885        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
886        assert!(
887            long.contains(RECOVER),
888            "Longform must carry the verbose RECOVER verbatim"
889        );
890        for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
891            let out = render(false, wrapper, CompressionLevel::Off, &tp());
892            assert!(
893                out.contains(RECOVER_COMPACT),
894                "{wrapper:?} must carry RECOVER_COMPACT verbatim"
895            );
896            assert!(
897                !out.contains(RECOVER),
898                "{wrapper:?} must not inline the verbose RECOVER block"
899            );
900        }
901        for wrapper in [
902            Wrapper::Longform,
903            Wrapper::Dedicated,
904            Wrapper::Shared,
905            Wrapper::Bare,
906        ] {
907            assert!(
908                render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
909                "{wrapper:?} recovery line must keep the MCP-free path"
910            );
911        }
912        for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
913            let out = render(true, wrapper, CompressionLevel::Off, &tp());
914            assert!(
915                !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
916                "{wrapper:?} shadow drops all RECOVER guidance"
917            );
918        }
919    }
920
921    // --- render() — Dedicated ---
922
923    #[test]
924    fn dedicated_has_markers_and_version() {
925        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
926        assert!(out.contains(START_MARK));
927        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
928        assert!(out.contains(END_MARK));
929        assert!(out.contains(BULLETS));
930        assert!(out.contains(NEVER));
931        assert!(out.contains("CRITICAL"));
932    }
933
934    #[test]
935    fn dedicated_shadow_is_minimal() {
936        // #963: shadow drops the whole tool-mapping AND routing playbook —
937        // interception makes them redundant. Only the exclusive-tool advert and
938        // the output style remain.
939        let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
940        assert!(out.contains(START_MARK));
941        assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
942        assert!(!out.contains(NEVER), "no NEVER in shadow");
943        assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
944        assert!(
945            !out.contains("Tool selection by intent"),
946            "routing INTENT block is redundant under interception"
947        );
948        assert!(
949            !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
950            "ANTI/PARALLEL routing guidance is dropped in shadow"
951        );
952        assert!(
953            out.contains("shadow mode") && out.contains("ctx_compose"),
954            "shadow keeps the exclusive-tool advert"
955        );
956        assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
957    }
958
959    #[test]
960    fn shadow_is_smaller_than_non_shadow() {
961        // The whole point of #963: the shadow body must be a strict reduction.
962        let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
963        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
964        assert!(
965            shadow.len() < full.len(),
966            "shadow ({}) must be smaller than non-shadow ({})",
967            shadow.len(),
968            full.len()
969        );
970    }
971
972    #[test]
973    fn dedicated_litm_structure() {
974        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
975        let lines: Vec<&str> = out.lines().collect();
976        let first_5 = lines[..5.min(lines.len())].join("\n");
977        assert!(
978            first_5.contains("CRITICAL") || first_5.contains("MUST"),
979            "LITM: MUST/CRITICAL instruction near start"
980        );
981        // LITM_END or NEVER should appear in the final content lines (before END_MARK).
982        let tail = lines[lines.len().saturating_sub(8)..].join("\n");
983        assert!(
984            tail.contains("PREFERENCE") || tail.contains("NEVER"),
985            "LITM: reinforcement near end, tail={tail:?}"
986        );
987    }
988
989    #[test]
990    fn dedicated_carries_weak_model_invoke_nudge() {
991        // #1067/GH #593: the "actually CALL ctx_*" nudge must ride every dedicated
992        // rule file (Windsurf, Cursor, Claude, …) in non-shadow mode, and must be
993        // absent where it is dead weight: shadow mode (call-layer routing) and the
994        // Bare/instructions channel (separately capped).
995        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
996        assert!(
997            dedicated.contains(MUST_INVOKE),
998            "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
999        );
1000        assert!(
1001            !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1002            "shadow mode must not carry the nudge (routing is enforced at the call layer)"
1003        );
1004        assert!(
1005            !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1006            "Bare/instructions channel is capped separately and carries no copy"
1007        );
1008    }
1009
1010    // --- render() — Shared ---
1011
1012    #[test]
1013    fn shared_has_markers_and_header() {
1014        let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1015        assert!(out.contains(START_MARK));
1016        assert!(out.contains(END_MARK));
1017        assert!(out.contains("MANDATORY MAPPING"));
1018        assert!(out.contains(BULLETS));
1019    }
1020
1021    #[test]
1022    fn shared_shadow_omits_mapping() {
1023        let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
1024        assert!(out.contains(START_MARK));
1025        assert!(
1026            !out.contains("MANDATORY MAPPING"),
1027            "shadow must not have header"
1028        );
1029        assert!(
1030            !out.contains("MANDATORY MAPPING"),
1031            "shadow must not contain BULLETS"
1032        );
1033    }
1034
1035    // --- render() — Bare ---
1036
1037    #[test]
1038    fn bare_has_no_markers() {
1039        let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1040        assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
1041        assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
1042        assert!(!out.contains("<!-- version:"), "Bare must not have version");
1043        assert!(out.contains(BULLETS));
1044        assert!(out.contains(NEVER));
1045    }
1046
1047    #[test]
1048    fn bare_shadow_only_read_modes() {
1049        let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
1050        assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
1051        assert!(
1052            !out.contains("MANDATORY MAPPING"),
1053            "shadow Bare must not have BULLETS"
1054        );
1055    }
1056
1057    // --- Compression level tests ---
1058
1059    #[test]
1060    fn render_includes_lite_prompt() {
1061        let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
1062        assert!(out.contains("OUTPUT STYLE: concise"));
1063        assert!(out.contains("Bullet points"));
1064    }
1065
1066    #[test]
1067    fn render_includes_standard_prompt() {
1068        let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1069        assert!(out.contains("OUTPUT STYLE: dense"));
1070        assert!(out.contains("atomic fact"));
1071    }
1072
1073    #[test]
1074    fn render_includes_max_prompt() {
1075        let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
1076        assert!(out.contains("OUTPUT STYLE: expert-terse"));
1077        assert!(out.contains("Telegraph"));
1078    }
1079
1080    #[test]
1081    fn render_off_excludes_compression() {
1082        let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1083        assert!(!out.contains("OUTPUT STYLE:"));
1084    }
1085
1086    #[test]
1087    fn compression_text_matches_level() {
1088        assert!(compression_text(CompressionLevel::Off).is_empty());
1089        assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1090        assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1091        assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1092    }
1093
1094    // --- Compression marker model (#548 B2) ---
1095
1096    #[test]
1097    fn carrier_wrappers_wrap_compression_in_markers() {
1098        // Persistent carriers must delimit the compression payload so coverage
1099        // and dedup can detect/thin it (#684/#548).
1100        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1101            let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1102            assert!(
1103                out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1104                "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1105            );
1106            // The marked region must actually contain the prompt body.
1107            let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1108            let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1109            assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1110            assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1111        }
1112    }
1113
1114    #[test]
1115    fn bare_wrapper_emits_compression_without_markers() {
1116        // The ephemeral MCP channel keeps the payload unmarked — its inclusion is
1117        // governed by carrier coverage, so per-session markers would be noise.
1118        let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1119        assert!(out.contains("OUTPUT STYLE: dense"));
1120        assert!(!out.contains(COMPRESSION_BLOCK_START));
1121        assert!(!out.contains(COMPRESSION_BLOCK_END));
1122    }
1123
1124    #[test]
1125    fn compression_off_emits_no_markers_in_any_wrapper() {
1126        for wrapper in [
1127            Wrapper::Longform,
1128            Wrapper::Dedicated,
1129            Wrapper::Shared,
1130            Wrapper::Bare,
1131        ] {
1132            let out = render(false, wrapper, CompressionLevel::Off, &tp());
1133            assert!(
1134                !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1135                "{wrapper:?}: Off must emit no compression markers"
1136            );
1137        }
1138    }
1139
1140    #[test]
1141    fn rendered_carrier_block_is_seen_as_carrying_compression() {
1142        // The detection helper that coverage/dedup rely on must agree with the
1143        // writer's output (the bug this slice fixes: it previously never did).
1144        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1145        assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1146        assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1147    }
1148
1149    // --- Wrapper round-trip ---
1150
1151    #[test]
1152    fn all_wrappers_produce_output() {
1153        for shadow in [false, true] {
1154            for wrapper in [
1155                Wrapper::Longform,
1156                Wrapper::Dedicated,
1157                Wrapper::Shared,
1158                Wrapper::Bare,
1159            ] {
1160                let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1161                assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1162            }
1163        }
1164    }
1165
1166    // --- RulesFile ---
1167
1168    #[test]
1169    fn rules_file_parses_version() {
1170        let content = format!(
1171            "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1172        );
1173        let f = RulesFile::parse(&content);
1174        assert!(f.has_content());
1175        assert_eq!(f.version(), RULES_VERSION);
1176        assert!(f.is_current());
1177        assert!(f.prefix().contains("stuff before"));
1178        assert!(f.suffix().contains("stuff after"));
1179    }
1180
1181    #[test]
1182    fn rules_file_no_version_defaults_to_zero() {
1183        let content = format!("{START_MARK}\nbody\n{END_MARK}");
1184        let f = RulesFile::parse(&content);
1185        assert!(f.has_content());
1186        assert_eq!(f.version(), 0);
1187        assert!(!f.is_current());
1188    }
1189
1190    #[test]
1191    fn rules_file_no_start_marker_no_content() {
1192        let f = RulesFile::parse("just user stuff");
1193        assert!(!f.has_content());
1194        assert_eq!(f.version(), 0);
1195    }
1196
1197    #[test]
1198    fn block_matches_render_true_for_fresh_render() {
1199        let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1200        let content = format!("user before\n{fresh}\nuser after");
1201        let f = RulesFile::parse(&content);
1202        assert!(f.is_current(), "fresh render carries the current version");
1203        assert!(
1204            f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1205            "an unchanged block must compare equal to a fresh render"
1206        );
1207    }
1208
1209    #[test]
1210    fn block_matches_render_false_on_compression_change() {
1211        // Body rendered at Off, then asked whether it matches a Max render:
1212        // the version is identical but the compression payload differs (#548).
1213        let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1214        let f = RulesFile::parse(&content);
1215        assert!(f.is_current());
1216        assert!(
1217            !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1218            "a compression-level change must be detected as drift"
1219        );
1220    }
1221
1222    #[test]
1223    fn block_matches_render_false_on_shadow_change() {
1224        let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1225        let f = RulesFile::parse(&content);
1226        assert!(
1227            !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1228            "a shadow-mode toggle must be detected as drift"
1229        );
1230    }
1231
1232    #[test]
1233    fn block_matches_render_false_without_block() {
1234        let f = RulesFile::parse("plain user content, no markers");
1235        assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1236    }
1237
1238    #[test]
1239    fn rules_file_merged_replaces_section() {
1240        let content =
1241            format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1242        let f = RulesFile::parse(&content);
1243        let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1244        assert!(merged.contains("before"), "prefix preserved");
1245        assert!(merged.contains("after"), "suffix preserved");
1246        assert!(!merged.contains("old"), "old content replaced");
1247        assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1248    }
1249
1250    #[test]
1251    fn rules_file_merged_appends_when_no_section() {
1252        let content = "user content";
1253        let f = RulesFile::parse(content);
1254        assert!(!f.has_content());
1255        let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1256        assert!(merged.contains("user content"));
1257        assert!(merged.contains(BULLETS));
1258    }
1259
1260    #[test]
1261    fn rules_file_without_section_strips_content() {
1262        let content =
1263            format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1264        let f = RulesFile::parse(&content);
1265        let stripped = f.without_section();
1266        assert!(stripped.contains("header"));
1267        assert!(stripped.contains("footer"));
1268        assert!(!stripped.contains("body"));
1269        assert!(!stripped.contains(START_MARK));
1270    }
1271
1272    #[test]
1273    fn rules_file_without_section_noop_when_no_content() {
1274        let content = "just user text";
1275        let f = RulesFile::parse(content);
1276        assert_eq!(f.without_section(), content);
1277    }
1278
1279    #[test]
1280    fn bullets_lead_with_four_core_redirects() {
1281        // Most-used routes (Read/Grep/Shell/Glob) lead; ls->ctx_tree trails.
1282        let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1283        let search = BULLETS
1284            .find("ctx_search")
1285            .expect("ctx_search mapping present");
1286        let shell = BULLETS
1287            .find("ctx_shell")
1288            .expect("ctx_shell mapping present");
1289        let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1290        let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1291        assert!(
1292            read < search && search < shell && shell < glob && glob < tree,
1293            "core redirects (read<search<shell<glob) must precede ctx_tree"
1294        );
1295    }
1296
1297    #[test]
1298    fn never_carries_self_correction() {
1299        // Self-correction reinforces the redirect harder than a bare prohibition.
1300        assert!(
1301            NEVER.contains("SELF-CORRECT"),
1302            "NEVER must teach self-correction"
1303        );
1304        assert!(
1305            NEVER.contains("call"),
1306            "NEVER must spell out the corrective action"
1307        );
1308    }
1309
1310    #[test]
1311    fn critical_names_ctx_family() {
1312        assert!(
1313            CRITICAL.contains("ctx_*"),
1314            "CRITICAL must name the ctx_* family"
1315        );
1316    }
1317
1318    // --- HookCovered profile (GL #1153) ---
1319
1320    #[test]
1321    fn hook_covered_carries_strict_mapping() {
1322        // v8: HookCovered is strict — always prefer ctx_* over native tools.
1323        // The MANDATORY MAPPING replaces the old "no native equivalent" list.
1324        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1325        assert!(
1326            !out.contains(NEVER),
1327            "HookCovered uses its own strict header, not the NEVER constant"
1328        );
1329        assert!(
1330            out.contains(HOOK_COVERED_HEADER),
1331            "must carry the strict preference header"
1332        );
1333        assert!(
1334            out.contains("MANDATORY MAPPING")
1335                && out.contains("ctx_read")
1336                && out.contains("ctx_search"),
1337            "must carry the full mandatory mapping"
1338        );
1339        assert!(
1340            out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1341            "must advertise the search capabilities"
1342        );
1343    }
1344
1345    #[test]
1346    fn hook_covered_keeps_markers_version_and_recovery() {
1347        // Coverage detection (rules_channel::carries_full_rules /
1348        // client_autoloads_rules) and the injector's drift check both key on
1349        // the canonical markers — HookCovered must stay a first-class carrier.
1350        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1351        assert!(out.contains(START_MARK) && out.contains(END_MARK));
1352        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1353        assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1354        assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1355    }
1356
1357    #[test]
1358    fn hook_covered_is_leaner_than_full() {
1359        let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1360        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1361        assert!(
1362            covered.len() < full.len(),
1363            "HookCovered ({}) must be a strict reduction of FULL ({})",
1364            covered.len(),
1365            full.len()
1366        );
1367    }
1368
1369    #[test]
1370    fn hook_covered_shadow_collapses_to_minimal() {
1371        // Interception supersedes hook coverage — same minimal profile as
1372        // Dedicated shadow.
1373        let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1374        let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1375        assert_eq!(covered_shadow, dedicated_shadow);
1376    }
1377
1378    #[test]
1379    fn hook_covered_wraps_compression_in_markers() {
1380        let out = render(
1381            false,
1382            Wrapper::HookCovered,
1383            CompressionLevel::Standard,
1384            &tp(),
1385        );
1386        assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1387        assert!(out.contains("OUTPUT STYLE: dense"));
1388    }
1389
1390    // --- Profile-aware rules (#756) ---
1391
1392    #[test]
1393    fn rules_only_mention_enabled_tools() {
1394        use super::super::rules_sections;
1395        use super::super::tool_profiles::ToolProfile;
1396        let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1397        for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1398            let mut sections = vec![
1399                rules_sections::intent_section(&profile),
1400                rules_sections::hook_covered_tools_section(&profile),
1401                rules_sections::shadow_minimal_section(&profile),
1402                rules_sections::anti_section(&profile),
1403                rules_sections::litm_end_section(&profile),
1404            ];
1405            if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1406                sections.push(fb);
1407            }
1408            for tool in &exclusive_tools {
1409                if !profile.is_tool_enabled(tool) {
1410                    for section in &sections {
1411                        assert!(
1412                            !section.contains(tool),
1413                            "profile {:?} dynamic section must not mention disabled tool {tool}",
1414                            profile.as_str()
1415                        );
1416                    }
1417                }
1418            }
1419        }
1420    }
1421
1422    #[test]
1423    fn minimal_rules_shorter_than_standard() {
1424        use super::super::tool_profiles::ToolProfile;
1425        let min = render(
1426            false,
1427            Wrapper::Dedicated,
1428            CompressionLevel::Off,
1429            &ToolProfile::Minimal,
1430        );
1431        let std = render(
1432            false,
1433            Wrapper::Dedicated,
1434            CompressionLevel::Off,
1435            &ToolProfile::Standard,
1436        );
1437        assert!(
1438            min.len() < std.len(),
1439            "minimal ({}) must be shorter than standard ({})",
1440            min.len(),
1441            std.len()
1442        );
1443    }
1444
1445    #[test]
1446    fn power_profile_output_identical_to_default() {
1447        use super::super::tool_profiles::ToolProfile;
1448        let power = render(
1449            false,
1450            Wrapper::Dedicated,
1451            CompressionLevel::Off,
1452            &ToolProfile::Power,
1453        );
1454        assert!(
1455            power.contains("ctx_compose"),
1456            "Power must include all tools"
1457        );
1458        assert!(
1459            power.contains("ctx_callgraph"),
1460            "Power must include all tools"
1461        );
1462        assert!(
1463            power.contains("ctx_session"),
1464            "Power must include all tools"
1465        );
1466    }
1467
1468    #[test]
1469    fn render_is_deterministic_across_profiles() {
1470        use super::super::tool_profiles::ToolProfile;
1471        for profile in [
1472            ToolProfile::Minimal,
1473            ToolProfile::Standard,
1474            ToolProfile::Power,
1475        ] {
1476            let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1477            let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1478            assert_eq!(
1479                a,
1480                b,
1481                "render must be deterministic for {:?}",
1482                profile.as_str()
1483            );
1484        }
1485    }
1486
1487    #[test]
1488    fn minimal_has_ctx_call_fallback() {
1489        use super::super::tool_profiles::ToolProfile;
1490        let rules = render(
1491            false,
1492            Wrapper::Dedicated,
1493            CompressionLevel::Off,
1494            &ToolProfile::Minimal,
1495        );
1496        assert!(
1497            rules.contains("ctx_call"),
1498            "minimal profile must include ctx_call gateway hint"
1499        );
1500    }
1501
1502    #[test]
1503    fn power_has_no_ctx_call_fallback() {
1504        use super::super::tool_profiles::ToolProfile;
1505        let rules = render(
1506            false,
1507            Wrapper::Dedicated,
1508            CompressionLevel::Off,
1509            &ToolProfile::Power,
1510        );
1511        assert!(
1512            !rules.contains("ctx_call(tool="),
1513            "power profile must not include ctx_call gateway hint"
1514        );
1515    }
1516}