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 fn compression_text(level: CompressionLevel) -> &'static str {
276 match level {
277 CompressionLevel::Off => "",
278 CompressionLevel::Lite => LITE_PROMPT,
279 CompressionLevel::Standard => STANDARD_PROMPT,
280 CompressionLevel::Max => MAX_PROMPT,
281 }
282}
283
284fn s(c: &str) -> String {
295 c.to_string()
296}
297
298fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
299 use super::rules_sections as rs;
300 let mut v = vec![
301 s(CRITICAL),
302 s(MUST_INVOKE),
303 s(BULLETS),
304 s(NEVER),
305 rs::intent_section(p),
306 s(AGENT_LOOP), rs::anti_section(p),
308 s(NAV_PARADOX), s(PARALLEL),
310 s(AUTO),
311 s(RECOVER),
312 s(CEP),
313 s(INTELLIGENCE),
314 rs::litm_end_section(p),
315 ];
316 if let Some(fb) = rs::ctx_call_fallback(p) {
317 v.push(fb);
318 }
319 v
320}
321
322fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
323 use super::rules_sections as rs;
324 let mut v = vec![
325 s(CRITICAL),
326 s(MUST_INVOKE),
327 s(BULLETS),
328 s(NEVER),
329 rs::intent_section(p),
330 rs::anti_section(p),
331 s(PARALLEL),
332 s(AUTO),
333 s(RECOVER_COMPACT),
334 s(INTELLIGENCE),
335 rs::litm_end_section(p),
336 ];
337 if let Some(fb) = rs::ctx_call_fallback(p) {
338 v.push(fb);
339 }
340 v
341}
342
343fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
344 use super::rules_sections as rs;
345 vec![rs::shadow_minimal_section(p), s(INTELLIGENCE)]
346}
347
348fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
349 use super::rules_sections as rs;
350 let mut v = vec![
351 s(HOOK_COVERED_HEADER),
352 rs::hook_covered_tools_section(p),
353 s(PARALLEL),
354 s(RECOVER_COMPACT),
355 s(INTELLIGENCE),
356 ];
357 if let Some(fb) = rs::ctx_call_fallback(p) {
358 v.push(fb);
359 }
360 v
361}
362
363fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
364 use super::rules_sections as rs;
365 let mut v = vec![
366 s(CRITICAL),
367 s(BULLETS),
368 s(NEVER),
369 rs::intent_section(p),
370 rs::anti_section(p),
371 s(PARALLEL),
372 s(RECOVER_COMPACT),
373 ];
374 if let Some(fb) = rs::ctx_call_fallback(p) {
375 v.push(fb);
376 }
377 v
378}
379
380fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
381 use super::rules_sections as rs;
382 vec![rs::shadow_minimal_section(p)]
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum Wrapper {
389 Longform,
394
395 Dedicated,
400
401 Shared,
405
406 Bare,
409
410 HookCovered,
417}
418
419pub fn render(
430 shadow: bool,
431 wrapper: Wrapper,
432 level: CompressionLevel,
433 tool_profile: &super::tool_profiles::ToolProfile,
434) -> String {
435 use super::rules_sections as rs;
436
437 let sections: Vec<String> = match (wrapper, shadow) {
438 (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
439 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
440 full_shadow_sections(tool_profile)
441 }
442 (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
443 (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
444 (_, false) => compact_non_shadow_sections(tool_profile),
445 (_, true) => compact_shadow_sections(tool_profile),
446 };
447
448 let mut body: String = sections
451 .into_iter()
452 .filter(|s| !s.is_empty())
453 .collect::<Vec<_>>()
454 .join("\n\n");
455 let _ = rs::intent_section; let compression = compression_text(level);
462 if !compression.is_empty() {
463 body.push('\n');
464 if matches!(wrapper, Wrapper::Bare) {
465 body.push_str(compression);
466 } else {
467 body.push_str(COMPRESSION_BLOCK_START);
468 body.push('\n');
469 body.push_str(compression);
470 body.push('\n');
471 body.push_str(COMPRESSION_BLOCK_END);
472 }
473 }
474
475 if matches!(wrapper, Wrapper::Bare) {
476 return body;
477 }
478
479 let version_line = format!("<!-- version: {RULES_VERSION} -->");
480
481 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
482}
483
484pub fn render_hook_covered_bare(
491 shadow: bool,
492 level: CompressionLevel,
493 tool_profile: &super::tool_profiles::ToolProfile,
494) -> String {
495 if shadow {
496 return render(true, Wrapper::Bare, level, tool_profile);
497 }
498 let sections = hook_covered_non_shadow_sections(tool_profile);
499 let mut body: String = sections
500 .into_iter()
501 .filter(|s| !s.is_empty())
502 .collect::<Vec<_>>()
503 .join("\n\n");
504 let compression = compression_text(level);
505 if !compression.is_empty() {
506 body.push('\n');
507 body.push_str(compression);
508 }
509 body
510}
511#[derive(Debug)]
522pub struct RulesFile<'a> {
523 content: &'a str,
524 start: Option<usize>,
526 end: Option<usize>,
528 version: usize,
530}
531
532fn parse_version_number(s: &str) -> Option<usize> {
535 let prefix = "<!-- version: ";
536 let vs = s.find(prefix)?;
537 let num_start = vs + prefix.len();
538 let end = s[num_start..].find(" -->")?;
539 s[num_start..num_start + end].parse().ok()
540}
541
542impl<'a> RulesFile<'a> {
543 pub fn parse(content: &'a str) -> Self {
549 let start = content.find(START_MARK);
550 let version = start
551 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
552 .unwrap_or(0);
553 let end = content.find(END_MARK);
554 RulesFile {
555 content,
556 start,
557 end,
558 version,
559 }
560 }
561
562 pub fn has_content(&self) -> bool {
564 self.start.is_some()
565 }
566
567 pub fn version(&self) -> usize {
570 self.version
571 }
572
573 pub fn is_current(&self) -> bool {
575 self.version >= RULES_VERSION
576 }
577
578 pub fn prefix(&self) -> &'a str {
581 self.start.map_or("", |s| self.content[..s].trim())
582 }
583
584 pub fn suffix(&self) -> &'a str {
587 self.end
588 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
589 }
590
591 fn block(&self) -> Option<&'a str> {
594 match (self.start, self.end) {
595 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
596 _ => None,
597 }
598 }
599
600 pub fn block_matches_render(
610 &self,
611 shadow: bool,
612 wrapper: Wrapper,
613 level: CompressionLevel,
614 tool_profile: &super::tool_profiles::ToolProfile,
615 ) -> bool {
616 match self.block() {
617 Some(block) => block.trim() == render(shadow, wrapper, level, tool_profile).trim(),
618 None => false,
619 }
620 }
621
622 pub fn merged(
628 &self,
629 shadow: bool,
630 wrapper: Wrapper,
631 level: CompressionLevel,
632 tool_profile: &super::tool_profiles::ToolProfile,
633 ) -> String {
634 let fresh = render(shadow, wrapper, level, tool_profile);
635 if self.start.is_some() {
636 let before = self.prefix();
637 let after = self.suffix();
638 let mut out = String::new();
639 if !before.is_empty() {
640 out.push_str(before);
641 out.push('\n');
642 out.push('\n');
643 }
644 out.push_str(&fresh);
645 if !after.is_empty() {
646 out.push('\n');
647 out.push('\n');
648 out.push_str(after);
649 }
650 if !out.ends_with('\n') {
651 out.push('\n');
652 }
653 out
654 } else {
655 let trimmed = self.content.trim_end();
657 let mut out = trimmed.to_string();
658 if !out.is_empty() {
659 out.push('\n');
660 out.push('\n');
661 }
662 out.push_str(&fresh);
663 out
664 }
665 }
666
667 pub fn initial(
669 shadow: bool,
670 wrapper: Wrapper,
671 level: CompressionLevel,
672 tool_profile: &super::tool_profiles::ToolProfile,
673 ) -> String {
674 render(shadow, wrapper, level, tool_profile)
675 }
676
677 pub fn without_section(&self) -> String {
681 if let Some(start_pos) = self.start {
682 let before = self.content[..start_pos].trim();
683 let after = self.suffix();
684 let mut out = String::new();
685 if !before.is_empty() {
686 out.push_str(before);
687 out.push('\n');
688 }
689 if !after.is_empty() {
690 out.push('\n');
691 out.push_str(after);
692 }
693 out
694 } else {
695 self.content.to_string()
696 }
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn tp() -> super::super::tool_profiles::ToolProfile {
705 super::super::tool_profiles::ToolProfile::Power
706 }
707
708 #[test]
711 fn bullets_uses_ctx_shell() {
712 assert!(BULLETS.contains("ctx_shell"));
713 assert!(!BULLETS.contains("lean-ctx -c"));
714 assert!(!BULLETS.contains("ctx_edit"));
715 }
716
717 #[test]
718 fn sections_not_empty() {
719 assert!(!BULLETS.is_empty());
720 assert!(!NEVER.is_empty());
721 assert!(!INTENT.is_empty());
722 assert!(!ANTI.is_empty());
723 assert!(!PARALLEL.is_empty());
724 assert!(!AUTO.is_empty());
725 assert!(!CEP.is_empty());
726 assert!(!INTELLIGENCE.is_empty());
727 assert!(!LITM_END.is_empty());
728 assert!(!CRITICAL.is_empty());
729 }
730
731 #[test]
732 fn intent_contains_ctx_compose() {
733 assert!(INTENT.contains("ctx_compose"));
734 }
735
736 #[test]
737 fn anti_contains_do_not() {
738 assert!(ANTI.contains("do NOT"));
739 }
740
741 #[test]
742 fn parallel_contains_parallel() {
743 assert!(PARALLEL.contains("PARALLEL"));
744 }
745
746 #[test]
749 fn agent_loop_names_every_phase() {
750 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
751 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
752 }
753 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
754 }
755
756 #[test]
757 fn nav_paradox_steers_semantic_vs_graph() {
758 assert!(
759 NAV_PARADOX.contains("ctx_search(action=semantic)"),
760 "semantic route must use folded action (#509)"
761 );
762 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
763 assert!(
764 NAV_PARADOX.contains("≠"),
765 "must carry the reading≠understanding thesis"
766 );
767 }
768
769 #[test]
770 fn longform_carries_loop_and_paradox_injected_full_does_not() {
771 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
775 assert!(
776 long.contains("AGENT LOOP"),
777 "LONGFORM must carry AGENT_LOOP"
778 );
779 assert!(
780 long.contains("NAVIGATION PARADOX"),
781 "LONGFORM must carry NAV_PARADOX"
782 );
783 assert!(long.contains(CEP), "LONGFORM must carry CEP");
784
785 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
786 assert!(
787 !full.contains("AGENT LOOP (phase -> tool):"),
788 "injected FULL must not inline the multi-line AGENT_LOOP block"
789 );
790 assert!(
791 !full.contains("NAVIGATION PARADOX: reading"),
792 "injected FULL must not inline the multi-line NAV_PARADOX block"
793 );
794 assert!(
795 full.contains('≠'),
796 "INTENT must keep the reading≠understanding thesis in FULL"
797 );
798 for phase_tool in [
800 "ctx_compose",
801 "ctx_search(action=symbol)",
802 "ctx_search",
803 "ctx_callgraph",
804 ] {
805 assert!(
806 full.contains(phase_tool),
807 "FULL keeps the loop tools via INTENT: {phase_tool}"
808 );
809 }
810 }
811
812 #[test]
813 fn injected_profiles_stay_lean() {
814 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
818 eprintln!(
819 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
820 full.len(),
821 full.len() / 4,
822 render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
823 render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
824 );
825 assert!(
826 full.len() <= 2400,
827 "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
828 full.len(),
829 full.len() / 4
830 );
831 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
832 assert!(
833 long.len() > full.len(),
834 "Longform ({}) must carry more than the injected profile ({})",
835 long.len(),
836 full.len()
837 );
838 let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
839 assert!(
840 compact.len() < full.len(),
841 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
842 compact.len(),
843 full.len()
844 );
845 }
846
847 #[test]
848 fn compact_profile_has_no_multiline_teaching_sections() {
849 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
852 assert!(
853 !out.contains("AGENT LOOP (phase -> tool):"),
854 "COMPACT must not inline the multi-line AGENT_LOOP block"
855 );
856 assert!(
857 !out.contains("NAVIGATION PARADOX: reading"),
858 "COMPACT must not inline the multi-line NAV_PARADOX block"
859 );
860 assert!(
861 out.contains('≠'),
862 "COMPACT keeps the reading≠understanding thesis via INTENT"
863 );
864 }
865
866 #[test]
867 fn shadow_omits_loop_and_paradox() {
868 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
871 let out = render(true, wrapper, CompressionLevel::Off, &tp());
872 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
873 assert!(
874 !out.contains("NAVIGATION PARADOX"),
875 "{wrapper:?} shadow drops paradox"
876 );
877 }
878 }
879
880 #[test]
881 fn recover_reaches_every_non_shadow_carrier() {
882 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
889 assert!(
890 long.contains(RECOVER),
891 "Longform must carry the verbose RECOVER verbatim"
892 );
893 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
894 let out = render(false, wrapper, CompressionLevel::Off, &tp());
895 assert!(
896 out.contains(RECOVER_COMPACT),
897 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
898 );
899 assert!(
900 !out.contains(RECOVER),
901 "{wrapper:?} must not inline the verbose RECOVER block"
902 );
903 }
904 for wrapper in [
905 Wrapper::Longform,
906 Wrapper::Dedicated,
907 Wrapper::Shared,
908 Wrapper::Bare,
909 ] {
910 assert!(
911 render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
912 "{wrapper:?} recovery line must keep the MCP-free path"
913 );
914 }
915 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
916 let out = render(true, wrapper, CompressionLevel::Off, &tp());
917 assert!(
918 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
919 "{wrapper:?} shadow drops all RECOVER guidance"
920 );
921 }
922 }
923
924 #[test]
927 fn dedicated_has_markers_and_version() {
928 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
929 assert!(out.contains(START_MARK));
930 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
931 assert!(out.contains(END_MARK));
932 assert!(out.contains(BULLETS));
933 assert!(out.contains(NEVER));
934 assert!(out.contains("CRITICAL"));
935 }
936
937 #[test]
938 fn dedicated_shadow_is_minimal() {
939 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
943 assert!(out.contains(START_MARK));
944 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
945 assert!(!out.contains(NEVER), "no NEVER in shadow");
946 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
947 assert!(
948 !out.contains("Tool selection by intent"),
949 "routing INTENT block is redundant under interception"
950 );
951 assert!(
952 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
953 "ANTI/PARALLEL routing guidance is dropped in shadow"
954 );
955 assert!(
956 out.contains("shadow mode") && out.contains("ctx_compose"),
957 "shadow keeps the exclusive-tool advert"
958 );
959 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
960 }
961
962 #[test]
963 fn shadow_is_smaller_than_non_shadow() {
964 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
966 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
967 assert!(
968 shadow.len() < full.len(),
969 "shadow ({}) must be smaller than non-shadow ({})",
970 shadow.len(),
971 full.len()
972 );
973 }
974
975 #[test]
976 fn dedicated_litm_structure() {
977 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
978 let lines: Vec<&str> = out.lines().collect();
979 let first_5 = lines[..5.min(lines.len())].join("\n");
980 assert!(
981 first_5.contains("CRITICAL") || first_5.contains("MUST"),
982 "LITM: MUST/CRITICAL instruction near start"
983 );
984 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
986 assert!(
987 tail.contains("PREFERENCE") || tail.contains("NEVER"),
988 "LITM: reinforcement near end, tail={tail:?}"
989 );
990 }
991
992 #[test]
993 fn dedicated_carries_weak_model_invoke_nudge() {
994 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
999 assert!(
1000 dedicated.contains(MUST_INVOKE),
1001 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
1002 );
1003 assert!(
1004 !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1005 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
1006 );
1007 assert!(
1008 !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
1009 "Bare/instructions channel is capped separately and carries no copy"
1010 );
1011 }
1012
1013 #[test]
1016 fn shared_has_markers_and_header() {
1017 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1018 assert!(out.contains(START_MARK));
1019 assert!(out.contains(END_MARK));
1020 assert!(out.contains("MANDATORY MAPPING"));
1021 assert!(out.contains(BULLETS));
1022 }
1023
1024 #[test]
1025 fn shared_shadow_omits_mapping() {
1026 let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
1027 assert!(out.contains(START_MARK));
1028 assert!(
1029 !out.contains("MANDATORY MAPPING"),
1030 "shadow must not have header"
1031 );
1032 assert!(
1033 !out.contains("MANDATORY MAPPING"),
1034 "shadow must not contain BULLETS"
1035 );
1036 }
1037
1038 #[test]
1041 fn bare_has_no_markers() {
1042 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1043 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
1044 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
1045 assert!(!out.contains("<!-- version:"), "Bare must not have version");
1046 assert!(out.contains(BULLETS));
1047 assert!(out.contains(NEVER));
1048 }
1049
1050 #[test]
1051 fn bare_shadow_only_read_modes() {
1052 let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
1053 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
1054 assert!(
1055 !out.contains("MANDATORY MAPPING"),
1056 "shadow Bare must not have BULLETS"
1057 );
1058 }
1059
1060 #[test]
1063 fn render_includes_lite_prompt() {
1064 let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
1065 assert!(out.contains("OUTPUT STYLE: concise"));
1066 assert!(out.contains("Bullet points"));
1067 }
1068
1069 #[test]
1070 fn render_includes_standard_prompt() {
1071 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1072 assert!(out.contains("OUTPUT STYLE: dense"));
1073 assert!(out.contains("atomic fact"));
1074 }
1075
1076 #[test]
1077 fn render_includes_max_prompt() {
1078 let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
1079 assert!(out.contains("OUTPUT STYLE: expert-terse"));
1080 assert!(out.contains("Telegraph"));
1081 }
1082
1083 #[test]
1084 fn render_off_excludes_compression() {
1085 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1086 assert!(!out.contains("OUTPUT STYLE:"));
1087 }
1088
1089 #[test]
1090 fn compression_text_matches_level() {
1091 assert!(compression_text(CompressionLevel::Off).is_empty());
1092 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1093 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1094 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1095 }
1096
1097 #[test]
1100 fn carrier_wrappers_wrap_compression_in_markers() {
1101 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1104 let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1105 assert!(
1106 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1107 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1108 );
1109 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1111 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1112 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1113 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1114 }
1115 }
1116
1117 #[test]
1118 fn bare_wrapper_emits_compression_without_markers() {
1119 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1122 assert!(out.contains("OUTPUT STYLE: dense"));
1123 assert!(!out.contains(COMPRESSION_BLOCK_START));
1124 assert!(!out.contains(COMPRESSION_BLOCK_END));
1125 }
1126
1127 #[test]
1128 fn compression_off_emits_no_markers_in_any_wrapper() {
1129 for wrapper in [
1130 Wrapper::Longform,
1131 Wrapper::Dedicated,
1132 Wrapper::Shared,
1133 Wrapper::Bare,
1134 ] {
1135 let out = render(false, wrapper, CompressionLevel::Off, &tp());
1136 assert!(
1137 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1138 "{wrapper:?}: Off must emit no compression markers"
1139 );
1140 }
1141 }
1142
1143 #[test]
1144 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1145 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1148 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1149 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1150 }
1151
1152 #[test]
1155 fn all_wrappers_produce_output() {
1156 for shadow in [false, true] {
1157 for wrapper in [
1158 Wrapper::Longform,
1159 Wrapper::Dedicated,
1160 Wrapper::Shared,
1161 Wrapper::Bare,
1162 ] {
1163 let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1164 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1165 }
1166 }
1167 }
1168
1169 #[test]
1172 fn rules_file_parses_version() {
1173 let content = format!(
1174 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1175 );
1176 let f = RulesFile::parse(&content);
1177 assert!(f.has_content());
1178 assert_eq!(f.version(), RULES_VERSION);
1179 assert!(f.is_current());
1180 assert!(f.prefix().contains("stuff before"));
1181 assert!(f.suffix().contains("stuff after"));
1182 }
1183
1184 #[test]
1185 fn rules_file_no_version_defaults_to_zero() {
1186 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1187 let f = RulesFile::parse(&content);
1188 assert!(f.has_content());
1189 assert_eq!(f.version(), 0);
1190 assert!(!f.is_current());
1191 }
1192
1193 #[test]
1194 fn rules_file_no_start_marker_no_content() {
1195 let f = RulesFile::parse("just user stuff");
1196 assert!(!f.has_content());
1197 assert_eq!(f.version(), 0);
1198 }
1199
1200 #[test]
1201 fn block_matches_render_true_for_fresh_render() {
1202 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1203 let content = format!("user before\n{fresh}\nuser after");
1204 let f = RulesFile::parse(&content);
1205 assert!(f.is_current(), "fresh render carries the current version");
1206 assert!(
1207 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1208 "an unchanged block must compare equal to a fresh render"
1209 );
1210 }
1211
1212 #[test]
1213 fn block_matches_render_false_on_compression_change() {
1214 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1217 let f = RulesFile::parse(&content);
1218 assert!(f.is_current());
1219 assert!(
1220 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1221 "a compression-level change must be detected as drift"
1222 );
1223 }
1224
1225 #[test]
1226 fn block_matches_render_false_on_shadow_change() {
1227 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1228 let f = RulesFile::parse(&content);
1229 assert!(
1230 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1231 "a shadow-mode toggle must be detected as drift"
1232 );
1233 }
1234
1235 #[test]
1236 fn block_matches_render_false_without_block() {
1237 let f = RulesFile::parse("plain user content, no markers");
1238 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1239 }
1240
1241 #[test]
1242 fn rules_file_merged_replaces_section() {
1243 let content =
1244 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1245 let f = RulesFile::parse(&content);
1246 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1247 assert!(merged.contains("before"), "prefix preserved");
1248 assert!(merged.contains("after"), "suffix preserved");
1249 assert!(!merged.contains("old"), "old content replaced");
1250 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1251 }
1252
1253 #[test]
1254 fn rules_file_merged_appends_when_no_section() {
1255 let content = "user content";
1256 let f = RulesFile::parse(content);
1257 assert!(!f.has_content());
1258 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1259 assert!(merged.contains("user content"));
1260 assert!(merged.contains(BULLETS));
1261 }
1262
1263 #[test]
1264 fn rules_file_without_section_strips_content() {
1265 let content =
1266 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1267 let f = RulesFile::parse(&content);
1268 let stripped = f.without_section();
1269 assert!(stripped.contains("header"));
1270 assert!(stripped.contains("footer"));
1271 assert!(!stripped.contains("body"));
1272 assert!(!stripped.contains(START_MARK));
1273 }
1274
1275 #[test]
1276 fn rules_file_without_section_noop_when_no_content() {
1277 let content = "just user text";
1278 let f = RulesFile::parse(content);
1279 assert_eq!(f.without_section(), content);
1280 }
1281
1282 #[test]
1283 fn bullets_lead_with_four_core_redirects() {
1284 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1286 let search = BULLETS
1287 .find("ctx_search")
1288 .expect("ctx_search mapping present");
1289 let shell = BULLETS
1290 .find("ctx_shell")
1291 .expect("ctx_shell mapping present");
1292 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1293 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1294 assert!(
1295 read < search && search < shell && shell < glob && glob < tree,
1296 "core redirects (read<search<shell<glob) must precede ctx_tree"
1297 );
1298 }
1299
1300 #[test]
1301 fn never_carries_self_correction() {
1302 assert!(
1304 NEVER.contains("SELF-CORRECT"),
1305 "NEVER must teach self-correction"
1306 );
1307 assert!(
1308 NEVER.contains("call"),
1309 "NEVER must spell out the corrective action"
1310 );
1311 }
1312
1313 #[test]
1314 fn critical_names_ctx_family() {
1315 assert!(
1316 CRITICAL.contains("ctx_*"),
1317 "CRITICAL must name the ctx_* family"
1318 );
1319 }
1320
1321 #[test]
1324 fn hook_covered_carries_strict_mapping() {
1325 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1328 assert!(
1329 !out.contains(NEVER),
1330 "HookCovered uses its own strict header, not the NEVER constant"
1331 );
1332 assert!(
1333 out.contains(HOOK_COVERED_HEADER),
1334 "must carry the strict preference header"
1335 );
1336 assert!(
1337 out.contains("MANDATORY MAPPING")
1338 && out.contains("ctx_read")
1339 && out.contains("ctx_search"),
1340 "must carry the full mandatory mapping"
1341 );
1342 assert!(
1343 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1344 "must advertise the search capabilities"
1345 );
1346 }
1347
1348 #[test]
1349 fn hook_covered_keeps_markers_version_and_recovery() {
1350 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1354 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1355 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1356 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1357 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1358 }
1359
1360 #[test]
1361 fn hook_covered_is_leaner_than_full() {
1362 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1363 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1364 assert!(
1365 covered.len() < full.len(),
1366 "HookCovered ({}) must be a strict reduction of FULL ({})",
1367 covered.len(),
1368 full.len()
1369 );
1370 }
1371
1372 #[test]
1373 fn hook_covered_shadow_collapses_to_minimal() {
1374 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1377 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1378 assert_eq!(covered_shadow, dedicated_shadow);
1379 }
1380
1381 #[test]
1382 fn hook_covered_wraps_compression_in_markers() {
1383 let out = render(
1384 false,
1385 Wrapper::HookCovered,
1386 CompressionLevel::Standard,
1387 &tp(),
1388 );
1389 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1390 assert!(out.contains("OUTPUT STYLE: dense"));
1391 }
1392
1393 #[test]
1396 fn rules_only_mention_enabled_tools() {
1397 use super::super::rules_sections;
1398 use super::super::tool_profiles::ToolProfile;
1399 let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1400 for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1401 let mut sections = vec![
1402 rules_sections::intent_section(&profile),
1403 rules_sections::hook_covered_tools_section(&profile),
1404 rules_sections::shadow_minimal_section(&profile),
1405 rules_sections::anti_section(&profile),
1406 rules_sections::litm_end_section(&profile),
1407 ];
1408 if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1409 sections.push(fb);
1410 }
1411 for tool in &exclusive_tools {
1412 if !profile.is_tool_enabled(tool) {
1413 for section in §ions {
1414 assert!(
1415 !section.contains(tool),
1416 "profile {:?} dynamic section must not mention disabled tool {tool}",
1417 profile.as_str()
1418 );
1419 }
1420 }
1421 }
1422 }
1423 }
1424
1425 #[test]
1426 fn minimal_rules_shorter_than_standard() {
1427 use super::super::tool_profiles::ToolProfile;
1428 let min = render(
1429 false,
1430 Wrapper::Dedicated,
1431 CompressionLevel::Off,
1432 &ToolProfile::Minimal,
1433 );
1434 let std = render(
1435 false,
1436 Wrapper::Dedicated,
1437 CompressionLevel::Off,
1438 &ToolProfile::Standard,
1439 );
1440 assert!(
1441 min.len() < std.len(),
1442 "minimal ({}) must be shorter than standard ({})",
1443 min.len(),
1444 std.len()
1445 );
1446 }
1447
1448 #[test]
1449 fn power_profile_output_identical_to_default() {
1450 use super::super::tool_profiles::ToolProfile;
1451 let power = render(
1452 false,
1453 Wrapper::Dedicated,
1454 CompressionLevel::Off,
1455 &ToolProfile::Power,
1456 );
1457 assert!(
1458 power.contains("ctx_compose"),
1459 "Power must include all tools"
1460 );
1461 assert!(
1462 power.contains("ctx_callgraph"),
1463 "Power must include all tools"
1464 );
1465 assert!(
1466 power.contains("ctx_session"),
1467 "Power must include all tools"
1468 );
1469 }
1470
1471 #[test]
1472 fn render_is_deterministic_across_profiles() {
1473 use super::super::tool_profiles::ToolProfile;
1474 for profile in [
1475 ToolProfile::Minimal,
1476 ToolProfile::Standard,
1477 ToolProfile::Power,
1478 ] {
1479 let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1480 let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1481 assert_eq!(
1482 a,
1483 b,
1484 "render must be deterministic for {:?}",
1485 profile.as_str()
1486 );
1487 }
1488 }
1489
1490 #[test]
1491 fn minimal_has_ctx_call_fallback() {
1492 use super::super::tool_profiles::ToolProfile;
1493 let rules = render(
1494 false,
1495 Wrapper::Dedicated,
1496 CompressionLevel::Off,
1497 &ToolProfile::Minimal,
1498 );
1499 assert!(
1500 rules.contains("ctx_call"),
1501 "minimal profile must include ctx_call gateway hint"
1502 );
1503 }
1504
1505 #[test]
1506 fn power_has_no_ctx_call_fallback() {
1507 use super::super::tool_profiles::ToolProfile;
1508 let rules = render(
1509 false,
1510 Wrapper::Dedicated,
1511 CompressionLevel::Off,
1512 &ToolProfile::Power,
1513 );
1514 assert!(
1515 !rules.contains("ctx_call(tool="),
1516 "power profile must not include ctx_call gateway hint"
1517 );
1518 }
1519}