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