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.";
227
228pub const HOOK_COVERED_TOOLS: &str = "\
232MANDATORY MAPPING (always use ctx_* instead of native):\n\
233• Read/cat -> ctx_read(path, mode) — cached, 10 modes, re-reads ~13 tokens\n\
234• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic for definitions/meaning\n\
235• Shell/bash -> ctx_shell(command) — 95+ compression patterns\n\
236• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
237• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
238• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
239• ctx_expand — recover full text from [Archived]/compressed output";
240
241pub const LITE_PROMPT: &str = "\
245OUTPUT STYLE: concise
246- Bullet points over paragraphs
247- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
248- 1-sentence explanations max, then code/action
249- No repeating what the user said";
250
251pub const STANDARD_PROMPT: &str = "\
253OUTPUT STYLE: dense
254- Each statement = one atomic fact line
255- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
256- Diff lines only (+/-/~), never repeat unchanged code
257- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
258- No narration, no filler, no hedging
259- BUDGET: ≤200 tokens per response unless code block required";
260
261pub const MAX_PROMPT: &str = "\
263OUTPUT STYLE: expert-terse
264- Telegraph format: subject-verb-object, drop articles/prepositions
265- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
266- Code blocks: untouched (never compress code syntax)
267- Each line: max 80 chars
268- Zero narration, zero filler
269- BUDGET: ≤100 tokens per non-code response";
270
271pub fn compression_text(level: CompressionLevel) -> &'static str {
273 match level {
274 CompressionLevel::Off => "",
275 CompressionLevel::Lite => LITE_PROMPT,
276 CompressionLevel::Standard => STANDARD_PROMPT,
277 CompressionLevel::Max => MAX_PROMPT,
278 }
279}
280
281fn s(c: &str) -> String {
292 c.to_string()
293}
294
295fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
296 use super::rules_sections as rs;
297 let mut v = vec![
298 s(CRITICAL),
299 s(MUST_INVOKE),
300 s(BULLETS),
301 s(NEVER),
302 rs::intent_section(p),
303 s(AGENT_LOOP), rs::anti_section(p),
305 s(NAV_PARADOX), s(PARALLEL),
307 s(AUTO),
308 s(RECOVER),
309 s(CEP),
310 s(INTELLIGENCE),
311 rs::litm_end_section(p),
312 ];
313 if let Some(fb) = rs::ctx_call_fallback(p) {
314 v.push(fb);
315 }
316 v
317}
318
319fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
320 use super::rules_sections as rs;
321 let mut v = vec![
322 s(CRITICAL),
323 s(MUST_INVOKE),
324 s(BULLETS),
325 s(NEVER),
326 rs::intent_section(p),
327 rs::anti_section(p),
328 s(PARALLEL),
329 s(AUTO),
330 s(RECOVER_COMPACT),
331 s(INTELLIGENCE),
332 rs::litm_end_section(p),
333 ];
334 if let Some(fb) = rs::ctx_call_fallback(p) {
335 v.push(fb);
336 }
337 v
338}
339
340fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
341 use super::rules_sections as rs;
342 vec![rs::shadow_minimal_section(p), s(INTELLIGENCE)]
343}
344
345fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
346 use super::rules_sections as rs;
347 let mut v = vec![
348 s(HOOK_COVERED_HEADER),
349 rs::hook_covered_tools_section(p),
350 s(PARALLEL),
351 s(RECOVER_COMPACT),
352 s(INTELLIGENCE),
353 ];
354 if let Some(fb) = rs::ctx_call_fallback(p) {
355 v.push(fb);
356 }
357 v
358}
359
360fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
361 use super::rules_sections as rs;
362 let mut v = vec![
363 s(CRITICAL),
364 s(BULLETS),
365 s(NEVER),
366 rs::intent_section(p),
367 rs::anti_section(p),
368 s(PARALLEL),
369 s(RECOVER_COMPACT),
370 ];
371 if let Some(fb) = rs::ctx_call_fallback(p) {
372 v.push(fb);
373 }
374 v
375}
376
377fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
378 use super::rules_sections as rs;
379 vec![rs::shadow_minimal_section(p)]
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub enum Wrapper {
386 Longform,
391
392 Dedicated,
397
398 Shared,
402
403 Bare,
406
407 HookCovered,
414}
415
416pub fn render(
427 shadow: bool,
428 wrapper: Wrapper,
429 level: CompressionLevel,
430 tool_profile: &super::tool_profiles::ToolProfile,
431) -> String {
432 use super::rules_sections as rs;
433
434 let sections: Vec<String> = match (wrapper, shadow) {
435 (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
436 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
437 full_shadow_sections(tool_profile)
438 }
439 (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
440 (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
441 (_, false) => compact_non_shadow_sections(tool_profile),
442 (_, true) => compact_shadow_sections(tool_profile),
443 };
444
445 let mut body: String = sections
448 .into_iter()
449 .filter(|s| !s.is_empty())
450 .collect::<Vec<_>>()
451 .join("\n\n");
452 let _ = rs::intent_section; let compression = compression_text(level);
459 if !compression.is_empty() {
460 body.push('\n');
461 if matches!(wrapper, Wrapper::Bare) {
462 body.push_str(compression);
463 } else {
464 body.push_str(COMPRESSION_BLOCK_START);
465 body.push('\n');
466 body.push_str(compression);
467 body.push('\n');
468 body.push_str(COMPRESSION_BLOCK_END);
469 }
470 }
471
472 if matches!(wrapper, Wrapper::Bare) {
473 return body;
474 }
475
476 let version_line = format!("<!-- version: {RULES_VERSION} -->");
477
478 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
479}
480
481pub fn render_hook_covered_bare(
488 shadow: bool,
489 level: CompressionLevel,
490 tool_profile: &super::tool_profiles::ToolProfile,
491) -> String {
492 if shadow {
493 return render(true, Wrapper::Bare, level, tool_profile);
494 }
495 let sections = hook_covered_non_shadow_sections(tool_profile);
496 let mut body: String = sections
497 .into_iter()
498 .filter(|s| !s.is_empty())
499 .collect::<Vec<_>>()
500 .join("\n\n");
501 let compression = compression_text(level);
502 if !compression.is_empty() {
503 body.push('\n');
504 body.push_str(compression);
505 }
506 body
507}
508#[derive(Debug)]
519pub struct RulesFile<'a> {
520 content: &'a str,
521 start: Option<usize>,
523 end: Option<usize>,
525 version: usize,
527}
528
529fn parse_version_number(s: &str) -> Option<usize> {
532 let prefix = "<!-- version: ";
533 let vs = s.find(prefix)?;
534 let num_start = vs + prefix.len();
535 let end = s[num_start..].find(" -->")?;
536 s[num_start..num_start + end].parse().ok()
537}
538
539impl<'a> RulesFile<'a> {
540 pub fn parse(content: &'a str) -> Self {
546 let start = content.find(START_MARK);
547 let version = start
548 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
549 .unwrap_or(0);
550 let end = content.find(END_MARK);
551 RulesFile {
552 content,
553 start,
554 end,
555 version,
556 }
557 }
558
559 pub fn has_content(&self) -> bool {
561 self.start.is_some()
562 }
563
564 pub fn version(&self) -> usize {
567 self.version
568 }
569
570 pub fn is_current(&self) -> bool {
572 self.version >= RULES_VERSION
573 }
574
575 pub fn prefix(&self) -> &'a str {
578 self.start.map_or("", |s| self.content[..s].trim())
579 }
580
581 pub fn suffix(&self) -> &'a str {
584 self.end
585 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
586 }
587
588 fn block(&self) -> Option<&'a str> {
591 match (self.start, self.end) {
592 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
593 _ => None,
594 }
595 }
596
597 pub fn block_matches_render(
607 &self,
608 shadow: bool,
609 wrapper: Wrapper,
610 level: CompressionLevel,
611 tool_profile: &super::tool_profiles::ToolProfile,
612 ) -> bool {
613 match self.block() {
614 Some(block) => block.trim() == render(shadow, wrapper, level, tool_profile).trim(),
615 None => false,
616 }
617 }
618
619 pub fn merged(
625 &self,
626 shadow: bool,
627 wrapper: Wrapper,
628 level: CompressionLevel,
629 tool_profile: &super::tool_profiles::ToolProfile,
630 ) -> String {
631 let fresh = render(shadow, wrapper, level, tool_profile);
632 if self.start.is_some() {
633 let before = self.prefix();
634 let after = self.suffix();
635 let mut out = String::new();
636 if !before.is_empty() {
637 out.push_str(before);
638 out.push('\n');
639 out.push('\n');
640 }
641 out.push_str(&fresh);
642 if !after.is_empty() {
643 out.push('\n');
644 out.push('\n');
645 out.push_str(after);
646 }
647 if !out.ends_with('\n') {
648 out.push('\n');
649 }
650 out
651 } else {
652 let trimmed = self.content.trim_end();
654 let mut out = trimmed.to_string();
655 if !out.is_empty() {
656 out.push('\n');
657 out.push('\n');
658 }
659 out.push_str(&fresh);
660 out
661 }
662 }
663
664 pub fn initial(
666 shadow: bool,
667 wrapper: Wrapper,
668 level: CompressionLevel,
669 tool_profile: &super::tool_profiles::ToolProfile,
670 ) -> String {
671 render(shadow, wrapper, level, tool_profile)
672 }
673
674 pub fn without_section(&self) -> String {
678 if let Some(start_pos) = self.start {
679 let before = self.content[..start_pos].trim();
680 let after = self.suffix();
681 let mut out = String::new();
682 if !before.is_empty() {
683 out.push_str(before);
684 out.push('\n');
685 }
686 if !after.is_empty() {
687 out.push('\n');
688 out.push_str(after);
689 }
690 out
691 } else {
692 self.content.to_string()
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 fn tp() -> super::super::tool_profiles::ToolProfile {
702 super::super::tool_profiles::ToolProfile::Power
703 }
704
705 #[test]
708 fn bullets_uses_ctx_shell() {
709 assert!(BULLETS.contains("ctx_shell"));
710 assert!(!BULLETS.contains("lean-ctx -c"));
711 assert!(!BULLETS.contains("ctx_edit"));
712 }
713
714 #[test]
715 fn sections_not_empty() {
716 assert!(!BULLETS.is_empty());
717 assert!(!NEVER.is_empty());
718 assert!(!INTENT.is_empty());
719 assert!(!ANTI.is_empty());
720 assert!(!PARALLEL.is_empty());
721 assert!(!AUTO.is_empty());
722 assert!(!CEP.is_empty());
723 assert!(!INTELLIGENCE.is_empty());
724 assert!(!LITM_END.is_empty());
725 assert!(!CRITICAL.is_empty());
726 }
727
728 #[test]
729 fn intent_contains_ctx_compose() {
730 assert!(INTENT.contains("ctx_compose"));
731 }
732
733 #[test]
734 fn anti_contains_do_not() {
735 assert!(ANTI.contains("do NOT"));
736 }
737
738 #[test]
739 fn parallel_contains_parallel() {
740 assert!(PARALLEL.contains("PARALLEL"));
741 }
742
743 #[test]
746 fn agent_loop_names_every_phase() {
747 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
748 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
749 }
750 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
751 }
752
753 #[test]
754 fn nav_paradox_steers_semantic_vs_graph() {
755 assert!(
756 NAV_PARADOX.contains("ctx_search(action=semantic)"),
757 "semantic route must use folded action (#509)"
758 );
759 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
760 assert!(
761 NAV_PARADOX.contains("≠"),
762 "must carry the reading≠understanding thesis"
763 );
764 }
765
766 #[test]
767 fn longform_carries_loop_and_paradox_injected_full_does_not() {
768 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
772 assert!(
773 long.contains("AGENT LOOP"),
774 "LONGFORM must carry AGENT_LOOP"
775 );
776 assert!(
777 long.contains("NAVIGATION PARADOX"),
778 "LONGFORM must carry NAV_PARADOX"
779 );
780 assert!(long.contains(CEP), "LONGFORM must carry CEP");
781
782 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
783 assert!(
784 !full.contains("AGENT LOOP (phase -> tool):"),
785 "injected FULL must not inline the multi-line AGENT_LOOP block"
786 );
787 assert!(
788 !full.contains("NAVIGATION PARADOX: reading"),
789 "injected FULL must not inline the multi-line NAV_PARADOX block"
790 );
791 assert!(
792 full.contains('≠'),
793 "INTENT must keep the reading≠understanding thesis in FULL"
794 );
795 for phase_tool in [
797 "ctx_compose",
798 "ctx_search(action=symbol)",
799 "ctx_search",
800 "ctx_callgraph",
801 ] {
802 assert!(
803 full.contains(phase_tool),
804 "FULL keeps the loop tools via INTENT: {phase_tool}"
805 );
806 }
807 }
808
809 #[test]
810 fn injected_profiles_stay_lean() {
811 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
815 eprintln!(
816 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
817 full.len(),
818 full.len() / 4,
819 render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
820 render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
821 );
822 assert!(
823 full.len() <= 2400,
824 "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
825 full.len(),
826 full.len() / 4
827 );
828 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
829 assert!(
830 long.len() > full.len(),
831 "Longform ({}) must carry more than the injected profile ({})",
832 long.len(),
833 full.len()
834 );
835 let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
836 assert!(
837 compact.len() < full.len(),
838 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
839 compact.len(),
840 full.len()
841 );
842 }
843
844 #[test]
845 fn compact_profile_has_no_multiline_teaching_sections() {
846 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
849 assert!(
850 !out.contains("AGENT LOOP (phase -> tool):"),
851 "COMPACT must not inline the multi-line AGENT_LOOP block"
852 );
853 assert!(
854 !out.contains("NAVIGATION PARADOX: reading"),
855 "COMPACT must not inline the multi-line NAV_PARADOX block"
856 );
857 assert!(
858 out.contains('≠'),
859 "COMPACT keeps the reading≠understanding thesis via INTENT"
860 );
861 }
862
863 #[test]
864 fn shadow_omits_loop_and_paradox() {
865 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
868 let out = render(true, wrapper, CompressionLevel::Off, &tp());
869 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
870 assert!(
871 !out.contains("NAVIGATION PARADOX"),
872 "{wrapper:?} shadow drops paradox"
873 );
874 }
875 }
876
877 #[test]
878 fn recover_reaches_every_non_shadow_carrier() {
879 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
886 assert!(
887 long.contains(RECOVER),
888 "Longform must carry the verbose RECOVER verbatim"
889 );
890 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
891 let out = render(false, wrapper, CompressionLevel::Off, &tp());
892 assert!(
893 out.contains(RECOVER_COMPACT),
894 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
895 );
896 assert!(
897 !out.contains(RECOVER),
898 "{wrapper:?} must not inline the verbose RECOVER block"
899 );
900 }
901 for wrapper in [
902 Wrapper::Longform,
903 Wrapper::Dedicated,
904 Wrapper::Shared,
905 Wrapper::Bare,
906 ] {
907 assert!(
908 render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
909 "{wrapper:?} recovery line must keep the MCP-free path"
910 );
911 }
912 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
913 let out = render(true, wrapper, CompressionLevel::Off, &tp());
914 assert!(
915 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
916 "{wrapper:?} shadow drops all RECOVER guidance"
917 );
918 }
919 }
920
921 #[test]
924 fn dedicated_has_markers_and_version() {
925 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
926 assert!(out.contains(START_MARK));
927 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
928 assert!(out.contains(END_MARK));
929 assert!(out.contains(BULLETS));
930 assert!(out.contains(NEVER));
931 assert!(out.contains("CRITICAL"));
932 }
933
934 #[test]
935 fn dedicated_shadow_is_minimal() {
936 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
940 assert!(out.contains(START_MARK));
941 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
942 assert!(!out.contains(NEVER), "no NEVER in shadow");
943 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
944 assert!(
945 !out.contains("Tool selection by intent"),
946 "routing INTENT block is redundant under interception"
947 );
948 assert!(
949 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
950 "ANTI/PARALLEL routing guidance is dropped in shadow"
951 );
952 assert!(
953 out.contains("shadow mode") && out.contains("ctx_compose"),
954 "shadow keeps the exclusive-tool advert"
955 );
956 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
957 }
958
959 #[test]
960 fn shadow_is_smaller_than_non_shadow() {
961 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
963 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
964 assert!(
965 shadow.len() < full.len(),
966 "shadow ({}) must be smaller than non-shadow ({})",
967 shadow.len(),
968 full.len()
969 );
970 }
971
972 #[test]
973 fn dedicated_litm_structure() {
974 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
975 let lines: Vec<&str> = out.lines().collect();
976 let first_5 = lines[..5.min(lines.len())].join("\n");
977 assert!(
978 first_5.contains("CRITICAL") || first_5.contains("MUST"),
979 "LITM: MUST/CRITICAL instruction near start"
980 );
981 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
983 assert!(
984 tail.contains("PREFERENCE") || tail.contains("NEVER"),
985 "LITM: reinforcement near end, tail={tail:?}"
986 );
987 }
988
989 #[test]
990 fn dedicated_carries_weak_model_invoke_nudge() {
991 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
996 assert!(
997 dedicated.contains(MUST_INVOKE),
998 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
999 );
1000 assert!(
1001 !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1002 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
1003 );
1004 assert!(
1005 !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1006 "Bare/instructions channel is capped separately and carries no copy"
1007 );
1008 }
1009
1010 #[test]
1013 fn shared_has_markers_and_header() {
1014 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1015 assert!(out.contains(START_MARK));
1016 assert!(out.contains(END_MARK));
1017 assert!(out.contains("MANDATORY MAPPING"));
1018 assert!(out.contains(BULLETS));
1019 }
1020
1021 #[test]
1022 fn shared_shadow_omits_mapping() {
1023 let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
1024 assert!(out.contains(START_MARK));
1025 assert!(
1026 !out.contains("MANDATORY MAPPING"),
1027 "shadow must not have header"
1028 );
1029 assert!(
1030 !out.contains("MANDATORY MAPPING"),
1031 "shadow must not contain BULLETS"
1032 );
1033 }
1034
1035 #[test]
1038 fn bare_has_no_markers() {
1039 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1040 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
1041 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
1042 assert!(!out.contains("<!-- version:"), "Bare must not have version");
1043 assert!(out.contains(BULLETS));
1044 assert!(out.contains(NEVER));
1045 }
1046
1047 #[test]
1048 fn bare_shadow_only_read_modes() {
1049 let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
1050 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
1051 assert!(
1052 !out.contains("MANDATORY MAPPING"),
1053 "shadow Bare must not have BULLETS"
1054 );
1055 }
1056
1057 #[test]
1060 fn render_includes_lite_prompt() {
1061 let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
1062 assert!(out.contains("OUTPUT STYLE: concise"));
1063 assert!(out.contains("Bullet points"));
1064 }
1065
1066 #[test]
1067 fn render_includes_standard_prompt() {
1068 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1069 assert!(out.contains("OUTPUT STYLE: dense"));
1070 assert!(out.contains("atomic fact"));
1071 }
1072
1073 #[test]
1074 fn render_includes_max_prompt() {
1075 let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
1076 assert!(out.contains("OUTPUT STYLE: expert-terse"));
1077 assert!(out.contains("Telegraph"));
1078 }
1079
1080 #[test]
1081 fn render_off_excludes_compression() {
1082 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1083 assert!(!out.contains("OUTPUT STYLE:"));
1084 }
1085
1086 #[test]
1087 fn compression_text_matches_level() {
1088 assert!(compression_text(CompressionLevel::Off).is_empty());
1089 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1090 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1091 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1092 }
1093
1094 #[test]
1097 fn carrier_wrappers_wrap_compression_in_markers() {
1098 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1101 let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1102 assert!(
1103 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1104 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1105 );
1106 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1108 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1109 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1110 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1111 }
1112 }
1113
1114 #[test]
1115 fn bare_wrapper_emits_compression_without_markers() {
1116 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1119 assert!(out.contains("OUTPUT STYLE: dense"));
1120 assert!(!out.contains(COMPRESSION_BLOCK_START));
1121 assert!(!out.contains(COMPRESSION_BLOCK_END));
1122 }
1123
1124 #[test]
1125 fn compression_off_emits_no_markers_in_any_wrapper() {
1126 for wrapper in [
1127 Wrapper::Longform,
1128 Wrapper::Dedicated,
1129 Wrapper::Shared,
1130 Wrapper::Bare,
1131 ] {
1132 let out = render(false, wrapper, CompressionLevel::Off, &tp());
1133 assert!(
1134 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1135 "{wrapper:?}: Off must emit no compression markers"
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1142 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1145 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1146 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1147 }
1148
1149 #[test]
1152 fn all_wrappers_produce_output() {
1153 for shadow in [false, true] {
1154 for wrapper in [
1155 Wrapper::Longform,
1156 Wrapper::Dedicated,
1157 Wrapper::Shared,
1158 Wrapper::Bare,
1159 ] {
1160 let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1161 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1162 }
1163 }
1164 }
1165
1166 #[test]
1169 fn rules_file_parses_version() {
1170 let content = format!(
1171 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1172 );
1173 let f = RulesFile::parse(&content);
1174 assert!(f.has_content());
1175 assert_eq!(f.version(), RULES_VERSION);
1176 assert!(f.is_current());
1177 assert!(f.prefix().contains("stuff before"));
1178 assert!(f.suffix().contains("stuff after"));
1179 }
1180
1181 #[test]
1182 fn rules_file_no_version_defaults_to_zero() {
1183 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1184 let f = RulesFile::parse(&content);
1185 assert!(f.has_content());
1186 assert_eq!(f.version(), 0);
1187 assert!(!f.is_current());
1188 }
1189
1190 #[test]
1191 fn rules_file_no_start_marker_no_content() {
1192 let f = RulesFile::parse("just user stuff");
1193 assert!(!f.has_content());
1194 assert_eq!(f.version(), 0);
1195 }
1196
1197 #[test]
1198 fn block_matches_render_true_for_fresh_render() {
1199 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1200 let content = format!("user before\n{fresh}\nuser after");
1201 let f = RulesFile::parse(&content);
1202 assert!(f.is_current(), "fresh render carries the current version");
1203 assert!(
1204 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1205 "an unchanged block must compare equal to a fresh render"
1206 );
1207 }
1208
1209 #[test]
1210 fn block_matches_render_false_on_compression_change() {
1211 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1214 let f = RulesFile::parse(&content);
1215 assert!(f.is_current());
1216 assert!(
1217 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1218 "a compression-level change must be detected as drift"
1219 );
1220 }
1221
1222 #[test]
1223 fn block_matches_render_false_on_shadow_change() {
1224 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1225 let f = RulesFile::parse(&content);
1226 assert!(
1227 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1228 "a shadow-mode toggle must be detected as drift"
1229 );
1230 }
1231
1232 #[test]
1233 fn block_matches_render_false_without_block() {
1234 let f = RulesFile::parse("plain user content, no markers");
1235 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1236 }
1237
1238 #[test]
1239 fn rules_file_merged_replaces_section() {
1240 let content =
1241 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1242 let f = RulesFile::parse(&content);
1243 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1244 assert!(merged.contains("before"), "prefix preserved");
1245 assert!(merged.contains("after"), "suffix preserved");
1246 assert!(!merged.contains("old"), "old content replaced");
1247 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1248 }
1249
1250 #[test]
1251 fn rules_file_merged_appends_when_no_section() {
1252 let content = "user content";
1253 let f = RulesFile::parse(content);
1254 assert!(!f.has_content());
1255 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1256 assert!(merged.contains("user content"));
1257 assert!(merged.contains(BULLETS));
1258 }
1259
1260 #[test]
1261 fn rules_file_without_section_strips_content() {
1262 let content =
1263 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1264 let f = RulesFile::parse(&content);
1265 let stripped = f.without_section();
1266 assert!(stripped.contains("header"));
1267 assert!(stripped.contains("footer"));
1268 assert!(!stripped.contains("body"));
1269 assert!(!stripped.contains(START_MARK));
1270 }
1271
1272 #[test]
1273 fn rules_file_without_section_noop_when_no_content() {
1274 let content = "just user text";
1275 let f = RulesFile::parse(content);
1276 assert_eq!(f.without_section(), content);
1277 }
1278
1279 #[test]
1280 fn bullets_lead_with_four_core_redirects() {
1281 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1283 let search = BULLETS
1284 .find("ctx_search")
1285 .expect("ctx_search mapping present");
1286 let shell = BULLETS
1287 .find("ctx_shell")
1288 .expect("ctx_shell mapping present");
1289 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1290 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1291 assert!(
1292 read < search && search < shell && shell < glob && glob < tree,
1293 "core redirects (read<search<shell<glob) must precede ctx_tree"
1294 );
1295 }
1296
1297 #[test]
1298 fn never_carries_self_correction() {
1299 assert!(
1301 NEVER.contains("SELF-CORRECT"),
1302 "NEVER must teach self-correction"
1303 );
1304 assert!(
1305 NEVER.contains("call"),
1306 "NEVER must spell out the corrective action"
1307 );
1308 }
1309
1310 #[test]
1311 fn critical_names_ctx_family() {
1312 assert!(
1313 CRITICAL.contains("ctx_*"),
1314 "CRITICAL must name the ctx_* family"
1315 );
1316 }
1317
1318 #[test]
1321 fn hook_covered_carries_strict_mapping() {
1322 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1325 assert!(
1326 !out.contains(NEVER),
1327 "HookCovered uses its own strict header, not the NEVER constant"
1328 );
1329 assert!(
1330 out.contains(HOOK_COVERED_HEADER),
1331 "must carry the strict preference header"
1332 );
1333 assert!(
1334 out.contains("MANDATORY MAPPING")
1335 && out.contains("ctx_read")
1336 && out.contains("ctx_search"),
1337 "must carry the full mandatory mapping"
1338 );
1339 assert!(
1340 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1341 "must advertise the search capabilities"
1342 );
1343 }
1344
1345 #[test]
1346 fn hook_covered_keeps_markers_version_and_recovery() {
1347 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1351 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1352 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1353 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1354 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1355 }
1356
1357 #[test]
1358 fn hook_covered_is_leaner_than_full() {
1359 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1360 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1361 assert!(
1362 covered.len() < full.len(),
1363 "HookCovered ({}) must be a strict reduction of FULL ({})",
1364 covered.len(),
1365 full.len()
1366 );
1367 }
1368
1369 #[test]
1370 fn hook_covered_shadow_collapses_to_minimal() {
1371 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1374 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1375 assert_eq!(covered_shadow, dedicated_shadow);
1376 }
1377
1378 #[test]
1379 fn hook_covered_wraps_compression_in_markers() {
1380 let out = render(
1381 false,
1382 Wrapper::HookCovered,
1383 CompressionLevel::Standard,
1384 &tp(),
1385 );
1386 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1387 assert!(out.contains("OUTPUT STYLE: dense"));
1388 }
1389
1390 #[test]
1393 fn rules_only_mention_enabled_tools() {
1394 use super::super::rules_sections;
1395 use super::super::tool_profiles::ToolProfile;
1396 let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1397 for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1398 let mut sections = vec![
1399 rules_sections::intent_section(&profile),
1400 rules_sections::hook_covered_tools_section(&profile),
1401 rules_sections::shadow_minimal_section(&profile),
1402 rules_sections::anti_section(&profile),
1403 rules_sections::litm_end_section(&profile),
1404 ];
1405 if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1406 sections.push(fb);
1407 }
1408 for tool in &exclusive_tools {
1409 if !profile.is_tool_enabled(tool) {
1410 for section in §ions {
1411 assert!(
1412 !section.contains(tool),
1413 "profile {:?} dynamic section must not mention disabled tool {tool}",
1414 profile.as_str()
1415 );
1416 }
1417 }
1418 }
1419 }
1420 }
1421
1422 #[test]
1423 fn minimal_rules_shorter_than_standard() {
1424 use super::super::tool_profiles::ToolProfile;
1425 let min = render(
1426 false,
1427 Wrapper::Dedicated,
1428 CompressionLevel::Off,
1429 &ToolProfile::Minimal,
1430 );
1431 let std = render(
1432 false,
1433 Wrapper::Dedicated,
1434 CompressionLevel::Off,
1435 &ToolProfile::Standard,
1436 );
1437 assert!(
1438 min.len() < std.len(),
1439 "minimal ({}) must be shorter than standard ({})",
1440 min.len(),
1441 std.len()
1442 );
1443 }
1444
1445 #[test]
1446 fn power_profile_output_identical_to_default() {
1447 use super::super::tool_profiles::ToolProfile;
1448 let power = render(
1449 false,
1450 Wrapper::Dedicated,
1451 CompressionLevel::Off,
1452 &ToolProfile::Power,
1453 );
1454 assert!(
1455 power.contains("ctx_compose"),
1456 "Power must include all tools"
1457 );
1458 assert!(
1459 power.contains("ctx_callgraph"),
1460 "Power must include all tools"
1461 );
1462 assert!(
1463 power.contains("ctx_session"),
1464 "Power must include all tools"
1465 );
1466 }
1467
1468 #[test]
1469 fn render_is_deterministic_across_profiles() {
1470 use super::super::tool_profiles::ToolProfile;
1471 for profile in [
1472 ToolProfile::Minimal,
1473 ToolProfile::Standard,
1474 ToolProfile::Power,
1475 ] {
1476 let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1477 let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1478 assert_eq!(
1479 a,
1480 b,
1481 "render must be deterministic for {:?}",
1482 profile.as_str()
1483 );
1484 }
1485 }
1486
1487 #[test]
1488 fn minimal_has_ctx_call_fallback() {
1489 use super::super::tool_profiles::ToolProfile;
1490 let rules = render(
1491 false,
1492 Wrapper::Dedicated,
1493 CompressionLevel::Off,
1494 &ToolProfile::Minimal,
1495 );
1496 assert!(
1497 rules.contains("ctx_call"),
1498 "minimal profile must include ctx_call gateway hint"
1499 );
1500 }
1501
1502 #[test]
1503 fn power_has_no_ctx_call_fallback() {
1504 use super::super::tool_profiles::ToolProfile;
1505 let rules = render(
1506 false,
1507 Wrapper::Dedicated,
1508 CompressionLevel::Off,
1509 &ToolProfile::Power,
1510 );
1511 assert!(
1512 !rules.contains("ctx_call(tool="),
1513 "power profile must not include ctx_call gateway hint"
1514 );
1515 }
1516}