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