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