1use crate::core::config::CompressionLevel;
19
20pub const START_MARK: &str = "<!-- lean-ctx-rules -->";
25
26pub const RULES_MARKER_PREFIX: &str = "<!-- lean-ctx-rules";
30
31pub const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
37
38pub const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
40
41pub const PROJECT_LEAN_CTX_OWNED_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
47
48pub const END_MARK: &str = "<!-- /lean-ctx-rules -->";
50
51pub const COMPRESSION_BLOCK_START: &str = "<!-- lean-ctx-compression -->";
63
64pub const COMPRESSION_BLOCK_END: &str = "<!-- /lean-ctx-compression -->";
67
68pub const RULES_VERSION: usize = 8;
87
88pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx ctx_* tools instead of native equivalents. \
90 This is NOT optional.";
91
92pub const MUST_INVOKE: &str = "ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — \
103 describing a tool is not calling it.";
104
105pub const BULLETS: &str = "\
107MANDATORY MAPPING:\n\
108• Read/cat -> ctx_read(path, mode)\n\
109• Grep -> ctx_search(pattern, path)\n\
110• Shell/bash -> ctx_shell(command)\n\
111• Glob/find -> ctx_glob(pattern)\n\
112• ls/find -> ctx_tree(path, depth)";
113
114pub const NEVER: &str = "NEVER use native Read/Grep/Shell/Glob when a ctx_* equivalent \
117 exists. SELF-CORRECT: the moment you reach for one, stop and call the ctx_* tool instead.";
118
119pub const INTENT: &str = "\
124Tool selection by intent:\n\
125• Orient / understand code (call FIRST) -> ctx_compose\n\
126• Read a file -> ctx_read(path, mode=signatures|map|full); edit after reading -> ctx_patch\n\
127• Exact symbol -> ctx_search(action=symbol); pattern -> ctx_search; by meaning -> ctx_search(action=semantic)\n\
128• Files by glob -> ctx_glob; structure -> ctx_tree; callers/impact -> ctx_callgraph\n\
129• Verify after edits -> ctx_shell(test/build); memory -> ctx_session / ctx_knowledge\n\
130Semantic questions -> search tools, not whole-file reads: reading more ≠ understanding more.";
131
132pub const ANTI: &str = "\
134Anti-patterns — do NOT:\n\
135• Chain ctx_search -> ctx_read -> ctx_search(action=symbol) — one ctx_compose replaces all three\n\
136• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
137• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track call\n\
138 edges and file deps only; use ctx_search instead";
139
140pub const PARALLEL: &str = "\
142PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles \
143multiple lookups into one call.";
144
145pub const AGENT_LOOP: &str = "\
150AGENT LOOP (phase -> tool):\n\
151• Orient — understand before acting -> ctx_compose\n\
152• Find — exact symbol by name -> ctx_search(action=symbol)\n\
153• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
154• Locate — a pattern across files -> ctx_search\n\
155• Trace — callers / callees / blast radius -> ctx_callgraph\n\
156• Verify — after an edit -> ctx_shell(test/build) + native lints";
157
158pub const NAV_PARADOX: &str = "\
163NAVIGATION PARADOX: reading more ≠ understanding more.\n\
164• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_search(action=semantic) (meaning), not whole-file reads\n\
165• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
166• Navigate structure (signatures, symbols) before reading entire files";
167
168pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
170 ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. \
171 Full guide: LEAN-CTX.md";
172
173pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
182 Need full/exact? Read the shown file path with any tool (no MCP), or \
183 ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
184
185pub 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
194pub 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
198pub const INTELLIGENCE: &str =
200 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
201
202pub 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
206pub 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_search(action=symbol) (exact symbol), ctx_callgraph (callers), ctx_search(action=semantic) (by meaning), ctx_knowledge / ctx_session (memory).";
214
215pub const HOOK_COVERED_HEADER: &str = "\
223CRITICAL: ALWAYS prefer lean-ctx ctx_* tools over native equivalents — ctx_* tools \
224provide superior caching, compression, and session memory. Hooks compress native \
225Shell/Read/Grep as fallback, but direct ctx_* calls are the primary path.\n\
226ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — describing a tool is not calling it.\n\
227WHY ctx_read > native Read: ctx_read picks the optimal mode (map/signatures/terse) \
228per file, caches for instant re-reads (~13 tokens), and compresses 26-92%. Native \
229Read through hook redirect is limited to verbatim pass-through (~5% savings).";
230
231pub const HOOK_COVERED_TOOLS: &str = "\
235MANDATORY MAPPING (always use ctx_* instead of native):\n\
236• Read/cat -> ctx_read(path, mode) — cached, 10 modes, re-reads ~13 tokens\n\
237• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic for definitions/meaning\n\
238• Shell/bash -> ctx_shell(command) — 95+ compression patterns\n\
239• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
240• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
241• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
242• ctx_expand — recover full text from [Archived]/compressed output";
243
244pub const LITE_PROMPT: &str = "\
248OUTPUT STYLE: concise
249- Bullet points over paragraphs
250- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
251- 1-sentence explanations max, then code/action
252- No repeating what the user said";
253
254pub const STANDARD_PROMPT: &str = "\
256OUTPUT STYLE: dense
257- Each statement = one atomic fact line
258- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
259- Diff lines only (+/-/~), never repeat unchanged code
260- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
261- No narration, no filler, no hedging
262- BUDGET: ≤200 tokens per response unless code block required";
263
264pub const MAX_PROMPT: &str = "\
266OUTPUT STYLE: expert-terse
267- Telegraph format: subject-verb-object, drop articles/prepositions
268- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
269- Code blocks: untouched (never compress code syntax)
270- Each line: max 80 chars
271- Zero narration, zero filler
272- BUDGET: ≤100 tokens per non-code response";
273
274pub const RAW_PROMPT: &str = "\
277OUTPUT STYLE: raw-dense
278- Bullet points ONLY, zero prose, no intro, no outro, no greetings
279- Diff-style facts: +added, -removed, ~changed, !breaking
280- One fact per line, max 60 chars
281- Symbolic: → ∵ ∴ ⊕ ⊖ Δ ≈ ≠ ∈ ∅ ✓ ✗ (same as expert-terse)
282- Code blocks: untouched
283- BUDGET: ≤50 tokens per non-code response
284- NEVER explain what you did — show only the result";
285
286pub fn compression_text(level: CompressionLevel) -> &'static str {
288 match level {
289 CompressionLevel::Off => "",
290 CompressionLevel::Lite => LITE_PROMPT,
291 CompressionLevel::Standard => STANDARD_PROMPT,
292 CompressionLevel::Max => MAX_PROMPT,
293 CompressionLevel::Raw => RAW_PROMPT,
294 }
295}
296
297fn s(c: &str) -> String {
308 c.to_string()
309}
310
311fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
312 use super::rules_sections as rs;
313 let mut v = vec![
314 s(CRITICAL),
315 s(MUST_INVOKE),
316 s(BULLETS),
317 s(NEVER),
318 rs::intent_section(p),
319 s(AGENT_LOOP), rs::anti_section(p),
321 s(NAV_PARADOX), s(PARALLEL),
323 s(AUTO),
324 s(RECOVER),
325 s(CEP),
326 s(INTELLIGENCE),
327 rs::litm_end_section(p),
328 ];
329 if let Some(fb) = rs::ctx_call_fallback(p) {
330 v.push(fb);
331 }
332 v
333}
334
335fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
336 use super::rules_sections as rs;
337 let mut v = vec![
338 s(CRITICAL),
339 s(MUST_INVOKE),
340 s(BULLETS),
341 s(NEVER),
342 rs::intent_section(p),
343 rs::anti_section(p),
344 s(PARALLEL),
345 s(AUTO),
346 s(RECOVER_COMPACT),
347 s(INTELLIGENCE),
348 rs::litm_end_section(p),
349 ];
350 if let Some(fb) = rs::ctx_call_fallback(p) {
351 v.push(fb);
352 }
353 v
354}
355
356fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
357 use super::rules_sections as rs;
358 vec![rs::shadow_minimal_section(p), s(INTELLIGENCE)]
359}
360
361fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
362 use super::rules_sections as rs;
363 let mut v = vec![
364 s(HOOK_COVERED_HEADER),
365 rs::hook_covered_tools_section(p),
366 s(PARALLEL),
367 s(RECOVER_COMPACT),
368 s(INTELLIGENCE),
369 ];
370 if let Some(fb) = rs::ctx_call_fallback(p) {
371 v.push(fb);
372 }
373 v
374}
375
376fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
377 use super::rules_sections as rs;
378 let mut v = vec![
379 s(CRITICAL),
380 s(BULLETS),
381 s(NEVER),
382 rs::intent_section(p),
383 rs::anti_section(p),
384 s(PARALLEL),
385 s(RECOVER_COMPACT),
386 ];
387 if let Some(fb) = rs::ctx_call_fallback(p) {
388 v.push(fb);
389 }
390 v
391}
392
393fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
394 use super::rules_sections as rs;
395 vec![rs::shadow_minimal_section(p)]
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum Wrapper {
402 Longform,
407
408 Dedicated,
413
414 Shared,
418
419 Bare,
422
423 HookCovered,
430}
431
432pub fn render(
443 shadow: bool,
444 wrapper: Wrapper,
445 level: CompressionLevel,
446 tool_profile: &super::tool_profiles::ToolProfile,
447) -> String {
448 use super::rules_sections as rs;
449
450 let sections: Vec<String> = match (wrapper, shadow) {
451 (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
452 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
453 full_shadow_sections(tool_profile)
454 }
455 (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
456 (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
457 (_, false) => compact_non_shadow_sections(tool_profile),
458 (_, true) => compact_shadow_sections(tool_profile),
459 };
460
461 let mut body: String = sections
464 .into_iter()
465 .filter(|s| !s.is_empty())
466 .collect::<Vec<_>>()
467 .join("\n\n");
468 let _ = rs::intent_section; let compression = compression_text(level);
475 if !compression.is_empty() {
476 body.push('\n');
477 if matches!(wrapper, Wrapper::Bare) {
478 body.push_str(compression);
479 } else {
480 body.push_str(COMPRESSION_BLOCK_START);
481 body.push('\n');
482 body.push_str(compression);
483 body.push('\n');
484 body.push_str(COMPRESSION_BLOCK_END);
485 }
486 }
487
488 if matches!(wrapper, Wrapper::Bare) {
489 return body;
490 }
491
492 let version_line = format!("<!-- version: {RULES_VERSION} -->");
493
494 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
495}
496
497pub fn render_hook_covered_bare(
504 shadow: bool,
505 level: CompressionLevel,
506 tool_profile: &super::tool_profiles::ToolProfile,
507) -> String {
508 if shadow {
509 return render(true, Wrapper::Bare, level, tool_profile);
510 }
511 let sections = hook_covered_non_shadow_sections(tool_profile);
512 let mut body: String = sections
513 .into_iter()
514 .filter(|s| !s.is_empty())
515 .collect::<Vec<_>>()
516 .join("\n\n");
517 let compression = compression_text(level);
518 if !compression.is_empty() {
519 body.push('\n');
520 body.push_str(compression);
521 }
522 body
523}
524#[derive(Debug)]
535pub struct RulesFile<'a> {
536 content: &'a str,
537 start: Option<usize>,
539 end: Option<usize>,
541 version: usize,
543}
544
545fn parse_version_number(s: &str) -> Option<usize> {
548 let prefix = "<!-- version: ";
549 let vs = s.find(prefix)?;
550 let num_start = vs + prefix.len();
551 let end = s[num_start..].find(" -->")?;
552 s[num_start..num_start + end].parse().ok()
553}
554
555impl<'a> RulesFile<'a> {
556 pub fn parse(content: &'a str) -> Self {
562 let start = content.find(START_MARK);
563 let version = start
564 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
565 .unwrap_or(0);
566 let end = content.find(END_MARK);
567 RulesFile {
568 content,
569 start,
570 end,
571 version,
572 }
573 }
574
575 pub fn has_content(&self) -> bool {
577 self.start.is_some()
578 }
579
580 pub fn version(&self) -> usize {
583 self.version
584 }
585
586 pub fn is_current(&self) -> bool {
588 self.version >= RULES_VERSION
589 }
590
591 pub fn prefix(&self) -> &'a str {
594 self.start.map_or("", |s| self.content[..s].trim())
595 }
596
597 pub fn suffix(&self) -> &'a str {
600 self.end
601 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
602 }
603
604 fn block(&self) -> Option<&'a str> {
607 match (self.start, self.end) {
608 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
609 _ => None,
610 }
611 }
612
613 pub fn block_matches_render(
623 &self,
624 shadow: bool,
625 wrapper: Wrapper,
626 level: CompressionLevel,
627 tool_profile: &super::tool_profiles::ToolProfile,
628 ) -> bool {
629 match self.block() {
630 Some(block) => block.trim() == render(shadow, wrapper, level, tool_profile).trim(),
631 None => false,
632 }
633 }
634
635 pub fn merged(
641 &self,
642 shadow: bool,
643 wrapper: Wrapper,
644 level: CompressionLevel,
645 tool_profile: &super::tool_profiles::ToolProfile,
646 ) -> String {
647 let fresh = render(shadow, wrapper, level, tool_profile);
648 if self.start.is_some() {
649 let before = self.prefix();
650 let after = self.suffix();
651 let mut out = String::new();
652 if !before.is_empty() {
653 out.push_str(before);
654 out.push('\n');
655 out.push('\n');
656 }
657 out.push_str(&fresh);
658 if !after.is_empty() {
659 out.push('\n');
660 out.push('\n');
661 out.push_str(after);
662 }
663 if !out.ends_with('\n') {
664 out.push('\n');
665 }
666 out
667 } else {
668 let trimmed = self.content.trim_end();
670 let mut out = trimmed.to_string();
671 if !out.is_empty() {
672 out.push('\n');
673 out.push('\n');
674 }
675 out.push_str(&fresh);
676 out
677 }
678 }
679
680 pub fn initial(
682 shadow: bool,
683 wrapper: Wrapper,
684 level: CompressionLevel,
685 tool_profile: &super::tool_profiles::ToolProfile,
686 ) -> String {
687 render(shadow, wrapper, level, tool_profile)
688 }
689
690 pub fn without_section(&self) -> String {
694 if let Some(start_pos) = self.start {
695 let before = self.content[..start_pos].trim();
696 let after = self.suffix();
697 let mut out = String::new();
698 if !before.is_empty() {
699 out.push_str(before);
700 out.push('\n');
701 }
702 if !after.is_empty() {
703 out.push('\n');
704 out.push_str(after);
705 }
706 out
707 } else {
708 self.content.to_string()
709 }
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 fn tp() -> super::super::tool_profiles::ToolProfile {
718 super::super::tool_profiles::ToolProfile::Power
719 }
720
721 #[test]
724 fn bullets_uses_ctx_shell() {
725 assert!(BULLETS.contains("ctx_shell"));
726 assert!(!BULLETS.contains("lean-ctx -c"));
727 assert!(!BULLETS.contains("ctx_edit"));
728 }
729
730 #[test]
731 fn sections_not_empty() {
732 assert!(!BULLETS.is_empty());
733 assert!(!NEVER.is_empty());
734 assert!(!INTENT.is_empty());
735 assert!(!ANTI.is_empty());
736 assert!(!PARALLEL.is_empty());
737 assert!(!AUTO.is_empty());
738 assert!(!CEP.is_empty());
739 assert!(!INTELLIGENCE.is_empty());
740 assert!(!LITM_END.is_empty());
741 assert!(!CRITICAL.is_empty());
742 }
743
744 #[test]
745 fn intent_contains_ctx_compose() {
746 assert!(INTENT.contains("ctx_compose"));
747 }
748
749 #[test]
750 fn anti_contains_do_not() {
751 assert!(ANTI.contains("do NOT"));
752 }
753
754 #[test]
755 fn parallel_contains_parallel() {
756 assert!(PARALLEL.contains("PARALLEL"));
757 }
758
759 #[test]
762 fn agent_loop_names_every_phase() {
763 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
764 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
765 }
766 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
767 }
768
769 #[test]
770 fn nav_paradox_steers_semantic_vs_graph() {
771 assert!(
772 NAV_PARADOX.contains("ctx_search(action=semantic)"),
773 "semantic route must use folded action (#509)"
774 );
775 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
776 assert!(
777 NAV_PARADOX.contains("≠"),
778 "must carry the reading≠understanding thesis"
779 );
780 }
781
782 #[test]
783 fn longform_carries_loop_and_paradox_injected_full_does_not() {
784 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
788 assert!(
789 long.contains("AGENT LOOP"),
790 "LONGFORM must carry AGENT_LOOP"
791 );
792 assert!(
793 long.contains("NAVIGATION PARADOX"),
794 "LONGFORM must carry NAV_PARADOX"
795 );
796 assert!(long.contains(CEP), "LONGFORM must carry CEP");
797
798 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
799 assert!(
800 !full.contains("AGENT LOOP (phase -> tool):"),
801 "injected FULL must not inline the multi-line AGENT_LOOP block"
802 );
803 assert!(
804 !full.contains("NAVIGATION PARADOX: reading"),
805 "injected FULL must not inline the multi-line NAV_PARADOX block"
806 );
807 assert!(
808 full.contains('≠'),
809 "INTENT must keep the reading≠understanding thesis in FULL"
810 );
811 for phase_tool in [
813 "ctx_compose",
814 "ctx_search(action=symbol)",
815 "ctx_search",
816 "ctx_callgraph",
817 ] {
818 assert!(
819 full.contains(phase_tool),
820 "FULL keeps the loop tools via INTENT: {phase_tool}"
821 );
822 }
823 }
824
825 #[test]
826 fn injected_profiles_stay_lean() {
827 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
831 eprintln!(
832 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
833 full.len(),
834 full.len() / 4,
835 render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
836 render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
837 );
838 assert!(
839 full.len() <= 2400,
840 "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
841 full.len(),
842 full.len() / 4
843 );
844 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
845 assert!(
846 long.len() > full.len(),
847 "Longform ({}) must carry more than the injected profile ({})",
848 long.len(),
849 full.len()
850 );
851 let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
852 assert!(
853 compact.len() < full.len(),
854 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
855 compact.len(),
856 full.len()
857 );
858 }
859
860 #[test]
861 fn compact_profile_has_no_multiline_teaching_sections() {
862 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
865 assert!(
866 !out.contains("AGENT LOOP (phase -> tool):"),
867 "COMPACT must not inline the multi-line AGENT_LOOP block"
868 );
869 assert!(
870 !out.contains("NAVIGATION PARADOX: reading"),
871 "COMPACT must not inline the multi-line NAV_PARADOX block"
872 );
873 assert!(
874 out.contains('≠'),
875 "COMPACT keeps the reading≠understanding thesis via INTENT"
876 );
877 }
878
879 #[test]
880 fn shadow_omits_loop_and_paradox() {
881 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
884 let out = render(true, wrapper, CompressionLevel::Off, &tp());
885 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
886 assert!(
887 !out.contains("NAVIGATION PARADOX"),
888 "{wrapper:?} shadow drops paradox"
889 );
890 }
891 }
892
893 #[test]
894 fn recover_reaches_every_non_shadow_carrier() {
895 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
902 assert!(
903 long.contains(RECOVER),
904 "Longform must carry the verbose RECOVER verbatim"
905 );
906 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
907 let out = render(false, wrapper, CompressionLevel::Off, &tp());
908 assert!(
909 out.contains(RECOVER_COMPACT),
910 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
911 );
912 assert!(
913 !out.contains(RECOVER),
914 "{wrapper:?} must not inline the verbose RECOVER block"
915 );
916 }
917 for wrapper in [
918 Wrapper::Longform,
919 Wrapper::Dedicated,
920 Wrapper::Shared,
921 Wrapper::Bare,
922 ] {
923 assert!(
924 render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
925 "{wrapper:?} recovery line must keep the MCP-free path"
926 );
927 }
928 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
929 let out = render(true, wrapper, CompressionLevel::Off, &tp());
930 assert!(
931 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
932 "{wrapper:?} shadow drops all RECOVER guidance"
933 );
934 }
935 }
936
937 #[test]
940 fn dedicated_has_markers_and_version() {
941 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
942 assert!(out.contains(START_MARK));
943 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
944 assert!(out.contains(END_MARK));
945 assert!(out.contains(BULLETS));
946 assert!(out.contains(NEVER));
947 assert!(out.contains("CRITICAL"));
948 }
949
950 #[test]
951 fn dedicated_shadow_is_minimal() {
952 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
956 assert!(out.contains(START_MARK));
957 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
958 assert!(!out.contains(NEVER), "no NEVER in shadow");
959 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
960 assert!(
961 !out.contains("Tool selection by intent"),
962 "routing INTENT block is redundant under interception"
963 );
964 assert!(
965 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
966 "ANTI/PARALLEL routing guidance is dropped in shadow"
967 );
968 assert!(
969 out.contains("shadow mode") && out.contains("ctx_compose"),
970 "shadow keeps the exclusive-tool advert"
971 );
972 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
973 }
974
975 #[test]
976 fn shadow_is_smaller_than_non_shadow() {
977 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
979 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
980 assert!(
981 shadow.len() < full.len(),
982 "shadow ({}) must be smaller than non-shadow ({})",
983 shadow.len(),
984 full.len()
985 );
986 }
987
988 #[test]
989 fn dedicated_litm_structure() {
990 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
991 let lines: Vec<&str> = out.lines().collect();
992 let first_5 = lines[..5.min(lines.len())].join("\n");
993 assert!(
994 first_5.contains("CRITICAL") || first_5.contains("MUST"),
995 "LITM: MUST/CRITICAL instruction near start"
996 );
997 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
999 assert!(
1000 tail.contains("PREFERENCE") || tail.contains("NEVER"),
1001 "LITM: reinforcement near end, tail={tail:?}"
1002 );
1003 }
1004
1005 #[test]
1006 fn dedicated_carries_weak_model_invoke_nudge() {
1007 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1012 assert!(
1013 dedicated.contains(MUST_INVOKE),
1014 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
1015 );
1016 assert!(
1017 !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1018 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
1019 );
1020 assert!(
1021 !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1022 "Bare/instructions channel is capped separately and carries no copy"
1023 );
1024 }
1025
1026 #[test]
1029 fn shared_has_markers_and_header() {
1030 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1031 assert!(out.contains(START_MARK));
1032 assert!(out.contains(END_MARK));
1033 assert!(out.contains("MANDATORY MAPPING"));
1034 assert!(out.contains(BULLETS));
1035 }
1036
1037 #[test]
1038 fn shared_shadow_omits_mapping() {
1039 let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
1040 assert!(out.contains(START_MARK));
1041 assert!(
1042 !out.contains("MANDATORY MAPPING"),
1043 "shadow must not have header"
1044 );
1045 assert!(
1046 !out.contains("MANDATORY MAPPING"),
1047 "shadow must not contain BULLETS"
1048 );
1049 }
1050
1051 #[test]
1054 fn bare_has_no_markers() {
1055 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1056 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
1057 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
1058 assert!(!out.contains("<!-- version:"), "Bare must not have version");
1059 assert!(out.contains(BULLETS));
1060 assert!(out.contains(NEVER));
1061 }
1062
1063 #[test]
1064 fn bare_shadow_only_read_modes() {
1065 let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
1066 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
1067 assert!(
1068 !out.contains("MANDATORY MAPPING"),
1069 "shadow Bare must not have BULLETS"
1070 );
1071 }
1072
1073 #[test]
1076 fn render_includes_lite_prompt() {
1077 let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
1078 assert!(out.contains("OUTPUT STYLE: concise"));
1079 assert!(out.contains("Bullet points"));
1080 }
1081
1082 #[test]
1083 fn render_includes_standard_prompt() {
1084 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1085 assert!(out.contains("OUTPUT STYLE: dense"));
1086 assert!(out.contains("atomic fact"));
1087 }
1088
1089 #[test]
1090 fn render_includes_max_prompt() {
1091 let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
1092 assert!(out.contains("OUTPUT STYLE: expert-terse"));
1093 assert!(out.contains("Telegraph"));
1094 }
1095
1096 #[test]
1097 fn render_off_excludes_compression() {
1098 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1099 assert!(!out.contains("OUTPUT STYLE:"));
1100 }
1101
1102 #[test]
1103 fn compression_text_matches_level() {
1104 assert!(compression_text(CompressionLevel::Off).is_empty());
1105 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1106 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1107 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1108 }
1109
1110 #[test]
1113 fn carrier_wrappers_wrap_compression_in_markers() {
1114 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1117 let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1118 assert!(
1119 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1120 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1121 );
1122 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1124 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1125 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1126 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1127 }
1128 }
1129
1130 #[test]
1131 fn bare_wrapper_emits_compression_without_markers() {
1132 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1135 assert!(out.contains("OUTPUT STYLE: dense"));
1136 assert!(!out.contains(COMPRESSION_BLOCK_START));
1137 assert!(!out.contains(COMPRESSION_BLOCK_END));
1138 }
1139
1140 #[test]
1141 fn compression_off_emits_no_markers_in_any_wrapper() {
1142 for wrapper in [
1143 Wrapper::Longform,
1144 Wrapper::Dedicated,
1145 Wrapper::Shared,
1146 Wrapper::Bare,
1147 ] {
1148 let out = render(false, wrapper, CompressionLevel::Off, &tp());
1149 assert!(
1150 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1151 "{wrapper:?}: Off must emit no compression markers"
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1158 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1161 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1162 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1163 }
1164
1165 #[test]
1168 fn all_wrappers_produce_output() {
1169 for shadow in [false, true] {
1170 for wrapper in [
1171 Wrapper::Longform,
1172 Wrapper::Dedicated,
1173 Wrapper::Shared,
1174 Wrapper::Bare,
1175 ] {
1176 let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1177 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1178 }
1179 }
1180 }
1181
1182 #[test]
1185 fn rules_file_parses_version() {
1186 let content = format!(
1187 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1188 );
1189 let f = RulesFile::parse(&content);
1190 assert!(f.has_content());
1191 assert_eq!(f.version(), RULES_VERSION);
1192 assert!(f.is_current());
1193 assert!(f.prefix().contains("stuff before"));
1194 assert!(f.suffix().contains("stuff after"));
1195 }
1196
1197 #[test]
1198 fn rules_file_no_version_defaults_to_zero() {
1199 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1200 let f = RulesFile::parse(&content);
1201 assert!(f.has_content());
1202 assert_eq!(f.version(), 0);
1203 assert!(!f.is_current());
1204 }
1205
1206 #[test]
1207 fn rules_file_no_start_marker_no_content() {
1208 let f = RulesFile::parse("just user stuff");
1209 assert!(!f.has_content());
1210 assert_eq!(f.version(), 0);
1211 }
1212
1213 #[test]
1214 fn block_matches_render_true_for_fresh_render() {
1215 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1216 let content = format!("user before\n{fresh}\nuser after");
1217 let f = RulesFile::parse(&content);
1218 assert!(f.is_current(), "fresh render carries the current version");
1219 assert!(
1220 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1221 "an unchanged block must compare equal to a fresh render"
1222 );
1223 }
1224
1225 #[test]
1226 fn block_matches_render_false_on_compression_change() {
1227 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1230 let f = RulesFile::parse(&content);
1231 assert!(f.is_current());
1232 assert!(
1233 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1234 "a compression-level change must be detected as drift"
1235 );
1236 }
1237
1238 #[test]
1239 fn block_matches_render_false_on_shadow_change() {
1240 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1241 let f = RulesFile::parse(&content);
1242 assert!(
1243 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1244 "a shadow-mode toggle must be detected as drift"
1245 );
1246 }
1247
1248 #[test]
1249 fn block_matches_render_false_without_block() {
1250 let f = RulesFile::parse("plain user content, no markers");
1251 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1252 }
1253
1254 #[test]
1255 fn rules_file_merged_replaces_section() {
1256 let content =
1257 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1258 let f = RulesFile::parse(&content);
1259 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1260 assert!(merged.contains("before"), "prefix preserved");
1261 assert!(merged.contains("after"), "suffix preserved");
1262 assert!(!merged.contains("old"), "old content replaced");
1263 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1264 }
1265
1266 #[test]
1267 fn rules_file_merged_appends_when_no_section() {
1268 let content = "user content";
1269 let f = RulesFile::parse(content);
1270 assert!(!f.has_content());
1271 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1272 assert!(merged.contains("user content"));
1273 assert!(merged.contains(BULLETS));
1274 }
1275
1276 #[test]
1277 fn rules_file_without_section_strips_content() {
1278 let content =
1279 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1280 let f = RulesFile::parse(&content);
1281 let stripped = f.without_section();
1282 assert!(stripped.contains("header"));
1283 assert!(stripped.contains("footer"));
1284 assert!(!stripped.contains("body"));
1285 assert!(!stripped.contains(START_MARK));
1286 }
1287
1288 #[test]
1289 fn rules_file_without_section_noop_when_no_content() {
1290 let content = "just user text";
1291 let f = RulesFile::parse(content);
1292 assert_eq!(f.without_section(), content);
1293 }
1294
1295 #[test]
1296 fn bullets_lead_with_four_core_redirects() {
1297 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1299 let search = BULLETS
1300 .find("ctx_search")
1301 .expect("ctx_search mapping present");
1302 let shell = BULLETS
1303 .find("ctx_shell")
1304 .expect("ctx_shell mapping present");
1305 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1306 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1307 assert!(
1308 read < search && search < shell && shell < glob && glob < tree,
1309 "core redirects (read<search<shell<glob) must precede ctx_tree"
1310 );
1311 }
1312
1313 #[test]
1314 fn never_carries_self_correction() {
1315 assert!(
1317 NEVER.contains("SELF-CORRECT"),
1318 "NEVER must teach self-correction"
1319 );
1320 assert!(
1321 NEVER.contains("call"),
1322 "NEVER must spell out the corrective action"
1323 );
1324 }
1325
1326 #[test]
1327 fn critical_names_ctx_family() {
1328 assert!(
1329 CRITICAL.contains("ctx_*"),
1330 "CRITICAL must name the ctx_* family"
1331 );
1332 }
1333
1334 #[test]
1337 fn hook_covered_carries_strict_mapping() {
1338 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1341 assert!(
1342 !out.contains(NEVER),
1343 "HookCovered uses its own strict header, not the NEVER constant"
1344 );
1345 assert!(
1346 out.contains(HOOK_COVERED_HEADER),
1347 "must carry the strict preference header"
1348 );
1349 assert!(
1350 out.contains("MANDATORY MAPPING")
1351 && out.contains("ctx_read")
1352 && out.contains("ctx_search"),
1353 "must carry the full mandatory mapping"
1354 );
1355 assert!(
1356 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1357 "must advertise the search capabilities"
1358 );
1359 }
1360
1361 #[test]
1362 fn hook_covered_keeps_markers_version_and_recovery() {
1363 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1367 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1368 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1369 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1370 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1371 }
1372
1373 #[test]
1374 fn hook_covered_is_leaner_than_full() {
1375 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1376 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1377 assert!(
1378 covered.len() < full.len(),
1379 "HookCovered ({}) must be a strict reduction of FULL ({})",
1380 covered.len(),
1381 full.len()
1382 );
1383 }
1384
1385 #[test]
1386 fn hook_covered_shadow_collapses_to_minimal() {
1387 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1390 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1391 assert_eq!(covered_shadow, dedicated_shadow);
1392 }
1393
1394 #[test]
1395 fn hook_covered_wraps_compression_in_markers() {
1396 let out = render(
1397 false,
1398 Wrapper::HookCovered,
1399 CompressionLevel::Standard,
1400 &tp(),
1401 );
1402 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1403 assert!(out.contains("OUTPUT STYLE: dense"));
1404 }
1405
1406 #[test]
1409 fn rules_only_mention_enabled_tools() {
1410 use super::super::rules_sections;
1411 use super::super::tool_profiles::ToolProfile;
1412 let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1413 for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1414 let mut sections = vec![
1415 rules_sections::intent_section(&profile),
1416 rules_sections::hook_covered_tools_section(&profile),
1417 rules_sections::shadow_minimal_section(&profile),
1418 rules_sections::anti_section(&profile),
1419 rules_sections::litm_end_section(&profile),
1420 ];
1421 if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1422 sections.push(fb);
1423 }
1424 for tool in &exclusive_tools {
1425 if !profile.is_tool_enabled(tool) {
1426 for section in §ions {
1427 assert!(
1428 !section.contains(tool),
1429 "profile {:?} dynamic section must not mention disabled tool {tool}",
1430 profile.as_str()
1431 );
1432 }
1433 }
1434 }
1435 }
1436 }
1437
1438 #[test]
1439 fn minimal_rules_shorter_than_standard() {
1440 use super::super::tool_profiles::ToolProfile;
1441 let min = render(
1442 false,
1443 Wrapper::Dedicated,
1444 CompressionLevel::Off,
1445 &ToolProfile::Minimal,
1446 );
1447 let std = render(
1448 false,
1449 Wrapper::Dedicated,
1450 CompressionLevel::Off,
1451 &ToolProfile::Standard,
1452 );
1453 assert!(
1454 min.len() < std.len(),
1455 "minimal ({}) must be shorter than standard ({})",
1456 min.len(),
1457 std.len()
1458 );
1459 }
1460
1461 #[test]
1462 fn power_profile_output_identical_to_default() {
1463 use super::super::tool_profiles::ToolProfile;
1464 let power = render(
1465 false,
1466 Wrapper::Dedicated,
1467 CompressionLevel::Off,
1468 &ToolProfile::Power,
1469 );
1470 assert!(
1471 power.contains("ctx_compose"),
1472 "Power must include all tools"
1473 );
1474 assert!(
1475 power.contains("ctx_callgraph"),
1476 "Power must include all tools"
1477 );
1478 assert!(
1479 power.contains("ctx_session"),
1480 "Power must include all tools"
1481 );
1482 }
1483
1484 #[test]
1485 fn render_is_deterministic_across_profiles() {
1486 use super::super::tool_profiles::ToolProfile;
1487 for profile in [
1488 ToolProfile::Minimal,
1489 ToolProfile::Standard,
1490 ToolProfile::Power,
1491 ] {
1492 let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1493 let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1494 assert_eq!(
1495 a,
1496 b,
1497 "render must be deterministic for {:?}",
1498 profile.as_str()
1499 );
1500 }
1501 }
1502
1503 #[test]
1504 fn minimal_has_ctx_call_fallback() {
1505 use super::super::tool_profiles::ToolProfile;
1506 let rules = render(
1507 false,
1508 Wrapper::Dedicated,
1509 CompressionLevel::Off,
1510 &ToolProfile::Minimal,
1511 );
1512 assert!(
1513 rules.contains("ctx_call"),
1514 "minimal profile must include ctx_call gateway hint"
1515 );
1516 }
1517
1518 #[test]
1519 fn power_has_no_ctx_call_fallback() {
1520 use super::super::tool_profiles::ToolProfile;
1521 let rules = render(
1522 false,
1523 Wrapper::Dedicated,
1524 CompressionLevel::Off,
1525 &ToolProfile::Power,
1526 );
1527 assert!(
1528 !rules.contains("ctx_call(tool="),
1529 "power profile must not include ctx_call gateway hint"
1530 );
1531 }
1532}