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/// Closing marker that ends a lean-ctx rule section.
36pub const END_MARK: &str = "<!-- /lean-ctx-rules -->";
37
38/// Markers of the heavy compression / output-style block — the per-turn payload
39/// that drives cross-channel duplication (#684/#548).
40///
41/// `render()` wraps the compression prompt in these markers for **persistent
42/// carriers** (the `Dedicated` and `Shared` wrappers, i.e. every injected rule
43/// file). This is the single carrier/marker model: coverage and dedup
44/// (`core::rules_channel`, `cli::rules_dedup`) detect and thin the payload by
45/// these markers, so the writer and the readers can never disagree again. The
46/// ephemeral `Bare` MCP-instructions channel deliberately omits the markers —
47/// its inclusion is *governed* by carrier coverage (`client_autoloads_compression`),
48/// so a per-session marker would be pure noise.
49pub const COMPRESSION_BLOCK_START: &str = "<!-- lean-ctx-compression -->";
50
51/// Closing marker of the compression / output-style block (see
52/// [`COMPRESSION_BLOCK_START`]).
53pub const COMPRESSION_BLOCK_END: &str = "<!-- /lean-ctx-compression -->";
54
55/// Current rules version (monotonically increasing integer).  Embedded as
56/// `<!-- version: {RULES_VERSION} -->` right after `START_MARK` so the
57/// injection layer can parse it and decide whether a file is up-to-date.
58pub const RULES_VERSION: usize = 1;
59
60/// Banner placed at the top of dedicated rule files (non-shadow only).
61pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx tools instead of native equivalents. \
62     This is NOT optional.";
63
64/// Compact bullet-list mapping (for both dedicated and shared contexts).
65pub const BULLETS: &str = "\
66MANDATORY MAPPING:\n\
67• Read/cat -> ctx_read(path, mode)\n\
68• Glob/find -> ctx_glob(pattern)\n\
69• Shell/bash -> ctx_shell(command)\n\
70• Grep -> ctx_search(pattern, path)\n\
71• ls/find -> ctx_tree(path, depth)";
72
73/// One-line admonition reinforcing the tool preference.  Always right after
74/// `BULLETS` in both profiles.
75pub const NEVER: &str =
76    "NEVER use native Read/Grep/Shell/Glob when ctx_* equivalents are available.";
77
78/// Intent-to-tool playbook — maps common agent questions to the right tool.
79pub const INTENT: &str = "\
80Tool selection by intent:\n\
81• Understand code / find answers / before editing -> ctx_compose (call FIRST)\n\
82• Read a file -> ctx_read(path, mode=signatures|map|full)\n\
83• Find a symbol by name (exact) -> ctx_symbol\n\
84• Search code by pattern (fuzzy) -> ctx_search\n\
85• Search by meaning (concepts) -> ctx_semantic_search\n\
86• Find files by pattern (glob) -> ctx_glob\n\
87• Project structure -> ctx_tree\n\
88• Who calls this / call graph -> ctx_callgraph\n\
89• Session state / memory -> ctx_session / ctx_knowledge";
90
91/// Anti-patterns that waste tokens and round-trips.
92pub const ANTI: &str = "\
93Anti-patterns — do NOT:\n\
94• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
95• Grep for symbol definitions — ctx_symbol is faster + more precise\n\
96• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
97• Use ctx_callgraph or ctx_graph for const/static/variable references — they track\n\
98  function call edges and file-level deps only. Use grep or ctx_compose instead";
99
100/// Encourages parallel tool calls to reduce round-trips.
101pub const PARALLEL: &str = "\
102PARALLEL tool calls: fire independent calls in the SAME turn — don't sequence them.\n\
103ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
104cover, batch independent reads/searches together.";
105
106/// One-line automation reminder.
107pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
108    ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
109    ctx_shell raw=true=uncompressed. Details: LEAN-CTX.md";
110
111/// Context Engineering Protocol version reference.
112pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
113     4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
114
115/// Output style rule.
116pub const INTELLIGENCE: &str =
117    "OUTPUT: never echo tool output, no narration comments, show only changed code.";
118
119/// LITM end-of-instructions preference line.
120pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
121     ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
122
123/// Minimal rules body for shadow mode (#963). Under shadow-mode interception
124/// native Read/Grep/Shell/Glob calls are transparently routed to ctx_*, so the
125/// tool-mapping and "use ctx_* instead of native" guidance is dead weight — the
126/// enforcement happens at the call layer, not in the prompt. Only the lean-ctx
127/// tools that have *no* native trigger to intercept still need advertising.
128pub const SHADOW_MINIMAL: &str = "\
129lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
130Exclusive 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).";
131
132// ── Output-style compression prompts ───────────────────────────
133
134/// Lite compression — concise, bullet-point output.
135pub const LITE_PROMPT: &str = "\
136OUTPUT STYLE: concise
137- Bullet points over paragraphs
138- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
139- 1-sentence explanations max, then code/action
140- No repeating what the user said";
141
142/// Standard compression — dense, atomic fact lines, abbreviations.
143pub const STANDARD_PROMPT: &str = "\
144OUTPUT STYLE: dense
145- Each statement = one atomic fact line
146- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
147- Diff lines only (+/-/~), never repeat unchanged code
148- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
149- No narration, no filler, no hedging
150- BUDGET: ≤200 tokens per response unless code block required";
151
152/// Max compression — expert-terse, telegraph format, symbolic vocabulary.
153pub const MAX_PROMPT: &str = "\
154OUTPUT STYLE: expert-terse
155- Telegraph format: subject-verb-object, drop articles/prepositions
156- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
157- Code blocks: untouched (never compress code syntax)
158- Each line: max 80 chars
159- Zero narration, zero filler
160- BUDGET: ≤100 tokens per non-code response";
161
162/// Return the compression prompt text for a given level (empty string for Off).
163pub fn compression_text(level: CompressionLevel) -> &'static str {
164    match level {
165        CompressionLevel::Off => "",
166        CompressionLevel::Lite => LITE_PROMPT,
167        CompressionLevel::Standard => STANDARD_PROMPT,
168        CompressionLevel::Max => MAX_PROMPT,
169    }
170}
171
172const FULL_NON_SHADOW: &[&str] = &[
173    CRITICAL,
174    BULLETS,
175    NEVER,
176    INTENT,
177    ANTI,
178    PARALLEL,
179    AUTO,
180    CEP,
181    INTELLIGENCE,
182    LITM_END,
183];
184
185// #963: shadow profiles collapse to the irreducible minimum. Every routing
186// section (INTENT/ANTI/PARALLEL/AUTO/CEP/LITM_END) is redundant once native
187// calls are intercepted; only SHADOW_MINIMAL (exclusive tools) plus the output
188// style survive. Footprint reduction is provable via the #959 delta harness.
189const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
190
191const COMPACT_NON_SHADOW: &[&str] = &[CRITICAL, BULLETS, NEVER, INTENT, ANTI, PARALLEL];
192
193const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
194
195/// Selects the profile (FULL vs COMPACT) and the wrapping style (markers,
196/// headers, footers) for `render()`.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Wrapper {
199    /// **Dedicated rule file.**  FULL profile.  Wrapped with `START_MARK`,
200    /// `<!-- version: N -->`, and `END_MARK`.  Non-shadow includes the
201    /// `CRITICAL` banner before the body.  The whole file is lean-ctx–owned;
202    /// the injection layer detects staleness by parsing the version comment.
203    Dedicated,
204
205    /// **Shared file section** (appended to AGENTS.md, GEMINI.md, etc.).
206    /// COMPACT profile.  Same marker wrapping for find/replace within a
207    /// larger shared file.  Non-shadow includes `## Tool Mapping` header.
208    Shared,
209
210    /// **MCP session instructions.**  COMPACT profile.  No markers or
211    /// headers — bare content used inline in per-session MCP instructions.
212    Bare,
213}
214
215/// Render lean-ctx rules for a given wrapper, shadow mode, and compression level.
216///
217/// * `shadow` — when true, tool-mapping sections (BULLETS, NEVER,
218///   CRITICAL banner, "## Tool Mapping" header) are omitted.
219/// * `wrapper` — selects the profile (FULL / COMPACT) and wrapping style.
220/// * `level` — selects the output-style compression prompt (Lite / Standard /
221///   Max) which is appended to the body. `Off` omits it.
222pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
223    let profile = match (wrapper, shadow) {
224        (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
225        (Wrapper::Dedicated, true) => FULL_SHADOW,
226        (_, false) => COMPACT_NON_SHADOW,
227        (_, true) => COMPACT_SHADOW,
228    };
229
230    let mut body = profile.join("\n\n");
231
232    // Append the compression / output-style prompt for active levels. Persistent
233    // carriers (Dedicated, Shared) wrap it in the canonical COMPRESSION_BLOCK
234    // markers so coverage/dedup (rules_channel, rules_dedup) can detect and thin
235    // it; the ephemeral Bare MCP channel keeps it unmarked (#684/#548).
236    let compression = compression_text(level);
237    if !compression.is_empty() {
238        body.push('\n');
239        if matches!(wrapper, Wrapper::Bare) {
240            body.push_str(compression);
241        } else {
242            body.push_str(COMPRESSION_BLOCK_START);
243            body.push('\n');
244            body.push_str(compression);
245            body.push('\n');
246            body.push_str(COMPRESSION_BLOCK_END);
247        }
248    }
249
250    if matches!(wrapper, Wrapper::Bare) {
251        return body;
252    }
253
254    let version_line = format!("<!-- version: {RULES_VERSION} -->");
255
256    format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
257}
258// ============================================================
259// RULES FILE — centralized interface for reading rule files
260// ============================================================
261
262/// A parsed lean-ctx rules section from a file on disk.
263///
264/// Handles version detection, content boundary discovery, and prefix/suffix
265/// extraction.  This is the **only** place that parses `START_MARK` / version
266/// comments — every consumer (injection, drift detection, status reporting)
267/// goes through this struct.
268#[derive(Debug)]
269pub struct RulesFile<'a> {
270    content: &'a str,
271    /// Byte offset of `START_MARK` (or the first old-format marker found).
272    start: Option<usize>,
273    /// Byte offset of `END_MARK`.
274    end: Option<usize>,
275    /// Parsed version number (0 if no `<!-- version: N -->` comment found).
276    version: usize,
277}
278
279/// Parse the version number from the first `<!-- version: N -->` comment
280/// found at or after `search_start`.
281fn parse_version_number(s: &str) -> Option<usize> {
282    let prefix = "<!-- version: ";
283    let vs = s.find(prefix)?;
284    let num_start = vs + prefix.len();
285    let end = s[num_start..].find(" -->")?;
286    s[num_start..num_start + end].parse().ok()
287}
288
289impl<'a> RulesFile<'a> {
290    /// Parse `content`, scanning for `START_MARK` and version comment.
291    ///
292    /// * `START_MARK` not found → `has_content() = false`, version = 0.
293    /// * `START_MARK` found but no version → `has_content() = true`, version = 0
294    ///   (assume older than current → needs update).
295    pub fn parse(content: &'a str) -> Self {
296        let start = content.find(START_MARK);
297        let version = start
298            .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
299            .unwrap_or(0);
300        let end = content.find(END_MARK);
301        RulesFile {
302            content,
303            start,
304            end,
305            version,
306        }
307    }
308
309    /// Whether the file carries any lean-ctx rules content.
310    pub fn has_content(&self) -> bool {
311        self.start.is_some()
312    }
313
314    /// The detected version (0 if no version marker — treat as older than
315    /// `RULES_VERSION`).
316    pub fn version(&self) -> usize {
317        self.version
318    }
319
320    /// Whether the file's version is at least `RULES_VERSION`.
321    pub fn is_current(&self) -> bool {
322        self.version >= RULES_VERSION
323    }
324
325    /// Content before the first `START_MARK` (user content / frontmatter).
326    /// Returns an empty string if no start marker was found.
327    pub fn prefix(&self) -> &'a str {
328        self.start.map_or("", |s| self.content[..s].trim())
329    }
330
331    /// Content after the last `END_MARK` (user content after the lean-ctx
332    /// block).  Returns an empty string if no end marker was found.
333    pub fn suffix(&self) -> &'a str {
334        self.end
335            .map_or("", |e| self.content[e + END_MARK.len()..].trim())
336    }
337
338    /// The lean-ctx block on disk, from `START_MARK` through `END_MARK`
339    /// (inclusive), if both markers are present.
340    fn block(&self) -> Option<&'a str> {
341        match (self.start, self.end) {
342            (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
343            _ => None,
344        }
345    }
346
347    /// Whether the on-disk block is already byte-identical (ignoring surrounding
348    /// whitespace) to a fresh [`render`] for these parameters.
349    ///
350    /// [`is_current`](Self::is_current) only compares the embedded
351    /// `<!-- version: N -->` against [`RULES_VERSION`], so a change that keeps
352    /// the version but alters the rendered body — toggling `shadow_mode`,
353    /// switching `compression_level`, or editing a canonical section without a
354    /// version bump — would otherwise be skipped by the injector. Callers pair
355    /// this with `is_current()` to detect that content/compression drift (#548).
356    pub fn block_matches_render(
357        &self,
358        shadow: bool,
359        wrapper: Wrapper,
360        level: CompressionLevel,
361    ) -> bool {
362        match self.block() {
363            Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
364            None => false,
365        }
366    }
367
368    /// Merge freshly-rendered rules into this file.
369    ///
370    /// * If a lean-ctx section exists → replaces content between `START_MARK`
371    ///   and `END_MARK`, preserving user content before/after.
372    /// * If no section exists → appends fresh content at the end.
373    pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
374        let fresh = render(shadow, wrapper, level);
375        if self.start.is_some() {
376            let before = self.prefix();
377            let after = self.suffix();
378            let mut out = String::new();
379            if !before.is_empty() {
380                out.push_str(before);
381                out.push('\n');
382                out.push('\n');
383            }
384            out.push_str(&fresh);
385            if !after.is_empty() {
386                out.push('\n');
387                out.push('\n');
388                out.push_str(after);
389            }
390            if !out.ends_with('\n') {
391                out.push('\n');
392            }
393            out
394        } else {
395            // No existing section — append.
396            let trimmed = self.content.trim_end();
397            let mut out = trimmed.to_string();
398            if !out.is_empty() {
399                out.push('\n');
400                out.push('\n');
401            }
402            out.push_str(&fresh);
403            out
404        }
405    }
406
407    /// Create initial rules content (no existing section to merge with).
408    pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
409        render(shadow, wrapper, level)
410    }
411
412    // ── Delete ─────────────────────────────────────────────────
413
414    /// Strip the lean-ctx section, keeping only user content before/after.
415    pub fn without_section(&self) -> String {
416        if let Some(start_pos) = self.start {
417            let before = self.content[..start_pos].trim();
418            let after = self.suffix();
419            let mut out = String::new();
420            if !before.is_empty() {
421                out.push_str(before);
422                out.push('\n');
423            }
424            if !after.is_empty() {
425                out.push('\n');
426                out.push_str(after);
427            }
428            out
429        } else {
430            self.content.to_string()
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    // --- Constants ---
440
441    #[test]
442    fn bullets_uses_ctx_shell() {
443        assert!(BULLETS.contains("ctx_shell"));
444        assert!(!BULLETS.contains("lean-ctx -c"));
445        assert!(!BULLETS.contains("ctx_edit"));
446    }
447
448    #[test]
449    fn sections_not_empty() {
450        assert!(!BULLETS.is_empty());
451        assert!(!NEVER.is_empty());
452        assert!(!INTENT.is_empty());
453        assert!(!ANTI.is_empty());
454        assert!(!PARALLEL.is_empty());
455        assert!(!AUTO.is_empty());
456        assert!(!CEP.is_empty());
457        assert!(!INTELLIGENCE.is_empty());
458        assert!(!LITM_END.is_empty());
459        assert!(!CRITICAL.is_empty());
460    }
461
462    #[test]
463    fn intent_contains_ctx_compose() {
464        assert!(INTENT.contains("ctx_compose"));
465    }
466
467    #[test]
468    fn anti_contains_do_not() {
469        assert!(ANTI.contains("do NOT"));
470    }
471
472    #[test]
473    fn parallel_contains_parallel() {
474        assert!(PARALLEL.contains("PARALLEL"));
475    }
476
477    // --- render() — Dedicated ---
478
479    #[test]
480    fn dedicated_has_markers_and_version() {
481        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
482        assert!(out.contains(START_MARK));
483        assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
484        assert!(out.contains(END_MARK));
485        assert!(out.contains(BULLETS));
486        assert!(out.contains(NEVER));
487        assert!(out.contains("CRITICAL"));
488    }
489
490    #[test]
491    fn dedicated_shadow_is_minimal() {
492        // #963: shadow drops the whole tool-mapping AND routing playbook —
493        // interception makes them redundant. Only the exclusive-tool advert and
494        // the output style remain.
495        let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
496        assert!(out.contains(START_MARK));
497        assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
498        assert!(!out.contains(NEVER), "no NEVER in shadow");
499        assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
500        assert!(
501            !out.contains("Tool selection by intent"),
502            "routing INTENT block is redundant under interception"
503        );
504        assert!(
505            !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
506            "ANTI/PARALLEL routing guidance is dropped in shadow"
507        );
508        assert!(
509            out.contains("shadow mode") && out.contains("ctx_compose"),
510            "shadow keeps the exclusive-tool advert"
511        );
512        assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
513    }
514
515    #[test]
516    fn shadow_is_smaller_than_non_shadow() {
517        // The whole point of #963: the shadow body must be a strict reduction.
518        let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
519        let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
520        assert!(
521            shadow.len() < full.len(),
522            "shadow ({}) must be smaller than non-shadow ({})",
523            shadow.len(),
524            full.len()
525        );
526    }
527
528    #[test]
529    fn dedicated_litm_structure() {
530        let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
531        let lines: Vec<&str> = out.lines().collect();
532        let first_5 = lines[..5.min(lines.len())].join("\n");
533        assert!(
534            first_5.contains("CRITICAL") || first_5.contains("MUST"),
535            "LITM: MUST/CRITICAL instruction near start"
536        );
537        // LITM_END or NEVER should appear in the final content lines (before END_MARK).
538        let tail = lines[lines.len().saturating_sub(8)..].join("\n");
539        assert!(
540            tail.contains("PREFERENCE") || tail.contains("NEVER"),
541            "LITM: reinforcement near end, tail={tail:?}"
542        );
543    }
544
545    // --- render() — Shared ---
546
547    #[test]
548    fn shared_has_markers_and_header() {
549        let out = render(false, Wrapper::Shared, CompressionLevel::Off);
550        assert!(out.contains(START_MARK));
551        assert!(out.contains(END_MARK));
552        assert!(out.contains("MANDATORY MAPPING"));
553        assert!(out.contains(BULLETS));
554    }
555
556    #[test]
557    fn shared_shadow_omits_mapping() {
558        let out = render(true, Wrapper::Shared, CompressionLevel::Off);
559        assert!(out.contains(START_MARK));
560        assert!(
561            !out.contains("MANDATORY MAPPING"),
562            "shadow must not have header"
563        );
564        assert!(
565            !out.contains("MANDATORY MAPPING"),
566            "shadow must not contain BULLETS"
567        );
568    }
569
570    // --- render() — Bare ---
571
572    #[test]
573    fn bare_has_no_markers() {
574        let out = render(false, Wrapper::Bare, CompressionLevel::Off);
575        assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
576        assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
577        assert!(!out.contains("<!-- version:"), "Bare must not have version");
578        assert!(out.contains(BULLETS));
579        assert!(out.contains(NEVER));
580    }
581
582    #[test]
583    fn bare_shadow_only_read_modes() {
584        let out = render(true, Wrapper::Bare, CompressionLevel::Off);
585        assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
586        assert!(
587            !out.contains("MANDATORY MAPPING"),
588            "shadow Bare must not have BULLETS"
589        );
590    }
591
592    // --- Compression level tests ---
593
594    #[test]
595    fn render_includes_lite_prompt() {
596        let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
597        assert!(out.contains("OUTPUT STYLE: concise"));
598        assert!(out.contains("Bullet points"));
599    }
600
601    #[test]
602    fn render_includes_standard_prompt() {
603        let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
604        assert!(out.contains("OUTPUT STYLE: dense"));
605        assert!(out.contains("atomic fact"));
606    }
607
608    #[test]
609    fn render_includes_max_prompt() {
610        let out = render(false, Wrapper::Bare, CompressionLevel::Max);
611        assert!(out.contains("OUTPUT STYLE: expert-terse"));
612        assert!(out.contains("Telegraph"));
613    }
614
615    #[test]
616    fn render_off_excludes_compression() {
617        let out = render(false, Wrapper::Bare, CompressionLevel::Off);
618        assert!(!out.contains("OUTPUT STYLE:"));
619    }
620
621    #[test]
622    fn compression_text_matches_level() {
623        assert!(compression_text(CompressionLevel::Off).is_empty());
624        assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
625        assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
626        assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
627    }
628
629    // --- Compression marker model (#548 B2) ---
630
631    #[test]
632    fn carrier_wrappers_wrap_compression_in_markers() {
633        // Persistent carriers must delimit the compression payload so coverage
634        // and dedup can detect/thin it (#684/#548).
635        for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
636            let out = render(false, wrapper, CompressionLevel::Standard);
637            assert!(
638                out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
639                "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
640            );
641            // The marked region must actually contain the prompt body.
642            let start = out.find(COMPRESSION_BLOCK_START).unwrap();
643            let end = out.find(COMPRESSION_BLOCK_END).unwrap();
644            assert!(start < end, "{wrapper:?}: start marker precedes end marker");
645            assert!(out[start..end].contains("OUTPUT STYLE: dense"));
646        }
647    }
648
649    #[test]
650    fn bare_wrapper_emits_compression_without_markers() {
651        // The ephemeral MCP channel keeps the payload unmarked — its inclusion is
652        // governed by carrier coverage, so per-session markers would be noise.
653        let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
654        assert!(out.contains("OUTPUT STYLE: dense"));
655        assert!(!out.contains(COMPRESSION_BLOCK_START));
656        assert!(!out.contains(COMPRESSION_BLOCK_END));
657    }
658
659    #[test]
660    fn compression_off_emits_no_markers_in_any_wrapper() {
661        for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
662            let out = render(false, wrapper, CompressionLevel::Off);
663            assert!(
664                !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
665                "{wrapper:?}: Off must emit no compression markers"
666            );
667        }
668    }
669
670    #[test]
671    fn rendered_carrier_block_is_seen_as_carrying_compression() {
672        // The detection helper that coverage/dedup rely on must agree with the
673        // writer's output (the bug this slice fixes: it previously never did).
674        let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
675        assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
676        assert!(dedicated.contains(COMPRESSION_BLOCK_START));
677    }
678
679    // --- Wrapper round-trip ---
680
681    #[test]
682    fn all_wrappers_produce_output() {
683        for shadow in [false, true] {
684            for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
685                let out = render(shadow, wrapper, CompressionLevel::Off);
686                assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
687            }
688        }
689    }
690
691    // --- RulesFile ---
692
693    #[test]
694    fn rules_file_parses_version() {
695        let content = format!(
696            "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
697        );
698        let f = RulesFile::parse(&content);
699        assert!(f.has_content());
700        assert_eq!(f.version(), RULES_VERSION);
701        assert!(f.is_current());
702        assert!(f.prefix().contains("stuff before"));
703        assert!(f.suffix().contains("stuff after"));
704    }
705
706    #[test]
707    fn rules_file_no_version_defaults_to_zero() {
708        let content = format!("{START_MARK}\nbody\n{END_MARK}");
709        let f = RulesFile::parse(&content);
710        assert!(f.has_content());
711        assert_eq!(f.version(), 0);
712        assert!(!f.is_current());
713    }
714
715    #[test]
716    fn rules_file_no_start_marker_no_content() {
717        let f = RulesFile::parse("just user stuff");
718        assert!(!f.has_content());
719        assert_eq!(f.version(), 0);
720    }
721
722    #[test]
723    fn block_matches_render_true_for_fresh_render() {
724        let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
725        let content = format!("user before\n{fresh}\nuser after");
726        let f = RulesFile::parse(&content);
727        assert!(f.is_current(), "fresh render carries the current version");
728        assert!(
729            f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
730            "an unchanged block must compare equal to a fresh render"
731        );
732    }
733
734    #[test]
735    fn block_matches_render_false_on_compression_change() {
736        // Body rendered at Off, then asked whether it matches a Max render:
737        // the version is identical but the compression payload differs (#548).
738        let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
739        let f = RulesFile::parse(&content);
740        assert!(f.is_current());
741        assert!(
742            !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
743            "a compression-level change must be detected as drift"
744        );
745    }
746
747    #[test]
748    fn block_matches_render_false_on_shadow_change() {
749        let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
750        let f = RulesFile::parse(&content);
751        assert!(
752            !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
753            "a shadow-mode toggle must be detected as drift"
754        );
755    }
756
757    #[test]
758    fn block_matches_render_false_without_block() {
759        let f = RulesFile::parse("plain user content, no markers");
760        assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
761    }
762
763    #[test]
764    fn rules_file_merged_replaces_section() {
765        let content =
766            format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
767        let f = RulesFile::parse(&content);
768        let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
769        assert!(merged.contains("before"), "prefix preserved");
770        assert!(merged.contains("after"), "suffix preserved");
771        assert!(!merged.contains("old"), "old content replaced");
772        assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
773    }
774
775    #[test]
776    fn rules_file_merged_appends_when_no_section() {
777        let content = "user content";
778        let f = RulesFile::parse(content);
779        assert!(!f.has_content());
780        let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
781        assert!(merged.contains("user content"));
782        assert!(merged.contains(BULLETS));
783    }
784
785    #[test]
786    fn rules_file_without_section_strips_content() {
787        let content =
788            format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
789        let f = RulesFile::parse(&content);
790        let stripped = f.without_section();
791        assert!(stripped.contains("header"));
792        assert!(stripped.contains("footer"));
793        assert!(!stripped.contains("body"));
794        assert!(!stripped.contains(START_MARK));
795    }
796
797    #[test]
798    fn rules_file_without_section_noop_when_no_content() {
799        let content = "just user text";
800        let f = RulesFile::parse(content);
801        assert_eq!(f.without_section(), content);
802    }
803}