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`, `core::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/// Opening marker of the solution-intelligence block.
69pub const SOLUTION_BLOCK_START: &str = "<!-- lean-ctx-solution -->";
70
71/// Closing marker of the solution-intelligence block.
72pub const SOLUTION_BLOCK_END: &str = "<!-- /lean-ctx-solution -->";
73
74/// The solution-efficiency guidance shared by rule-file generators that do not
75/// use [`render`] directly.
76pub fn enabled_solution_rules_block() -> String {
77    // A3: rules files ride every turn like the instructions, so the ladder
78    // follows the same opt-in as the instructions channel.
79    let solution = crate::core::config::Config::load().solution;
80    if !solution.enabled || !solution.inject_in_instructions {
81        return String::new();
82    }
83
84    format!(
85        "{SOLUTION_BLOCK_START}\n\
86SOLUTION EFFICIENCY: stop at first level that applies:\n\
87skip (YAGNI) → reuse codebase → stdlib → native platform → installed dep → one-line → minimum code.\n\
88Never skip: validation, security, error handling.\n\
89{SOLUTION_BLOCK_END}"
90    )
91}
92
93/// Current rules version (monotonically increasing integer).  Embedded as
94/// `<!-- version: {RULES_VERSION} -->` right after `START_MARK` so the
95/// injection layer can parse it and decide whether a file is up-to-date.
96///
97/// History: v3 (#609) adds the `AGENT_LOOP` taxonomy + `NAV_PARADOX` guidance to
98/// the FULL profile and a compact one-liner to COMPACT. v4 adds recovery guidance
99/// so agents learn the (MCP-optional) decompression paths proactively instead of
100/// re-reading compressed output line-by-line. v5 (#578) is the self-footprint
101/// diet: the injected FULL profile drops to ~470 tokens (the intent playbook
102/// absorbs the loop taxonomy and navigation paradox; the verbose
103/// `AGENT_LOOP`/`NAV_PARADOX`/`RECOVER`/`CEP` sections move to the LONGFORM
104/// profile that only the on-demand project `LEAN-CTX.md` carries).
105/// v6: HookCovered profile strengthened — ctx_compose-first priority, imperative
106/// session memory requirement, ctx_callgraph/ctx_search(semantic) promoted from
107/// optional to mandatory. Fixes agents falling back to native Read+Grep for
108/// exploration on hook-covered hosts (Cursor).
109/// Bumping it forces every committed `LEAN-CTX.md` artifact to be regenerated
110/// (see `tests/rules_drift.rs`) and every injected user file to resync.
111/// v9 (#1287): the "re-reads ~13 tokens" claim is scoped to unchanged
112/// full/auto re-reads — signatures/map re-reads currently return the full
113/// (cached) payload.
114/// v10 (#1788): the steering states its own boundary. It governs the host's
115/// built-in file/search/shell tools; it is not a ranking over other MCP
116/// servers, which the previous unqualified wording was read as.
117/// v11 (#1849): the shadow edit line names no host tool — "native
118/// Edit/StrReplace" was wrong for Codex, which edits through `apply_patch`.
119/// v12: identify the Context Gateway and remove ungrounded savings/count claims.
120/// v13 (A3): routing stated once, neutrally. No "ALWAYS"/"NEVER"/"SELF-CORRECT"
121/// shouting and no "orient FIRST" push (pilot P6: on short tasks it added model
122/// steps); anti-patterns, parallel and end-preference lines move to LONGFORM.
123pub const RULES_VERSION: usize = 13;
124
125/// Product identity for the on-demand guide; injected profiles keep their budget.
126pub const GATEWAY_IDENTITY: &str = "LeanCTX (lean-ctx) — Context Gateway for AI agents.";
127
128/// Banner placed at the top of dedicated rule files (non-shadow only).
129///
130/// It used to end "…instead of native equivalents. This is NOT optional." —
131/// unqualified, so it read as a ranking over every tool the host exposes, and a
132/// model with an IDE/LSP, database or issue-tracker MCP server attached routed
133/// those questions here too (#1788). The imperative stays (it is the adherence
134/// lever, and the instructions channel is guarded on it); what goes is the
135/// unbounded object. "as mapped below" hands the scope to `BULLETS`, which
136/// names the four tools this actually governs.
137///
138/// v13 (A3): a neutral routing header instead of the "CRITICAL: ALWAYS" banner.
139pub const CRITICAL: &str = "lean-ctx routing — the host's built-in tools map to:";
140
141/// Weak-model adherence nudge (#1067 / GH #593). Smaller models (e.g. GLM 5.2 in
142/// Windsurf) often read the tool-mapping rules yet still answer from memory or
143/// reach for a built-in tool instead of emitting an actual MCP call — so
144/// `lean-ctx watch` stays empty and the layer looks "off". This one imperative
145/// line makes the *act of invoking* a ctx_* tool the explicit first step. It
146/// lives in the dedicated rule files of *every* agent (`FULL_NON_SHADOW`), which
147/// is what #1067 asks for; the Windsurf project template (`windsurfrules.txt`)
148/// carries its own copy. It is omitted where it would be dead weight: shadow
149/// mode enforces routing at the call layer, and the `Bare`/instructions channel
150/// is capped separately.
151pub const MUST_INVOKE: &str = "Call the tool; describing a call does not run it.";
152
153/// Compact bullet-list mapping (for both dedicated and shared contexts).
154pub const BULLETS: &str = "\
155• Read/cat -> ctx_read(path, mode)\n\
156• Grep -> ctx_search(pattern, path)\n\
157• Shell/bash -> ctx_shell(command)\n\
158• Glob/find -> ctx_glob(pattern)\n\
159• ls/find -> ctx_tree(path, depth)";
160
161/// One-line admonition reinforcing the tool preference.  Always right after
162/// `BULLETS` in both profiles.
163///
164/// Carries the scope boundary for the whole ruleset (#1788). The steering used
165/// to name no limit, so it was read as ranking lean-ctx above every other MCP
166/// server — an IDE/LSP bridge, a database, an issue tracker — rather than above
167/// the host's built-in file/search/shell tools. The closing clause is kept to
168/// one short sentence: this text ships on every turn, and the layer that sells
169/// token savings cannot pay for its own disclaimer twice. It is paid for, not
170/// added: "for these jobs" replaces "when a ctx_* equivalent exists", which
171/// restated the mapping printed directly above it.
172///
173/// v13 (A3): the scope boundary only — the imperative went with the shouting.
174pub const NEVER: &str = "This covers built-in file/search/shell tools only; \
175    other MCP servers keep their own jobs.";
176
177/// Intent-to-tool playbook — maps common agent questions to the right tool.
178/// Since v5 it also absorbs the agent-loop phases (#609) and the navigation
179/// paradox one-liner, replacing the separate verbose sections in the injected
180/// profile (they stay verbatim in LONGFORM).
181pub const INTENT: &str = "\
182By task:\n\
183• Unfamiliar code -> ctx_compose (search + read + symbols in one call)\n\
184• Read -> ctx_read(mode=signatures|map|full); edit -> ctx_patch\n\
185• Symbol -> ctx_search(action=symbol); meaning -> ctx_search(action=semantic)\n\
186• Callers/impact -> ctx_callgraph; memory -> ctx_session / ctx_knowledge";
187
188/// Anti-patterns that waste tokens and round-trips.
189pub const ANTI: &str = "\
190Anti-patterns — do NOT:\n\
191• Chain ctx_search -> ctx_read -> ctx_search(action=symbol) — one ctx_compose replaces all three\n\
192• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
193• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track call\n\
194  edges and file deps only; use ctx_search instead";
195
196/// Encourages parallel tool calls to reduce round-trips.
197pub const PARALLEL: &str = "\
198PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles \
199multiple lookups into one call.";
200
201/// Agent-loop tool taxonomy (#609). Names each phase of the gather → act →
202/// verify loop an agent actually runs in and the one lean-ctx tool that serves
203/// it. Since v5 (#578) LONGFORM-only — the injected profiles carry the phases
204/// folded into `INTENT`.
205pub const AGENT_LOOP: &str = "\
206AGENT LOOP (phase -> tool):\n\
207• Orient — understand before acting -> ctx_compose\n\
208• Find — exact symbol by name -> ctx_search(action=symbol)\n\
209• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
210• Locate — a pattern across files -> ctx_search\n\
211• Trace — callers / callees / blast radius -> ctx_callgraph\n\
212• Verify — after an edit -> ctx_shell(test/build) + native lints";
213
214/// Navigation-paradox guidance (#609): reading more is not understanding more.
215/// Steers semantic questions to BM25 + meaning search and reserves the call/dep
216/// graph for genuinely hidden architectural edges. Since v5 LONGFORM-only —
217/// `INTENT` carries the one-line thesis in the injected profiles.
218pub const NAV_PARADOX: &str = "\
219NAVIGATION PARADOX: reading more ≠ understanding more.\n\
220• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_search(action=semantic) (meaning), not whole-file reads\n\
221• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
222• Navigate structure (signatures, symbols) before reading entire files";
223
224/// One-line automation reminder.
225pub const AUTO: &str = "Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. \
226    ctx_session=memory; full guide: LEAN-CTX.md";
227
228/// Recovery vocabulary (verbose, LONGFORM profile). lean-ctx compression is fully
229/// reversible (CCR), but agents otherwise only discover the escape hatch reactively
230/// from output hints — so they re-read compressed files line-by-line instead of
231/// expanding (the "too compressed" complaint). The MCP-free path ("read the shown
232/// file path with any tool") covers orgs that forbid MCP. Since v5 every injected
233/// profile (FULL + COMPACT/Bare) carries the terser [`RECOVER_COMPACT`] instead;
234/// the reactive footers in `ctx_read`/`archive`/`ctx_shell` still teach the
235/// `ctx_expand` path in context.
236pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
237    Need full/exact? Read the shown file path with any tool (no MCP), or \
238    ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
239
240/// Terse injected variant of [`RECOVER`] (FULL + COMPACT/Bare). The cold
241/// first-contact handshake renders the COMPACT profile, so this one-liner keeps
242/// the static char/token budget (`tests/intensive_benchmarks.rs`,
243/// `instructions.rs`); since v5 the FULL dedicated files carry it too (#578).
244/// Keeps the two primary MCP-optional paths and the "never line-by-line" rule.
245/// Must keep the `(no MCP)` clause (asserted in tests).
246pub const RECOVER_COMPACT: &str = "RECOVER: compression is reversible — read the shown path \
247    (no MCP) or ctx_read(raw=true), never re-read line-by-line.";
248
249/// Root restriction hint (#1465). Paths outside project roots and configured
250/// `allow_paths`/`extra_roots` are refused by ctx_* tools. Without this hint
251/// the model discovers the restriction only from a surprise error.
252/// The closing sentence is a block-end marker for installed Claude Code mods
253/// (`templates/claude_mod/register.ts`) — keep it verbatim.
254pub const ROOT_RESTRICTION: &str = "ROOTS: ctx_* read ~ projects, write the active one; ~/.ssh etc. closed. \
255    Use native Read for out-of-root; `lean-ctx doctor` shows effective roots.";
256
257/// Context Engineering Protocol version reference.
258pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
259     4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
260
261/// LITM end-of-instructions preference line.
262pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
263     ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
264
265/// Minimal rules body for shadow mode (#963). Under shadow-mode interception
266/// native Read/Grep/Shell/Glob calls are transparently routed to ctx_*, so the
267/// tool-mapping and "use ctx_* instead of native" guidance is dead weight — the
268/// enforcement happens at the call layer, not in the prompt. Only the lean-ctx
269/// tools that have *no* native trigger to intercept still need advertising.
270pub const SHADOW_MINIMAL: &str = "\
271lean-ctx shadow mode: native read/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
272File editing → the host's native edit tool (lean-ctx only handles reads); if denied, use ctx_patch.\n\
273Exclusive tools (no native trigger): ctx_compose (search + read + symbols), ctx_search(action=symbol) (exact symbol), ctx_search(action=semantic) (by meaning), ctx_callgraph (callers), ctx_knowledge / ctx_session (memory).";
274
275/// Hook-covered header (GL #1153): the honest replacement for the
276/// `CRITICAL`/`BULLETS`/`NEVER` mapping on hosts whose *installed hooks*
277/// already compress the native tools (Cursor: `preToolUse` rewrite covers
278/// Shell, redirect covers Read/Grep). There a "NEVER use native tools" rule
279/// fights the host's own tool guidance and is unenforceable — the model calls
280/// native tools anyway and the hooks compress them transparently. Saying so
281/// removes the instruction dissonance instead of losing the battle silently.
282pub const HOOK_COVERED_HEADER: &str = "\
283lean-ctx hooks compress native Shell/Read/Grep. Direct ctx_* calls add read depth \
284per file (map/signatures/lines), cached re-reads and session memory.";
285
286/// The tools worth an explicit MCP call on a hook-covered host: capabilities
287/// with *no* native equivalent the hooks could intercept. Kept in sync with
288/// [`SHADOW_MINIMAL`]'s exclusive-tools line (same rationale, different cause).
289pub const HOOK_COVERED_TOOLS: &str = "\
290lean-ctx tools:\n\
291• Read/cat -> ctx_read(path, mode) — structural views, cached re-reads\n\
292• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic\n\
293• Shell/bash -> ctx_shell(command) — shaped command output\n\
294• ctx_compose — search + read + symbols in one call\n\
295• ctx_callgraph — callers, callees, blast radius\n\
296• ctx_session / ctx_knowledge — persistent memory\n\
297• ctx_expand — full text of [Archived]/compressed output";
298
299// ── Output-style compression prompts ───────────────────────────
300
301/// Lite compression — concise, bullet-point output.
302pub const LITE_PROMPT: &str = "\
303OUTPUT STYLE: concise
304- Bullet points over paragraphs
305- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
306- 1-sentence explanations max, then code/action
307- No repeating what the user said";
308
309/// Standard compression — dense, atomic fact lines, abbreviations.
310pub const STANDARD_PROMPT: &str = "\
311OUTPUT STYLE: dense
312- Each statement = one atomic fact line
313- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
314- Diff lines only (+/-/~), never repeat unchanged code
315- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
316- No narration, no filler, no hedging
317- BUDGET: ≤200 tokens per response unless code block required";
318
319/// Max compression — expert-terse, telegraph format, symbolic vocabulary.
320pub const MAX_PROMPT: &str = "\
321OUTPUT STYLE: expert-terse
322- Telegraph format: subject-verb-object, drop articles/prepositions
323- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
324- Code blocks: untouched (never compress code syntax)
325- Each line: max 80 chars
326- Zero narration, zero filler
327- BUDGET: ≤100 tokens per non-code response";
328
329/// Raw compression — densest possible output. Bullet points only, zero prose,
330/// diff-style facts, no intro/outro. Tighter than Max (#795).
331pub const RAW_PROMPT: &str = "\
332OUTPUT STYLE: raw-dense
333- Bullet points ONLY, zero prose, no intro, no outro, no greetings
334- Diff-style facts: +added, -removed, ~changed, !breaking
335- One fact per line, max 60 chars
336- Symbolic: → ∵ ∴ ⊕ ⊖ Δ ≈ ≠ ∈ ∅ ✓ ✗ (same as expert-terse)
337- Code blocks: untouched
338- BUDGET: ≤50 tokens per non-code response
339- NEVER explain what you did — show only the result";
340
341/// Return the compression prompt text for a given level (empty string for Off).
342pub fn compression_text(level: CompressionLevel) -> &'static str {
343    match level {
344        CompressionLevel::Off => "",
345        CompressionLevel::Lite => LITE_PROMPT,
346        CompressionLevel::Standard => STANDARD_PROMPT,
347        CompressionLevel::Max => MAX_PROMPT,
348        CompressionLevel::Raw => RAW_PROMPT,
349    }
350}
351
352// The verbose teaching profile — only the on-demand project `LEAN-CTX.md`
353// Static profile arrays removed (#756): replaced by the profile-aware
354// section-builder functions below (longform_non_shadow_sections, etc.).
355
356// ── Profile-aware section assembly (#756) ──────────────────────
357//
358// Each function returns Vec<String> with the same section ordering as the
359// static arrays above, but replaces tool-referencing constants with their
360// dynamic equivalents from `rules_sections`.
361
362fn s(c: &str) -> String {
363    c.to_string()
364}
365
366/// The routing block: header, mapping and scope line as one section (A3).
367fn routing() -> String {
368    format!("{CRITICAL}\n{BULLETS}\n{NEVER}")
369}
370
371fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
372    use super::rules_sections as rs;
373    let mut v = vec![
374        s(GATEWAY_IDENTITY),
375        routing(),
376        rs::must_invoke_section(p),
377        rs::intent_section(p),
378        s(AGENT_LOOP), // verbose teaching — LONGFORM only
379        rs::anti_section(p),
380        s(NAV_PARADOX), // verbose teaching — LONGFORM only
381        rs::parallel_section(p),
382        s(AUTO),
383        s(RECOVER),
384        s(ROOT_RESTRICTION),
385        s(CEP),
386        rs::litm_end_section(p),
387    ];
388    if let Some(fb) = rs::ctx_call_fallback(p) {
389        v.push(fb);
390    }
391    v
392}
393
394fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
395    use super::rules_sections as rs;
396    // v13 (A3): every line here ships per request on dedicated-file hosts —
397    // routing facts once; anti-patterns/parallel/end-preference are LONGFORM.
398    let mut v = vec![
399        routing(),
400        rs::must_invoke_section(p),
401        rs::intent_section(p),
402        s(AUTO),
403        s(RECOVER_COMPACT),
404        s(ROOT_RESTRICTION),
405    ];
406    if let Some(fb) = rs::ctx_call_fallback(p) {
407        v.push(fb);
408    }
409    v
410}
411
412fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
413    use super::rules_sections as rs;
414    vec![rs::shadow_minimal_section(p)]
415}
416
417fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
418    use super::rules_sections as rs;
419    let mut v = vec![
420        s(HOOK_COVERED_HEADER),
421        rs::hook_covered_tools_section(p),
422        s(RECOVER_COMPACT),
423        s(ROOT_RESTRICTION),
424    ];
425    if let Some(fb) = rs::ctx_call_fallback(p) {
426        v.push(fb);
427    }
428    v
429}
430
431fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
432    use super::rules_sections as rs;
433    let mut v = vec![
434        routing(),
435        rs::intent_section(p),
436        s(RECOVER_COMPACT),
437        s(ROOT_RESTRICTION),
438    ];
439    if let Some(fb) = rs::ctx_call_fallback(p) {
440        v.push(fb);
441    }
442    v
443}
444
445fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
446    use super::rules_sections as rs;
447    vec![rs::shadow_minimal_section(p)]
448}
449
450/// Selects the profile (LONGFORM / FULL / COMPACT) and the wrapping style
451/// (markers, headers, footers) for `render()`.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Wrapper {
454    /// **On-demand long form** (project `LEAN-CTX.md`). LONGFORM profile with
455    /// the same marker wrapping as `Dedicated`. Not auto-loaded by any client
456    /// — agents open it on demand via the AGENTS.md pointer, so it can afford
457    /// the verbose teaching sections.
458    Longform,
459
460    /// **Dedicated rule file.**  FULL profile.  Wrapped with `START_MARK`,
461    /// `<!-- version: N -->`, and `END_MARK`.  Non-shadow includes the
462    /// `CRITICAL` banner before the body.  The whole file is lean-ctx–owned;
463    /// the injection layer detects staleness by parsing the version comment.
464    Dedicated,
465
466    /// **Shared file section** (appended to AGENTS.md, GEMINI.md, etc.).
467    /// COMPACT profile.  Same marker wrapping for find/replace within a
468    /// larger shared file.  Non-shadow includes `## Tool Mapping` header.
469    Shared,
470
471    /// **MCP session instructions.**  COMPACT profile.  No markers or
472    /// headers — bare content used inline in per-session MCP instructions.
473    Bare,
474
475    /// **Hook-covered dedicated rule file** (GL #1153). For hosts whose
476    /// installed lean-ctx hooks already compress the native tools (Cursor:
477    /// PreToolUse rewrite/redirect). Same marker/version wrapping as
478    /// `Dedicated`, but the body swaps the unenforceable tool-mapping for the
479    /// honest hook-coverage note plus the exclusive-capability advert.
480    /// Shadow mode collapses it to the same minimal profile as `Dedicated`.
481    HookCovered,
482}
483
484/// Render lean-ctx rules for a given wrapper, shadow mode, compression level,
485/// and tool profile (#756).
486///
487/// * `shadow` — when true, tool-mapping sections (BULLETS, NEVER,
488///   CRITICAL banner, "## Tool Mapping" header) are omitted.
489/// * `wrapper` — selects the profile (FULL / COMPACT) and wrapping style.
490/// * `level` — selects the output-style compression prompt (Lite / Standard /
491///   Max) which is appended to the body. `Off` omits it.
492/// * `tool_profile` — filters tool references in dynamic sections so agents
493///   only see tools they can actually call.
494pub fn render(
495    shadow: bool,
496    wrapper: Wrapper,
497    level: CompressionLevel,
498    tool_profile: &super::tool_profiles::ToolProfile,
499) -> String {
500    let cfg = crate::core::config::Config::load();
501    render_with_solution(shadow, wrapper, level, tool_profile, &cfg.solution)
502}
503
504/// Render with an explicit solution-efficiency configuration.
505///
506/// The dedicated hook context uses this seam to exercise every rendered
507/// solution variant in its cross-language boundary drift test.
508pub fn render_with_solution(
509    shadow: bool,
510    wrapper: Wrapper,
511    level: CompressionLevel,
512    tool_profile: &super::tool_profiles::ToolProfile,
513    solution: &super::config::solution::SolutionConfig,
514) -> String {
515    use super::rules_sections as rs;
516
517    let sections: Vec<String> = match (wrapper, shadow) {
518        (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
519        (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
520            full_shadow_sections(tool_profile)
521        }
522        (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
523        (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
524        (_, false) => compact_non_shadow_sections(tool_profile),
525        (_, true) => compact_shadow_sections(tool_profile),
526    };
527
528    // Suppress empty sections (a section builder may return "" when the
529    // profile hides all tools it would mention).
530    let mut body: String = sections
531        .into_iter()
532        .filter(|s| !s.is_empty())
533        .collect::<Vec<_>>()
534        .join("\n\n");
535    let _ = rs::intent_section; // anchor — ensures the module is linked
536
537    // Append the compression / output-style prompt for active levels. Persistent
538    // carriers (Dedicated, Shared) wrap it in the canonical COMPRESSION_BLOCK
539    // markers so coverage/dedup (rules_channel, rules_dedup) can detect and thin
540    // it; the ephemeral Bare MCP channel keeps it unmarked (#684/#548).
541    let compression = compression_text(level);
542    if !compression.is_empty() {
543        body.push('\n');
544        if matches!(wrapper, Wrapper::Bare) {
545            body.push_str(compression);
546        } else {
547            body.push_str(COMPRESSION_BLOCK_START);
548            body.push('\n');
549            body.push_str(compression);
550            body.push('\n');
551            body.push_str(COMPRESSION_BLOCK_END);
552        }
553    }
554
555    // Solution Intelligence block: inject efficiency ladder when enabled. The
556    // per-turn profiles carry it only on opt-in (`inject_in_instructions`, A3);
557    // the on-demand LONGFORM guide always does.
558    let ladder = if matches!(wrapper, Wrapper::Longform) || solution.inject_in_instructions {
559        solution.ladder_text()
560    } else {
561        ""
562    };
563    if !ladder.is_empty() {
564        body.push('\n');
565        if matches!(wrapper, Wrapper::Bare) {
566            body.push_str(ladder);
567        } else {
568            body.push_str(SOLUTION_BLOCK_START);
569            body.push('\n');
570            body.push_str("SOLUTION EFFICIENCY: stop at first level that applies:\n");
571            body.push_str("skip (YAGNI) → reuse codebase → stdlib → native platform → installed dep → one-line → minimum code.\n");
572            body.push_str("Never skip: validation, security, error handling.\n");
573            body.push_str(SOLUTION_BLOCK_END);
574        }
575    }
576
577    if matches!(wrapper, Wrapper::Bare) {
578        return body;
579    }
580
581    let version_line = format!("<!-- version: {RULES_VERSION} -->");
582
583    format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
584}
585
586/// Unmarked render of the hook-covered profile for ephemeral channels
587/// (the mcp.json `instructions` snapshot on hook-covered hosts, GL #1153).
588/// The `Bare` counterpart of `Wrapper::HookCovered`: same body, no markers —
589/// per-session channels are governed by carrier coverage, so markers would be
590/// noise (see [`COMPRESSION_BLOCK_START`]). Shadow collapses to the regular
591/// bare shadow profile (interception supersedes hook coverage).
592pub fn render_hook_covered_bare(
593    shadow: bool,
594    level: CompressionLevel,
595    tool_profile: &super::tool_profiles::ToolProfile,
596) -> String {
597    if shadow {
598        return render(true, Wrapper::Bare, level, tool_profile);
599    }
600    let sections = hook_covered_non_shadow_sections(tool_profile);
601    let mut body: String = sections
602        .into_iter()
603        .filter(|s| !s.is_empty())
604        .collect::<Vec<_>>()
605        .join("\n\n");
606    let compression = compression_text(level);
607    if !compression.is_empty() {
608        body.push('\n');
609        body.push_str(compression);
610    }
611    body
612}
613// Rules-file parsing and validation live in the sibling module.
614pub use super::rules_validation::RulesFile;
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    fn tp() -> super::super::tool_profiles::ToolProfile {
621        super::super::tool_profiles::ToolProfile::Power
622    }
623
624    // --- Constants ---
625
626    #[test]
627    fn bullets_uses_ctx_shell() {
628        assert!(BULLETS.contains("ctx_shell"));
629        assert!(!BULLETS.contains("lean-ctx -c"));
630        assert!(!BULLETS.contains("ctx_edit"));
631    }
632
633    #[test]
634    fn sections_not_empty() {
635        assert!(!BULLETS.is_empty(), "`BULLETS` is empty");
636        assert!(!NEVER.is_empty(), "`NEVER` is empty");
637        assert!(!INTENT.is_empty(), "`INTENT` is empty");
638        assert!(!ANTI.is_empty(), "`ANTI` is empty");
639        assert!(!PARALLEL.is_empty(), "`PARALLEL` is empty");
640        assert!(!AUTO.is_empty(), "`AUTO` is empty");
641        assert!(!CEP.is_empty(), "`CEP` is empty");
642        assert!(!LITM_END.is_empty(), "`LITM_END` is empty");
643        assert!(!CRITICAL.is_empty(), "`CRITICAL` is empty");
644    }
645
646    #[test]
647    fn intent_contains_ctx_compose() {
648        assert!(INTENT.contains("ctx_compose"));
649    }
650
651    #[test]
652    fn anti_contains_do_not() {
653        assert!(ANTI.contains("do NOT"));
654    }
655
656    #[test]
657    fn parallel_contains_parallel() {
658        assert!(PARALLEL.contains("PARALLEL"));
659    }
660
661    // --- Agent loop + navigation paradox (#609) ---
662
663    #[test]
664    fn agent_loop_names_every_phase() {
665        for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
666            assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
667        }
668        assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
669    }
670
671    #[test]
672    fn nav_paradox_steers_semantic_vs_graph() {
673        assert!(
674            NAV_PARADOX.contains("ctx_search(action=semantic)"),
675            "semantic route must use folded action (#509)"
676        );
677        assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
678        assert!(
679            NAV_PARADOX.contains("≠"),
680            "must carry the reading≠understanding thesis"
681        );
682    }
683
684    #[test]
685    fn longform_carries_loop_and_paradox_injected_full_does_not() {
686        // v5 (#578): the verbose teaching sections live only in the on-demand
687        // LEAN-CTX.md (Longform); the injected dedicated files fold the loop
688        // phases + paradox thesis into INTENT.
689        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
690        assert!(
691            long.contains("AGENT LOOP"),
692            "LONGFORM must carry AGENT_LOOP"
693        );
694        assert!(
695            long.contains("NAVIGATION PARADOX"),
696            "LONGFORM must carry NAV_PARADOX"
697        );
698        assert!(long.contains(CEP), "LONGFORM must carry CEP");
699
700        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
701        assert!(
702            !full.contains("AGENT LOOP (phase -> tool):"),
703            "injected FULL must not inline the multi-line AGENT_LOOP block"
704        );
705        assert!(
706            !full.contains("NAVIGATION PARADOX: reading"),
707            "injected FULL must not inline the multi-line NAV_PARADOX block"
708        );
709        // #509: ctx_symbol folded into ctx_search(action=symbol)
710        for phase_tool in [
711            "ctx_compose",
712            "ctx_search(action=symbol)",
713            "ctx_search",
714            "ctx_callgraph",
715        ] {
716            assert!(
717                full.contains(phase_tool),
718                "FULL keeps the loop tools via INTENT: {phase_tool}"
719            );
720        }
721    }
722
723    #[test]
724    fn injected_profiles_stay_lean() {
725        // The whole point of v5 (#578): injected files bill every session.
726        // chars/4 ≈ tokens — the dedicated body must stay around ~470 tok and
727        // the Longform must be a strict superset.
728        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
729        eprintln!(
730            "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
731            full.len(),
732            full.len() / 4,
733            render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
734            render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
735        );
736        assert!(
737            full.len() <= 2400,
738            "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
739            full.len(),
740            full.len() / 4
741        );
742        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
743        assert!(
744            long.len() > full.len(),
745            "Longform ({}) must carry more than the injected profile ({})",
746            long.len(),
747            full.len()
748        );
749        let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
750        assert!(
751            compact.len() < full.len(),
752            "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
753            compact.len(),
754            full.len()
755        );
756    }
757
758    #[test]
759    fn compact_profile_has_no_multiline_teaching_sections() {
760        // COMPACT (shared + Bare) keeps the per-session channel lean: no
761        // multi-line AGENT_LOOP/NAV_PARADOX blocks; INTENT carries the thesis.
762        let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
763        assert!(
764            !out.contains("AGENT LOOP (phase -> tool):"),
765            "COMPACT must not inline the multi-line AGENT_LOOP block"
766        );
767        assert!(
768            !out.contains("NAVIGATION PARADOX: reading"),
769            "COMPACT must not inline the multi-line NAV_PARADOX block"
770        );
771    }
772
773    #[test]
774    fn shadow_omits_loop_and_paradox() {
775        // #963: shadow collapses to the irreducible minimum — the routing
776        // taxonomy is redundant once native calls are intercepted.
777        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
778            let out = render(true, wrapper, CompressionLevel::Off, &tp());
779            assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
780            assert!(
781                !out.contains("NAVIGATION PARADOX"),
782                "{wrapper:?} shadow drops paradox"
783            );
784        }
785    }
786
787    #[test]
788    fn recover_reaches_every_non_shadow_carrier() {
789        // The recovery vocabulary must reach every non-shadow carrier so agents
790        // never re-read compressed output line-by-line, and every carrier must
791        // keep the MCP-free path ("read the shown path") for orgs that ban MCP.
792        // v5 (#578): only Longform carries the verbose RECOVER; every injected
793        // profile (Dedicated FULL + Shared/Bare COMPACT) carries the terse
794        // RECOVER_COMPACT one-liner.
795        let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
796        assert!(
797            long.contains(RECOVER),
798            "Longform must carry the verbose RECOVER verbatim"
799        );
800        for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
801            let out = render(false, wrapper, CompressionLevel::Off, &tp());
802            assert!(
803                out.contains(RECOVER_COMPACT),
804                "{wrapper:?} must carry RECOVER_COMPACT verbatim"
805            );
806            assert!(
807                !out.contains(RECOVER),
808                "{wrapper:?} must not inline the verbose RECOVER block"
809            );
810        }
811        for wrapper in [
812            Wrapper::Longform,
813            Wrapper::Dedicated,
814            Wrapper::Shared,
815            Wrapper::Bare,
816        ] {
817            assert!(
818                render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
819                "{wrapper:?} recovery line must keep the MCP-free path"
820            );
821        }
822        for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
823            let out = render(true, wrapper, CompressionLevel::Off, &tp());
824            assert!(
825                !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
826                "{wrapper:?} shadow drops all RECOVER guidance"
827            );
828        }
829    }
830
831    // --- render() — Dedicated ---
832
833    #[test]
834    fn dedicated_has_markers_and_version() {
835        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
836        assert!(out.contains(START_MARK));
837        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
838        assert!(out.contains(END_MARK));
839        assert!(out.contains(BULLETS));
840        assert!(out.contains(NEVER));
841        assert!(out.contains(CRITICAL));
842    }
843
844    #[test]
845    fn dedicated_shadow_is_minimal() {
846        // #963: shadow drops the whole tool-mapping AND routing playbook —
847        // interception makes them redundant. Only the exclusive-tool advert remains.
848        let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
849        assert!(out.contains(START_MARK));
850        assert!(!out.contains(BULLETS), "no BULLETS in shadow");
851        assert!(!out.contains(NEVER), "no NEVER in shadow");
852        assert!(!out.contains(CRITICAL), "no routing header in shadow");
853        assert!(
854            !out.contains("By task:"),
855            "routing INTENT block is redundant under interception"
856        );
857        assert!(
858            !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
859            "ANTI/PARALLEL routing guidance is dropped in shadow"
860        );
861        assert!(
862            out.contains("shadow mode") && out.contains("ctx_compose"),
863            "shadow keeps the exclusive-tool advert"
864        );
865    }
866
867    #[test]
868    fn shadow_is_smaller_than_non_shadow() {
869        // The whole point of #963: the shadow body must be a strict reduction.
870        let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
871        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
872        assert!(
873            shadow.len() < full.len(),
874            "shadow ({}) must be smaller than non-shadow ({})",
875            shadow.len(),
876            full.len()
877        );
878    }
879
880    #[test]
881    fn dedicated_litm_structure() {
882        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
883        let lines: Vec<&str> = out.lines().collect();
884        let first_5 = lines[..5.min(lines.len())].join("\n");
885        // v13 (A3): the routing header leads; the end-of-file preference
886        // repeat is LONGFORM-only (it restated the mapping on every request).
887        assert!(
888            first_5.contains(CRITICAL),
889            "LITM: routing near start, head={first_5:?}"
890        );
891        assert!(!out.contains("TOOL PREFERENCE (END)"));
892    }
893
894    #[test]
895    fn dedicated_carries_weak_model_invoke_nudge() {
896        // #1067/GH #593: the "actually CALL ctx_*" nudge must ride every dedicated
897        // rule file (Windsurf, Cursor, Claude, …) in non-shadow mode, and must be
898        // absent where it is dead weight: shadow mode (call-layer routing) and the
899        // Bare/instructions channel (separately capped).
900        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
901        assert!(
902            dedicated.contains(MUST_INVOKE),
903            "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
904        );
905        assert!(
906            !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
907            "shadow mode must not carry the nudge (routing is enforced at the call layer)"
908        );
909        assert!(
910            !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
911            "Bare/instructions channel is capped separately and carries no copy"
912        );
913    }
914
915    // --- render() — Shared ---
916
917    #[test]
918    fn shared_has_markers_and_header() {
919        let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
920        assert!(out.contains(START_MARK));
921        assert!(out.contains(END_MARK));
922        assert!(out.contains(CRITICAL));
923        assert!(out.contains(BULLETS));
924    }
925
926    #[test]
927    fn shared_shadow_omits_mapping() {
928        let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
929        assert!(out.contains(START_MARK));
930        assert!(!out.contains(CRITICAL), "shadow must not have header");
931        assert!(!out.contains(BULLETS), "shadow must not contain BULLETS");
932    }
933
934    // --- render() — Bare ---
935
936    #[test]
937    fn bare_has_no_markers() {
938        let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
939        assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
940        assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
941        assert!(!out.contains("<!-- version:"), "Bare must not have version");
942        assert!(out.contains(BULLETS));
943        assert!(out.contains(NEVER));
944    }
945
946    #[test]
947    fn bare_shadow_only_read_modes() {
948        let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
949        assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
950        assert!(!out.contains(BULLETS), "shadow Bare must not have BULLETS");
951    }
952
953    // --- Compression level tests ---
954
955    #[test]
956    fn render_includes_lite_prompt() {
957        let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
958        assert!(out.contains("OUTPUT STYLE: concise"));
959        assert!(out.contains("Bullet points"));
960    }
961
962    #[test]
963    fn render_includes_standard_prompt() {
964        let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
965        assert!(out.contains("OUTPUT STYLE: dense"));
966        assert!(out.contains("atomic fact"));
967    }
968
969    #[test]
970    fn render_includes_max_prompt() {
971        let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
972        assert!(out.contains("OUTPUT STYLE: expert-terse"));
973        assert!(out.contains("Telegraph"));
974    }
975
976    #[test]
977    fn render_off_excludes_compression() {
978        let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
979        assert!(!out.contains("OUTPUT STYLE:"));
980    }
981
982    #[test]
983    fn compression_text_matches_level() {
984        {
985            let found = &compression_text(CompressionLevel::Off);
986            assert!(found.is_empty(), "{found:?}");
987        }
988        assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
989        assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
990        assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
991    }
992
993    // --- Compression marker model (#548 B2) ---
994
995    #[test]
996    fn carrier_wrappers_wrap_compression_in_markers() {
997        // Persistent carriers must delimit the compression payload so coverage
998        // and dedup can detect/thin it (#684/#548).
999        for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1000            let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1001            assert!(
1002                out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1003                "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1004            );
1005            // The marked region must actually contain the prompt body.
1006            let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1007            let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1008            assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1009            assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1010        }
1011    }
1012
1013    #[test]
1014    fn bare_wrapper_emits_compression_without_markers() {
1015        // The ephemeral MCP channel keeps the payload unmarked — its inclusion is
1016        // governed by carrier coverage, so per-session markers would be noise.
1017        let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1018        assert!(out.contains("OUTPUT STYLE: dense"));
1019        assert!(!out.contains(COMPRESSION_BLOCK_START));
1020        assert!(!out.contains(COMPRESSION_BLOCK_END));
1021    }
1022
1023    #[test]
1024    fn compression_off_emits_no_markers_in_any_wrapper() {
1025        for wrapper in [
1026            Wrapper::Longform,
1027            Wrapper::Dedicated,
1028            Wrapper::Shared,
1029            Wrapper::Bare,
1030        ] {
1031            let out = render(false, wrapper, CompressionLevel::Off, &tp());
1032            assert!(
1033                !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1034                "{wrapper:?}: Off must emit no compression markers"
1035            );
1036        }
1037    }
1038
1039    #[test]
1040    fn rendered_carrier_block_is_seen_as_carrying_compression() {
1041        // The detection helper that coverage/dedup rely on must agree with the
1042        // writer's output (the bug this slice fixes: it previously never did).
1043        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1044        assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1045        assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1046    }
1047
1048    // --- Wrapper round-trip ---
1049
1050    #[test]
1051    fn all_wrappers_produce_output() {
1052        for shadow in [false, true] {
1053            for wrapper in [
1054                Wrapper::Longform,
1055                Wrapper::Dedicated,
1056                Wrapper::Shared,
1057                Wrapper::Bare,
1058            ] {
1059                let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1060                assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1061            }
1062        }
1063    }
1064
1065    // --- RulesFile ---
1066
1067    #[test]
1068    fn rules_file_parses_version() {
1069        let content = format!(
1070            "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1071        );
1072        let f = RulesFile::parse(&content);
1073        assert!(f.has_content());
1074        assert_eq!(f.version(), RULES_VERSION);
1075        assert!(f.is_current());
1076        assert!(f.prefix().contains("stuff before"));
1077        assert!(f.suffix().contains("stuff after"));
1078    }
1079
1080    #[test]
1081    fn rules_file_no_version_defaults_to_zero() {
1082        let content = format!("{START_MARK}\nbody\n{END_MARK}");
1083        let f = RulesFile::parse(&content);
1084        assert!(f.has_content());
1085        assert_eq!(f.version(), 0);
1086        assert!(!f.is_current());
1087    }
1088
1089    #[test]
1090    fn rules_file_no_start_marker_no_content() {
1091        let f = RulesFile::parse("just user stuff");
1092        assert!(!f.has_content());
1093        assert_eq!(f.version(), 0);
1094    }
1095
1096    #[test]
1097    fn block_matches_render_true_for_fresh_render() {
1098        let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1099        let content = format!("user before\n{fresh}\nuser after");
1100        let f = RulesFile::parse(&content);
1101        assert!(f.is_current(), "fresh render carries the current version");
1102        assert!(
1103            f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1104            "an unchanged block must compare equal to a fresh render"
1105        );
1106    }
1107
1108    #[test]
1109    fn block_matches_render_false_on_compression_change() {
1110        // Body rendered at Off, then asked whether it matches a Max render:
1111        // the version is identical but the compression payload differs (#548).
1112        let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1113        let f = RulesFile::parse(&content);
1114        assert!(f.is_current());
1115        assert!(
1116            !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1117            "a compression-level change must be detected as drift"
1118        );
1119    }
1120
1121    #[test]
1122    fn block_matches_render_false_on_shadow_change() {
1123        let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1124        let f = RulesFile::parse(&content);
1125        assert!(
1126            !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1127            "a shadow-mode toggle must be detected as drift"
1128        );
1129    }
1130
1131    #[test]
1132    fn block_matches_render_false_without_block() {
1133        let f = RulesFile::parse("plain user content, no markers");
1134        assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1135    }
1136
1137    #[test]
1138    fn rules_file_merged_replaces_section() {
1139        let content =
1140            format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1141        let f = RulesFile::parse(&content);
1142        let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1143        assert!(merged.contains("before"), "prefix preserved");
1144        assert!(merged.contains("after"), "suffix preserved");
1145        assert!(!merged.contains("old"), "old content replaced");
1146        assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1147    }
1148
1149    #[test]
1150    fn rules_file_merged_appends_when_no_section() {
1151        let content = "user content";
1152        let f = RulesFile::parse(content);
1153        assert!(!f.has_content());
1154        let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1155        assert!(merged.contains("user content"));
1156        assert!(merged.contains(BULLETS));
1157    }
1158
1159    #[test]
1160    fn rules_file_without_section_strips_content() {
1161        let content =
1162            format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1163        let f = RulesFile::parse(&content);
1164        let stripped = f.without_section();
1165        assert!(stripped.contains("header"));
1166        assert!(stripped.contains("footer"));
1167        assert!(!stripped.contains("body"));
1168        assert!(!stripped.contains(START_MARK));
1169    }
1170
1171    #[test]
1172    fn rules_file_without_section_noop_when_no_content() {
1173        let content = "just user text";
1174        let f = RulesFile::parse(content);
1175        assert_eq!(f.without_section(), content);
1176    }
1177
1178    #[test]
1179    fn bullets_lead_with_four_core_redirects() {
1180        // Most-used routes (Read/Grep/Shell/Glob) lead; ls->ctx_tree trails.
1181        let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1182        let search = BULLETS
1183            .find("ctx_search")
1184            .expect("ctx_search mapping present");
1185        let shell = BULLETS
1186            .find("ctx_shell")
1187            .expect("ctx_shell mapping present");
1188        let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1189        let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1190        assert!(
1191            read < search && search < shell && shell < glob && glob < tree,
1192            "core redirects (read<search<shell<glob) must precede ctx_tree"
1193        );
1194    }
1195
1196    #[test]
1197    fn injected_rules_state_routing_without_shouting() {
1198        // v13 (A3): routing facts once, neutrally — pilot P6 showed the
1199        // "ALWAYS"/"NEVER"/"orient FIRST" pushes adding steps on short tasks.
1200        // The #1788 scope boundary (other MCP servers) stays.
1201        assert!(NEVER.contains("other MCP servers keep their own jobs"));
1202        for wrapper in [
1203            Wrapper::Dedicated,
1204            Wrapper::Shared,
1205            Wrapper::Bare,
1206            Wrapper::HookCovered,
1207        ] {
1208            let out = render(false, wrapper, CompressionLevel::Off, &tp());
1209            for shout in [
1210                "ALWAYS",
1211                "NEVER",
1212                "SELF-CORRECT",
1213                "FIRST",
1214                "MANDATORY",
1215                "CRITICAL",
1216            ] {
1217                assert!(
1218                    !out.contains(shout),
1219                    "{wrapper:?} carries {shout:?}:\n{out}"
1220                );
1221            }
1222        }
1223    }
1224
1225    // --- HookCovered profile (GL #1153) ---
1226
1227    #[test]
1228    fn hook_covered_carries_strict_mapping() {
1229        // v13 (A3): HookCovered lists the tools neutrally; hooks cover the
1230        // native calls, direct ctx_* calls add read depth and memory.
1231        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1232        assert!(
1233            !out.contains(NEVER),
1234            "HookCovered uses its own strict header, not the NEVER constant"
1235        );
1236        assert!(
1237            out.contains(HOOK_COVERED_HEADER),
1238            "must carry the strict preference header"
1239        );
1240        assert!(
1241            out.contains("lean-ctx tools:")
1242                && out.contains("ctx_read")
1243                && out.contains("ctx_search"),
1244            "must carry the full mandatory mapping"
1245        );
1246        assert!(
1247            out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1248            "must advertise the search capabilities"
1249        );
1250    }
1251
1252    #[test]
1253    fn hook_covered_keeps_markers_version_and_recovery() {
1254        // Coverage detection (rules_channel::carries_full_rules /
1255        // client_autoloads_rules) and the injector's drift check both key on
1256        // the canonical markers — HookCovered must stay a first-class carrier.
1257        let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1258        assert!(out.contains(START_MARK) && out.contains(END_MARK));
1259        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1260        assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1261        assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1262    }
1263
1264    #[test]
1265    fn hook_covered_is_leaner_than_full() {
1266        let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1267        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1268        assert!(
1269            covered.len() < full.len(),
1270            "HookCovered ({}) must be a strict reduction of FULL ({})",
1271            covered.len(),
1272            full.len()
1273        );
1274    }
1275
1276    #[test]
1277    fn hook_covered_shadow_collapses_to_minimal() {
1278        // Interception supersedes hook coverage — same minimal profile as
1279        // Dedicated shadow.
1280        let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1281        let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1282        assert_eq!(covered_shadow, dedicated_shadow);
1283    }
1284
1285    #[test]
1286    fn hook_covered_wraps_compression_in_markers() {
1287        let out = render(
1288            false,
1289            Wrapper::HookCovered,
1290            CompressionLevel::Standard,
1291            &tp(),
1292        );
1293        assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1294        assert!(out.contains("OUTPUT STYLE: dense"));
1295    }
1296
1297    // --- Profile-aware rules (#756) ---
1298
1299    #[test]
1300    fn rules_only_mention_enabled_tools() {
1301        use super::super::rules_sections;
1302        use super::super::tool_profiles::ToolProfile;
1303        let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1304        for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1305            let mut sections = vec![
1306                rules_sections::intent_section(&profile),
1307                rules_sections::hook_covered_tools_section(&profile),
1308                rules_sections::shadow_minimal_section(&profile),
1309                rules_sections::anti_section(&profile),
1310                rules_sections::litm_end_section(&profile),
1311            ];
1312            if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1313                sections.push(fb);
1314            }
1315            for tool in &exclusive_tools {
1316                if !profile.is_tool_enabled(tool) {
1317                    for section in &sections {
1318                        assert!(
1319                            !section.contains(tool),
1320                            "profile {:?} dynamic section must not mention disabled tool {tool}",
1321                            profile.as_str()
1322                        );
1323                    }
1324                }
1325            }
1326        }
1327    }
1328
1329    #[test]
1330    fn minimal_rules_shorter_than_standard() {
1331        use super::super::tool_profiles::ToolProfile;
1332        let min = render(
1333            false,
1334            Wrapper::Dedicated,
1335            CompressionLevel::Off,
1336            &ToolProfile::Minimal,
1337        );
1338        let std = render(
1339            false,
1340            Wrapper::Dedicated,
1341            CompressionLevel::Off,
1342            &ToolProfile::Standard,
1343        );
1344        assert!(
1345            min.len() < std.len(),
1346            "minimal ({}) must be shorter than standard ({})",
1347            min.len(),
1348            std.len()
1349        );
1350    }
1351
1352    #[test]
1353    fn power_profile_output_identical_to_default() {
1354        use super::super::tool_profiles::ToolProfile;
1355        let power = render(
1356            false,
1357            Wrapper::Dedicated,
1358            CompressionLevel::Off,
1359            &ToolProfile::Power,
1360        );
1361        assert!(
1362            power.contains("ctx_compose"),
1363            "Power must include all tools"
1364        );
1365        assert!(
1366            power.contains("ctx_callgraph"),
1367            "Power must include all tools"
1368        );
1369        assert!(
1370            power.contains("ctx_session"),
1371            "Power must include all tools"
1372        );
1373    }
1374
1375    #[test]
1376    fn render_is_deterministic_across_profiles() {
1377        use super::super::tool_profiles::ToolProfile;
1378        for profile in [
1379            ToolProfile::Minimal,
1380            ToolProfile::Standard,
1381            ToolProfile::Power,
1382        ] {
1383            let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1384            let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1385            assert_eq!(
1386                a,
1387                b,
1388                "render must be deterministic for {:?}",
1389                profile.as_str()
1390            );
1391        }
1392    }
1393
1394    #[test]
1395    fn minimal_has_ctx_call_fallback() {
1396        use super::super::tool_profiles::ToolProfile;
1397        let rules = render(
1398            false,
1399            Wrapper::Dedicated,
1400            CompressionLevel::Off,
1401            &ToolProfile::Minimal,
1402        );
1403        assert!(
1404            rules.contains("ctx_call"),
1405            "minimal profile must include ctx_call gateway hint"
1406        );
1407    }
1408
1409    #[test]
1410    fn power_has_no_ctx_call_fallback() {
1411        use super::super::tool_profiles::ToolProfile;
1412        let rules = render(
1413            false,
1414            Wrapper::Dedicated,
1415            CompressionLevel::Off,
1416            &ToolProfile::Power,
1417        );
1418        assert!(
1419            !rules.contains("ctx_call(tool="),
1420            "power profile must not include ctx_call gateway hint"
1421        );
1422    }
1423}