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 = 5;
83
84pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx ctx_* tools instead of native equivalents. \
86 This is NOT optional.";
87
88pub const MUST_INVOKE: &str = "ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — \
99 describing a tool is not calling it.";
100
101pub const BULLETS: &str = "\
103MANDATORY MAPPING:\n\
104• Read/cat -> ctx_read(path, mode)\n\
105• Grep -> ctx_search(pattern, path)\n\
106• Shell/bash -> ctx_shell(command)\n\
107• Glob/find -> ctx_glob(pattern)\n\
108• ls/find -> ctx_tree(path, depth)";
109
110pub const NEVER: &str = "NEVER use native Read/Grep/Shell/Glob when a ctx_* equivalent \
113 exists. SELF-CORRECT: the moment you reach for one, stop and call the ctx_* tool instead.";
114
115pub const INTENT: &str = "\
120Tool selection by intent:\n\
121• Orient / understand code (call FIRST) -> ctx_compose\n\
122• Read a file -> ctx_read(path, mode=signatures|map|full); edit after reading -> ctx_patch\n\
123• Exact symbol -> ctx_symbol; pattern -> ctx_search; by meaning -> ctx_semantic_search\n\
124• Files by glob -> ctx_glob; structure -> ctx_tree; callers/impact -> ctx_callgraph\n\
125• Verify after edits -> ctx_shell(test/build); memory -> ctx_session / ctx_knowledge\n\
126Semantic questions -> search tools, not whole-file reads: reading more ≠ understanding more.";
127
128pub const ANTI: &str = "\
130Anti-patterns — do NOT:\n\
131• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
132• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
133• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track call\n\
134 edges and file deps only; use ctx_search instead";
135
136pub const PARALLEL: &str = "\
138PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles \
139multiple lookups into one call.";
140
141pub const AGENT_LOOP: &str = "\
146AGENT LOOP (phase -> tool):\n\
147• Orient — understand before acting -> ctx_compose\n\
148• Find — exact symbol by name -> ctx_symbol\n\
149• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
150• Locate — a pattern across files -> ctx_search\n\
151• Trace — callers / callees / blast radius -> ctx_callgraph\n\
152• Verify — after an edit -> ctx_shell(test/build) + native lints";
153
154pub const NAV_PARADOX: &str = "\
159NAVIGATION PARADOX: reading more ≠ understanding more.\n\
160• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_semantic_search (meaning), not whole-file reads\n\
161• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
162• Navigate structure (signatures, symbols) before reading entire files";
163
164pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
166 ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. \
167 Full guide: LEAN-CTX.md";
168
169pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
178 Need full/exact? Read the shown file path with any tool (no MCP), or \
179 ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
180
181pub const RECOVER_COMPACT: &str = "RECOVER: compression is reversible — read the shown path \
188 (no MCP) or ctx_read(raw=true), never re-read line-by-line.";
189
190pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
192 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
193
194pub const INTELLIGENCE: &str =
196 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
197
198pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
200 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
201
202pub const SHADOW_MINIMAL: &str = "\
208lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
209Exclusive tools (no native trigger): ctx_compose (understand code, call first), ctx_symbol (exact symbol), ctx_callgraph (callers), ctx_semantic_search (by meaning), ctx_knowledge / ctx_session (memory).";
210
211pub const HOOK_COVERED_HEADER: &str = "\
219lean-ctx hooks cover this session: native Shell, Read and Grep are compressed \
220transparently (PreToolUse rewrite/redirect) — using them is fine and already saves tokens.";
221
222pub const HOOK_COVERED_TOOLS: &str = "\
226Call the ctx_* MCP tools for what native tools cannot do:\n\
227• ctx_compose — orient in code (bundles search + read + symbols in one call)\n\
228• ctx_symbol / ctx_callgraph — exact definitions, callers, blast radius\n\
229• ctx_semantic_search — search by meaning, not pattern\n\
230• ctx_knowledge / ctx_session — persistent memory across sessions\n\
231• ctx_expand — recover full text from [Archived]/compressed output";
232
233pub const LITE_PROMPT: &str = "\
237OUTPUT STYLE: concise
238- Bullet points over paragraphs
239- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
240- 1-sentence explanations max, then code/action
241- No repeating what the user said";
242
243pub const STANDARD_PROMPT: &str = "\
245OUTPUT STYLE: dense
246- Each statement = one atomic fact line
247- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
248- Diff lines only (+/-/~), never repeat unchanged code
249- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
250- No narration, no filler, no hedging
251- BUDGET: ≤200 tokens per response unless code block required";
252
253pub const MAX_PROMPT: &str = "\
255OUTPUT STYLE: expert-terse
256- Telegraph format: subject-verb-object, drop articles/prepositions
257- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
258- Code blocks: untouched (never compress code syntax)
259- Each line: max 80 chars
260- Zero narration, zero filler
261- BUDGET: ≤100 tokens per non-code response";
262
263pub fn compression_text(level: CompressionLevel) -> &'static str {
265 match level {
266 CompressionLevel::Off => "",
267 CompressionLevel::Lite => LITE_PROMPT,
268 CompressionLevel::Standard => STANDARD_PROMPT,
269 CompressionLevel::Max => MAX_PROMPT,
270 }
271}
272
273const LONGFORM_NON_SHADOW: &[&str] = &[
278 CRITICAL,
279 MUST_INVOKE,
280 BULLETS,
281 NEVER,
282 INTENT,
283 AGENT_LOOP,
284 ANTI,
285 NAV_PARADOX,
286 PARALLEL,
287 AUTO,
288 RECOVER,
289 CEP,
290 INTELLIGENCE,
291 LITM_END,
292];
293
294const FULL_NON_SHADOW: &[&str] = &[
298 CRITICAL,
299 MUST_INVOKE,
300 BULLETS,
301 NEVER,
302 INTENT,
303 ANTI,
304 PARALLEL,
305 AUTO,
306 RECOVER_COMPACT,
307 INTELLIGENCE,
308 LITM_END,
309];
310
311const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
316
317const HOOK_COVERED_NON_SHADOW: &[&str] = &[
325 HOOK_COVERED_HEADER,
326 HOOK_COVERED_TOOLS,
327 PARALLEL,
328 RECOVER_COMPACT,
329 INTELLIGENCE,
330];
331
332const COMPACT_NON_SHADOW: &[&str] = &[
333 CRITICAL,
334 BULLETS,
335 NEVER,
336 INTENT,
337 ANTI,
338 PARALLEL,
339 RECOVER_COMPACT,
340];
341
342const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum Wrapper {
348 Longform,
353
354 Dedicated,
359
360 Shared,
364
365 Bare,
368
369 HookCovered,
376}
377
378pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
386 let profile = match (wrapper, shadow) {
387 (Wrapper::Longform, false) => LONGFORM_NON_SHADOW,
388 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => FULL_SHADOW,
391 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
392 (Wrapper::HookCovered, false) => HOOK_COVERED_NON_SHADOW,
393 (_, false) => COMPACT_NON_SHADOW,
394 (_, true) => COMPACT_SHADOW,
395 };
396
397 let mut body = profile.join("\n\n");
398
399 let compression = compression_text(level);
404 if !compression.is_empty() {
405 body.push('\n');
406 if matches!(wrapper, Wrapper::Bare) {
407 body.push_str(compression);
408 } else {
409 body.push_str(COMPRESSION_BLOCK_START);
410 body.push('\n');
411 body.push_str(compression);
412 body.push('\n');
413 body.push_str(COMPRESSION_BLOCK_END);
414 }
415 }
416
417 if matches!(wrapper, Wrapper::Bare) {
418 return body;
419 }
420
421 let version_line = format!("<!-- version: {RULES_VERSION} -->");
422
423 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
424}
425
426pub fn render_hook_covered_bare(shadow: bool, level: CompressionLevel) -> String {
433 if shadow {
434 return render(true, Wrapper::Bare, level);
435 }
436 let mut body = HOOK_COVERED_NON_SHADOW.join("\n\n");
437 let compression = compression_text(level);
438 if !compression.is_empty() {
439 body.push('\n');
440 body.push_str(compression);
441 }
442 body
443}
444#[derive(Debug)]
455pub struct RulesFile<'a> {
456 content: &'a str,
457 start: Option<usize>,
459 end: Option<usize>,
461 version: usize,
463}
464
465fn parse_version_number(s: &str) -> Option<usize> {
468 let prefix = "<!-- version: ";
469 let vs = s.find(prefix)?;
470 let num_start = vs + prefix.len();
471 let end = s[num_start..].find(" -->")?;
472 s[num_start..num_start + end].parse().ok()
473}
474
475impl<'a> RulesFile<'a> {
476 pub fn parse(content: &'a str) -> Self {
482 let start = content.find(START_MARK);
483 let version = start
484 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
485 .unwrap_or(0);
486 let end = content.find(END_MARK);
487 RulesFile {
488 content,
489 start,
490 end,
491 version,
492 }
493 }
494
495 pub fn has_content(&self) -> bool {
497 self.start.is_some()
498 }
499
500 pub fn version(&self) -> usize {
503 self.version
504 }
505
506 pub fn is_current(&self) -> bool {
508 self.version >= RULES_VERSION
509 }
510
511 pub fn prefix(&self) -> &'a str {
514 self.start.map_or("", |s| self.content[..s].trim())
515 }
516
517 pub fn suffix(&self) -> &'a str {
520 self.end
521 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
522 }
523
524 fn block(&self) -> Option<&'a str> {
527 match (self.start, self.end) {
528 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
529 _ => None,
530 }
531 }
532
533 pub fn block_matches_render(
543 &self,
544 shadow: bool,
545 wrapper: Wrapper,
546 level: CompressionLevel,
547 ) -> bool {
548 match self.block() {
549 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
550 None => false,
551 }
552 }
553
554 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
560 let fresh = render(shadow, wrapper, level);
561 if self.start.is_some() {
562 let before = self.prefix();
563 let after = self.suffix();
564 let mut out = String::new();
565 if !before.is_empty() {
566 out.push_str(before);
567 out.push('\n');
568 out.push('\n');
569 }
570 out.push_str(&fresh);
571 if !after.is_empty() {
572 out.push('\n');
573 out.push('\n');
574 out.push_str(after);
575 }
576 if !out.ends_with('\n') {
577 out.push('\n');
578 }
579 out
580 } else {
581 let trimmed = self.content.trim_end();
583 let mut out = trimmed.to_string();
584 if !out.is_empty() {
585 out.push('\n');
586 out.push('\n');
587 }
588 out.push_str(&fresh);
589 out
590 }
591 }
592
593 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
595 render(shadow, wrapper, level)
596 }
597
598 pub fn without_section(&self) -> String {
602 if let Some(start_pos) = self.start {
603 let before = self.content[..start_pos].trim();
604 let after = self.suffix();
605 let mut out = String::new();
606 if !before.is_empty() {
607 out.push_str(before);
608 out.push('\n');
609 }
610 if !after.is_empty() {
611 out.push('\n');
612 out.push_str(after);
613 }
614 out
615 } else {
616 self.content.to_string()
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 #[test]
628 fn bullets_uses_ctx_shell() {
629 assert!(BULLETS.contains("ctx_shell"));
630 assert!(!BULLETS.contains("lean-ctx -c"));
631 assert!(!BULLETS.contains("ctx_edit"));
632 }
633
634 #[test]
635 fn sections_not_empty() {
636 assert!(!BULLETS.is_empty());
637 assert!(!NEVER.is_empty());
638 assert!(!INTENT.is_empty());
639 assert!(!ANTI.is_empty());
640 assert!(!PARALLEL.is_empty());
641 assert!(!AUTO.is_empty());
642 assert!(!CEP.is_empty());
643 assert!(!INTELLIGENCE.is_empty());
644 assert!(!LITM_END.is_empty());
645 assert!(!CRITICAL.is_empty());
646 }
647
648 #[test]
649 fn intent_contains_ctx_compose() {
650 assert!(INTENT.contains("ctx_compose"));
651 }
652
653 #[test]
654 fn anti_contains_do_not() {
655 assert!(ANTI.contains("do NOT"));
656 }
657
658 #[test]
659 fn parallel_contains_parallel() {
660 assert!(PARALLEL.contains("PARALLEL"));
661 }
662
663 #[test]
666 fn agent_loop_names_every_phase() {
667 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
668 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
669 }
670 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
671 }
672
673 #[test]
674 fn nav_paradox_steers_semantic_vs_graph() {
675 assert!(
676 NAV_PARADOX.contains("ctx_semantic_search"),
677 "semantic route"
678 );
679 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
680 assert!(
681 NAV_PARADOX.contains("≠"),
682 "must carry the reading≠understanding thesis"
683 );
684 }
685
686 #[test]
687 fn longform_carries_loop_and_paradox_injected_full_does_not() {
688 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
692 assert!(
693 long.contains("AGENT LOOP"),
694 "LONGFORM must carry AGENT_LOOP"
695 );
696 assert!(
697 long.contains("NAVIGATION PARADOX"),
698 "LONGFORM must carry NAV_PARADOX"
699 );
700 assert!(long.contains(CEP), "LONGFORM must carry CEP");
701
702 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
703 assert!(
704 !full.contains("AGENT LOOP (phase -> tool):"),
705 "injected FULL must not inline the multi-line AGENT_LOOP block"
706 );
707 assert!(
708 !full.contains("NAVIGATION PARADOX: reading"),
709 "injected FULL must not inline the multi-line NAV_PARADOX block"
710 );
711 assert!(
712 full.contains('≠'),
713 "INTENT must keep the reading≠understanding thesis in FULL"
714 );
715 for phase_tool in ["ctx_compose", "ctx_symbol", "ctx_search", "ctx_callgraph"] {
716 assert!(
717 full.contains(phase_tool),
718 "FULL keeps the loop tools via INTENT: {phase_tool}"
719 );
720 }
721 }
722
723 #[test]
724 fn injected_profiles_stay_lean() {
725 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
729 eprintln!(
730 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
731 full.len(),
732 full.len() / 4,
733 render(false, Wrapper::Longform, CompressionLevel::Off).len(),
734 render(false, Wrapper::Bare, CompressionLevel::Off).len(),
735 );
736 assert!(
737 full.len() <= 2100,
738 "injected dedicated rules must stay ≤2100 chars (~525 tok), got {} chars (~{} tok)",
739 full.len(),
740 full.len() / 4
741 );
742 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
743 assert!(
744 long.len() > full.len(),
745 "Longform ({}) must carry more than the injected profile ({})",
746 long.len(),
747 full.len()
748 );
749 let compact = render(false, Wrapper::Bare, CompressionLevel::Off);
750 assert!(
751 compact.len() < full.len(),
752 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
753 compact.len(),
754 full.len()
755 );
756 }
757
758 #[test]
759 fn compact_profile_has_no_multiline_teaching_sections() {
760 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
763 assert!(
764 !out.contains("AGENT LOOP (phase -> tool):"),
765 "COMPACT must not inline the multi-line AGENT_LOOP block"
766 );
767 assert!(
768 !out.contains("NAVIGATION PARADOX: reading"),
769 "COMPACT must not inline the multi-line NAV_PARADOX block"
770 );
771 assert!(
772 out.contains('≠'),
773 "COMPACT keeps the reading≠understanding thesis via INTENT"
774 );
775 }
776
777 #[test]
778 fn shadow_omits_loop_and_paradox() {
779 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
782 let out = render(true, wrapper, CompressionLevel::Off);
783 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
784 assert!(
785 !out.contains("NAVIGATION PARADOX"),
786 "{wrapper:?} shadow drops paradox"
787 );
788 }
789 }
790
791 #[test]
792 fn recover_reaches_every_non_shadow_carrier() {
793 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
800 assert!(
801 long.contains(RECOVER),
802 "Longform must carry the verbose RECOVER verbatim"
803 );
804 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
805 let out = render(false, wrapper, CompressionLevel::Off);
806 assert!(
807 out.contains(RECOVER_COMPACT),
808 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
809 );
810 assert!(
811 !out.contains(RECOVER),
812 "{wrapper:?} must not inline the verbose RECOVER block"
813 );
814 }
815 for wrapper in [
816 Wrapper::Longform,
817 Wrapper::Dedicated,
818 Wrapper::Shared,
819 Wrapper::Bare,
820 ] {
821 assert!(
822 render(false, wrapper, CompressionLevel::Off).contains("(no MCP)"),
823 "{wrapper:?} recovery line must keep the MCP-free path"
824 );
825 }
826 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
828 let out = render(true, wrapper, CompressionLevel::Off);
829 assert!(
830 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
831 "{wrapper:?} shadow drops all RECOVER guidance"
832 );
833 }
834 }
835
836 #[test]
839 fn dedicated_has_markers_and_version() {
840 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
841 assert!(out.contains(START_MARK));
842 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
843 assert!(out.contains(END_MARK));
844 assert!(out.contains(BULLETS));
845 assert!(out.contains(NEVER));
846 assert!(out.contains("CRITICAL"));
847 }
848
849 #[test]
850 fn dedicated_shadow_is_minimal() {
851 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
855 assert!(out.contains(START_MARK));
856 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
857 assert!(!out.contains(NEVER), "no NEVER in shadow");
858 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
859 assert!(
860 !out.contains("Tool selection by intent"),
861 "routing INTENT block is redundant under interception"
862 );
863 assert!(
864 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
865 "ANTI/PARALLEL routing guidance is dropped in shadow"
866 );
867 assert!(
868 out.contains("shadow mode") && out.contains("ctx_compose"),
869 "shadow keeps the exclusive-tool advert"
870 );
871 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
872 }
873
874 #[test]
875 fn shadow_is_smaller_than_non_shadow() {
876 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
878 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
879 assert!(
880 shadow.len() < full.len(),
881 "shadow ({}) must be smaller than non-shadow ({})",
882 shadow.len(),
883 full.len()
884 );
885 }
886
887 #[test]
888 fn dedicated_litm_structure() {
889 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
890 let lines: Vec<&str> = out.lines().collect();
891 let first_5 = lines[..5.min(lines.len())].join("\n");
892 assert!(
893 first_5.contains("CRITICAL") || first_5.contains("MUST"),
894 "LITM: MUST/CRITICAL instruction near start"
895 );
896 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
898 assert!(
899 tail.contains("PREFERENCE") || tail.contains("NEVER"),
900 "LITM: reinforcement near end, tail={tail:?}"
901 );
902 }
903
904 #[test]
905 fn dedicated_carries_weak_model_invoke_nudge() {
906 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off);
911 assert!(
912 dedicated.contains(MUST_INVOKE),
913 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
914 );
915 assert!(
916 !render(true, Wrapper::Dedicated, CompressionLevel::Off).contains(MUST_INVOKE),
917 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
918 );
919 assert!(
920 !render(false, Wrapper::Bare, CompressionLevel::Off).contains(MUST_INVOKE),
921 "Bare/instructions channel is capped separately and carries no copy"
922 );
923 }
924
925 #[test]
928 fn shared_has_markers_and_header() {
929 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
930 assert!(out.contains(START_MARK));
931 assert!(out.contains(END_MARK));
932 assert!(out.contains("MANDATORY MAPPING"));
933 assert!(out.contains(BULLETS));
934 }
935
936 #[test]
937 fn shared_shadow_omits_mapping() {
938 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
939 assert!(out.contains(START_MARK));
940 assert!(
941 !out.contains("MANDATORY MAPPING"),
942 "shadow must not have header"
943 );
944 assert!(
945 !out.contains("MANDATORY MAPPING"),
946 "shadow must not contain BULLETS"
947 );
948 }
949
950 #[test]
953 fn bare_has_no_markers() {
954 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
955 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
956 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
957 assert!(!out.contains("<!-- version:"), "Bare must not have version");
958 assert!(out.contains(BULLETS));
959 assert!(out.contains(NEVER));
960 }
961
962 #[test]
963 fn bare_shadow_only_read_modes() {
964 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
965 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
966 assert!(
967 !out.contains("MANDATORY MAPPING"),
968 "shadow Bare must not have BULLETS"
969 );
970 }
971
972 #[test]
975 fn render_includes_lite_prompt() {
976 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
977 assert!(out.contains("OUTPUT STYLE: concise"));
978 assert!(out.contains("Bullet points"));
979 }
980
981 #[test]
982 fn render_includes_standard_prompt() {
983 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
984 assert!(out.contains("OUTPUT STYLE: dense"));
985 assert!(out.contains("atomic fact"));
986 }
987
988 #[test]
989 fn render_includes_max_prompt() {
990 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
991 assert!(out.contains("OUTPUT STYLE: expert-terse"));
992 assert!(out.contains("Telegraph"));
993 }
994
995 #[test]
996 fn render_off_excludes_compression() {
997 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
998 assert!(!out.contains("OUTPUT STYLE:"));
999 }
1000
1001 #[test]
1002 fn compression_text_matches_level() {
1003 assert!(compression_text(CompressionLevel::Off).is_empty());
1004 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1005 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1006 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1007 }
1008
1009 #[test]
1012 fn carrier_wrappers_wrap_compression_in_markers() {
1013 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1016 let out = render(false, wrapper, CompressionLevel::Standard);
1017 assert!(
1018 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1019 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1020 );
1021 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1023 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1024 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1025 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1026 }
1027 }
1028
1029 #[test]
1030 fn bare_wrapper_emits_compression_without_markers() {
1031 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
1034 assert!(out.contains("OUTPUT STYLE: dense"));
1035 assert!(!out.contains(COMPRESSION_BLOCK_START));
1036 assert!(!out.contains(COMPRESSION_BLOCK_END));
1037 }
1038
1039 #[test]
1040 fn compression_off_emits_no_markers_in_any_wrapper() {
1041 for wrapper in [
1042 Wrapper::Longform,
1043 Wrapper::Dedicated,
1044 Wrapper::Shared,
1045 Wrapper::Bare,
1046 ] {
1047 let out = render(false, wrapper, CompressionLevel::Off);
1048 assert!(
1049 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1050 "{wrapper:?}: Off must emit no compression markers"
1051 );
1052 }
1053 }
1054
1055 #[test]
1056 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1057 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1060 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1061 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1062 }
1063
1064 #[test]
1067 fn all_wrappers_produce_output() {
1068 for shadow in [false, true] {
1069 for wrapper in [
1070 Wrapper::Longform,
1071 Wrapper::Dedicated,
1072 Wrapper::Shared,
1073 Wrapper::Bare,
1074 ] {
1075 let out = render(shadow, wrapper, CompressionLevel::Off);
1076 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1077 }
1078 }
1079 }
1080
1081 #[test]
1084 fn rules_file_parses_version() {
1085 let content = format!(
1086 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1087 );
1088 let f = RulesFile::parse(&content);
1089 assert!(f.has_content());
1090 assert_eq!(f.version(), RULES_VERSION);
1091 assert!(f.is_current());
1092 assert!(f.prefix().contains("stuff before"));
1093 assert!(f.suffix().contains("stuff after"));
1094 }
1095
1096 #[test]
1097 fn rules_file_no_version_defaults_to_zero() {
1098 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1099 let f = RulesFile::parse(&content);
1100 assert!(f.has_content());
1101 assert_eq!(f.version(), 0);
1102 assert!(!f.is_current());
1103 }
1104
1105 #[test]
1106 fn rules_file_no_start_marker_no_content() {
1107 let f = RulesFile::parse("just user stuff");
1108 assert!(!f.has_content());
1109 assert_eq!(f.version(), 0);
1110 }
1111
1112 #[test]
1113 fn block_matches_render_true_for_fresh_render() {
1114 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1115 let content = format!("user before\n{fresh}\nuser after");
1116 let f = RulesFile::parse(&content);
1117 assert!(f.is_current(), "fresh render carries the current version");
1118 assert!(
1119 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
1120 "an unchanged block must compare equal to a fresh render"
1121 );
1122 }
1123
1124 #[test]
1125 fn block_matches_render_false_on_compression_change() {
1126 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1129 let f = RulesFile::parse(&content);
1130 assert!(f.is_current());
1131 assert!(
1132 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
1133 "a compression-level change must be detected as drift"
1134 );
1135 }
1136
1137 #[test]
1138 fn block_matches_render_false_on_shadow_change() {
1139 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1140 let f = RulesFile::parse(&content);
1141 assert!(
1142 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
1143 "a shadow-mode toggle must be detected as drift"
1144 );
1145 }
1146
1147 #[test]
1148 fn block_matches_render_false_without_block() {
1149 let f = RulesFile::parse("plain user content, no markers");
1150 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
1151 }
1152
1153 #[test]
1154 fn rules_file_merged_replaces_section() {
1155 let content =
1156 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1157 let f = RulesFile::parse(&content);
1158 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
1159 assert!(merged.contains("before"), "prefix preserved");
1160 assert!(merged.contains("after"), "suffix preserved");
1161 assert!(!merged.contains("old"), "old content replaced");
1162 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1163 }
1164
1165 #[test]
1166 fn rules_file_merged_appends_when_no_section() {
1167 let content = "user content";
1168 let f = RulesFile::parse(content);
1169 assert!(!f.has_content());
1170 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
1171 assert!(merged.contains("user content"));
1172 assert!(merged.contains(BULLETS));
1173 }
1174
1175 #[test]
1176 fn rules_file_without_section_strips_content() {
1177 let content =
1178 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1179 let f = RulesFile::parse(&content);
1180 let stripped = f.without_section();
1181 assert!(stripped.contains("header"));
1182 assert!(stripped.contains("footer"));
1183 assert!(!stripped.contains("body"));
1184 assert!(!stripped.contains(START_MARK));
1185 }
1186
1187 #[test]
1188 fn rules_file_without_section_noop_when_no_content() {
1189 let content = "just user text";
1190 let f = RulesFile::parse(content);
1191 assert_eq!(f.without_section(), content);
1192 }
1193
1194 #[test]
1195 fn bullets_lead_with_four_core_redirects() {
1196 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1198 let search = BULLETS
1199 .find("ctx_search")
1200 .expect("ctx_search mapping present");
1201 let shell = BULLETS
1202 .find("ctx_shell")
1203 .expect("ctx_shell mapping present");
1204 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1205 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1206 assert!(
1207 read < search && search < shell && shell < glob && glob < tree,
1208 "core redirects (read<search<shell<glob) must precede ctx_tree"
1209 );
1210 }
1211
1212 #[test]
1213 fn never_carries_self_correction() {
1214 assert!(
1216 NEVER.contains("SELF-CORRECT"),
1217 "NEVER must teach self-correction"
1218 );
1219 assert!(
1220 NEVER.contains("call"),
1221 "NEVER must spell out the corrective action"
1222 );
1223 }
1224
1225 #[test]
1226 fn critical_names_ctx_family() {
1227 assert!(
1228 CRITICAL.contains("ctx_*"),
1229 "CRITICAL must name the ctx_* family"
1230 );
1231 }
1232
1233 #[test]
1236 fn hook_covered_drops_unenforceable_mapping() {
1237 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1241 assert!(
1242 !out.contains("MANDATORY MAPPING") && !out.contains(NEVER) && !out.contains(CRITICAL),
1243 "HookCovered must not carry the native-tool prohibition"
1244 );
1245 assert!(
1246 out.contains(HOOK_COVERED_HEADER),
1247 "must state that hooks compress native tools"
1248 );
1249 assert!(
1250 out.contains("ctx_compose") && out.contains("ctx_semantic_search"),
1251 "must advertise the exclusive capabilities"
1252 );
1253 }
1254
1255 #[test]
1256 fn hook_covered_keeps_markers_version_and_recovery() {
1257 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1261 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1262 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1263 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1264 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1265 }
1266
1267 #[test]
1268 fn hook_covered_is_leaner_than_full() {
1269 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1270 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1271 assert!(
1272 covered.len() < full.len(),
1273 "HookCovered ({}) must be a strict reduction of FULL ({})",
1274 covered.len(),
1275 full.len()
1276 );
1277 }
1278
1279 #[test]
1280 fn hook_covered_shadow_collapses_to_minimal() {
1281 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off);
1284 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
1285 assert_eq!(covered_shadow, dedicated_shadow);
1286 }
1287
1288 #[test]
1289 fn hook_covered_wraps_compression_in_markers() {
1290 let out = render(false, Wrapper::HookCovered, CompressionLevel::Standard);
1293 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1294 assert!(out.contains("OUTPUT STYLE: dense"));
1295 }
1296}