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 = 6;
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_symbol; pattern -> ctx_search; by meaning -> ctx_semantic_search\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_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_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_semantic_search (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_symbol (exact symbol), ctx_callgraph (callers), ctx_semantic_search (by meaning), ctx_knowledge / ctx_session (memory).";
214
215pub const HOOK_COVERED_HEADER: &str = "\
223lean-ctx hooks cover this session: native Shell, Read and Grep are compressed \
224transparently (PreToolUse rewrite/redirect) — using them is fine for single known files.\n\
225CRITICAL: ALWAYS call ctx_compose FIRST to orient before scattering individual \
226Read/Grep calls. ACTUALLY EMIT the call — describing it is not calling it.";
227
228pub const HOOK_COVERED_TOOLS: &str = "\
232MANDATORY ctx_* tools (no native equivalent):\n\
233• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
234• ctx_search(action=symbol|semantic) — exact definitions or search by meaning, not pattern\n\
235• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
236• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
237• ctx_expand — recover full text from [Archived]/compressed output";
238
239pub const LITE_PROMPT: &str = "\
243OUTPUT STYLE: concise
244- Bullet points over paragraphs
245- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
246- 1-sentence explanations max, then code/action
247- No repeating what the user said";
248
249pub const STANDARD_PROMPT: &str = "\
251OUTPUT STYLE: dense
252- Each statement = one atomic fact line
253- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
254- Diff lines only (+/-/~), never repeat unchanged code
255- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
256- No narration, no filler, no hedging
257- BUDGET: ≤200 tokens per response unless code block required";
258
259pub const MAX_PROMPT: &str = "\
261OUTPUT STYLE: expert-terse
262- Telegraph format: subject-verb-object, drop articles/prepositions
263- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
264- Code blocks: untouched (never compress code syntax)
265- Each line: max 80 chars
266- Zero narration, zero filler
267- BUDGET: ≤100 tokens per non-code response";
268
269pub fn compression_text(level: CompressionLevel) -> &'static str {
271 match level {
272 CompressionLevel::Off => "",
273 CompressionLevel::Lite => LITE_PROMPT,
274 CompressionLevel::Standard => STANDARD_PROMPT,
275 CompressionLevel::Max => MAX_PROMPT,
276 }
277}
278
279const LONGFORM_NON_SHADOW: &[&str] = &[
284 CRITICAL,
285 MUST_INVOKE,
286 BULLETS,
287 NEVER,
288 INTENT,
289 AGENT_LOOP,
290 ANTI,
291 NAV_PARADOX,
292 PARALLEL,
293 AUTO,
294 RECOVER,
295 CEP,
296 INTELLIGENCE,
297 LITM_END,
298];
299
300const FULL_NON_SHADOW: &[&str] = &[
304 CRITICAL,
305 MUST_INVOKE,
306 BULLETS,
307 NEVER,
308 INTENT,
309 ANTI,
310 PARALLEL,
311 AUTO,
312 RECOVER_COMPACT,
313 INTELLIGENCE,
314 LITM_END,
315];
316
317const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
322
323const HOOK_COVERED_NON_SHADOW: &[&str] = &[
331 HOOK_COVERED_HEADER,
332 HOOK_COVERED_TOOLS,
333 PARALLEL,
334 RECOVER_COMPACT,
335 INTELLIGENCE,
336];
337
338const COMPACT_NON_SHADOW: &[&str] = &[
339 CRITICAL,
340 BULLETS,
341 NEVER,
342 INTENT,
343 ANTI,
344 PARALLEL,
345 RECOVER_COMPACT,
346];
347
348const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum Wrapper {
354 Longform,
359
360 Dedicated,
365
366 Shared,
370
371 Bare,
374
375 HookCovered,
382}
383
384pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
392 let profile = match (wrapper, shadow) {
393 (Wrapper::Longform, false) => LONGFORM_NON_SHADOW,
394 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => FULL_SHADOW,
397 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
398 (Wrapper::HookCovered, false) => HOOK_COVERED_NON_SHADOW,
399 (_, false) => COMPACT_NON_SHADOW,
400 (_, true) => COMPACT_SHADOW,
401 };
402
403 let mut body = profile.join("\n\n");
404
405 let compression = compression_text(level);
410 if !compression.is_empty() {
411 body.push('\n');
412 if matches!(wrapper, Wrapper::Bare) {
413 body.push_str(compression);
414 } else {
415 body.push_str(COMPRESSION_BLOCK_START);
416 body.push('\n');
417 body.push_str(compression);
418 body.push('\n');
419 body.push_str(COMPRESSION_BLOCK_END);
420 }
421 }
422
423 if matches!(wrapper, Wrapper::Bare) {
424 return body;
425 }
426
427 let version_line = format!("<!-- version: {RULES_VERSION} -->");
428
429 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
430}
431
432pub fn render_hook_covered_bare(shadow: bool, level: CompressionLevel) -> String {
439 if shadow {
440 return render(true, Wrapper::Bare, level);
441 }
442 let mut body = HOOK_COVERED_NON_SHADOW.join("\n\n");
443 let compression = compression_text(level);
444 if !compression.is_empty() {
445 body.push('\n');
446 body.push_str(compression);
447 }
448 body
449}
450#[derive(Debug)]
461pub struct RulesFile<'a> {
462 content: &'a str,
463 start: Option<usize>,
465 end: Option<usize>,
467 version: usize,
469}
470
471fn parse_version_number(s: &str) -> Option<usize> {
474 let prefix = "<!-- version: ";
475 let vs = s.find(prefix)?;
476 let num_start = vs + prefix.len();
477 let end = s[num_start..].find(" -->")?;
478 s[num_start..num_start + end].parse().ok()
479}
480
481impl<'a> RulesFile<'a> {
482 pub fn parse(content: &'a str) -> Self {
488 let start = content.find(START_MARK);
489 let version = start
490 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
491 .unwrap_or(0);
492 let end = content.find(END_MARK);
493 RulesFile {
494 content,
495 start,
496 end,
497 version,
498 }
499 }
500
501 pub fn has_content(&self) -> bool {
503 self.start.is_some()
504 }
505
506 pub fn version(&self) -> usize {
509 self.version
510 }
511
512 pub fn is_current(&self) -> bool {
514 self.version >= RULES_VERSION
515 }
516
517 pub fn prefix(&self) -> &'a str {
520 self.start.map_or("", |s| self.content[..s].trim())
521 }
522
523 pub fn suffix(&self) -> &'a str {
526 self.end
527 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
528 }
529
530 fn block(&self) -> Option<&'a str> {
533 match (self.start, self.end) {
534 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
535 _ => None,
536 }
537 }
538
539 pub fn block_matches_render(
549 &self,
550 shadow: bool,
551 wrapper: Wrapper,
552 level: CompressionLevel,
553 ) -> bool {
554 match self.block() {
555 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
556 None => false,
557 }
558 }
559
560 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
566 let fresh = render(shadow, wrapper, level);
567 if self.start.is_some() {
568 let before = self.prefix();
569 let after = self.suffix();
570 let mut out = String::new();
571 if !before.is_empty() {
572 out.push_str(before);
573 out.push('\n');
574 out.push('\n');
575 }
576 out.push_str(&fresh);
577 if !after.is_empty() {
578 out.push('\n');
579 out.push('\n');
580 out.push_str(after);
581 }
582 if !out.ends_with('\n') {
583 out.push('\n');
584 }
585 out
586 } else {
587 let trimmed = self.content.trim_end();
589 let mut out = trimmed.to_string();
590 if !out.is_empty() {
591 out.push('\n');
592 out.push('\n');
593 }
594 out.push_str(&fresh);
595 out
596 }
597 }
598
599 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
601 render(shadow, wrapper, level)
602 }
603
604 pub fn without_section(&self) -> String {
608 if let Some(start_pos) = self.start {
609 let before = self.content[..start_pos].trim();
610 let after = self.suffix();
611 let mut out = String::new();
612 if !before.is_empty() {
613 out.push_str(before);
614 out.push('\n');
615 }
616 if !after.is_empty() {
617 out.push('\n');
618 out.push_str(after);
619 }
620 out
621 } else {
622 self.content.to_string()
623 }
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
634 fn bullets_uses_ctx_shell() {
635 assert!(BULLETS.contains("ctx_shell"));
636 assert!(!BULLETS.contains("lean-ctx -c"));
637 assert!(!BULLETS.contains("ctx_edit"));
638 }
639
640 #[test]
641 fn sections_not_empty() {
642 assert!(!BULLETS.is_empty());
643 assert!(!NEVER.is_empty());
644 assert!(!INTENT.is_empty());
645 assert!(!ANTI.is_empty());
646 assert!(!PARALLEL.is_empty());
647 assert!(!AUTO.is_empty());
648 assert!(!CEP.is_empty());
649 assert!(!INTELLIGENCE.is_empty());
650 assert!(!LITM_END.is_empty());
651 assert!(!CRITICAL.is_empty());
652 }
653
654 #[test]
655 fn intent_contains_ctx_compose() {
656 assert!(INTENT.contains("ctx_compose"));
657 }
658
659 #[test]
660 fn anti_contains_do_not() {
661 assert!(ANTI.contains("do NOT"));
662 }
663
664 #[test]
665 fn parallel_contains_parallel() {
666 assert!(PARALLEL.contains("PARALLEL"));
667 }
668
669 #[test]
672 fn agent_loop_names_every_phase() {
673 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
674 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
675 }
676 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
677 }
678
679 #[test]
680 fn nav_paradox_steers_semantic_vs_graph() {
681 assert!(
682 NAV_PARADOX.contains("ctx_semantic_search"),
683 "semantic route"
684 );
685 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
686 assert!(
687 NAV_PARADOX.contains("≠"),
688 "must carry the reading≠understanding thesis"
689 );
690 }
691
692 #[test]
693 fn longform_carries_loop_and_paradox_injected_full_does_not() {
694 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
698 assert!(
699 long.contains("AGENT LOOP"),
700 "LONGFORM must carry AGENT_LOOP"
701 );
702 assert!(
703 long.contains("NAVIGATION PARADOX"),
704 "LONGFORM must carry NAV_PARADOX"
705 );
706 assert!(long.contains(CEP), "LONGFORM must carry CEP");
707
708 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
709 assert!(
710 !full.contains("AGENT LOOP (phase -> tool):"),
711 "injected FULL must not inline the multi-line AGENT_LOOP block"
712 );
713 assert!(
714 !full.contains("NAVIGATION PARADOX: reading"),
715 "injected FULL must not inline the multi-line NAV_PARADOX block"
716 );
717 assert!(
718 full.contains('≠'),
719 "INTENT must keep the reading≠understanding thesis in FULL"
720 );
721 for phase_tool in ["ctx_compose", "ctx_symbol", "ctx_search", "ctx_callgraph"] {
722 assert!(
723 full.contains(phase_tool),
724 "FULL keeps the loop tools via INTENT: {phase_tool}"
725 );
726 }
727 }
728
729 #[test]
730 fn injected_profiles_stay_lean() {
731 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
735 eprintln!(
736 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
737 full.len(),
738 full.len() / 4,
739 render(false, Wrapper::Longform, CompressionLevel::Off).len(),
740 render(false, Wrapper::Bare, CompressionLevel::Off).len(),
741 );
742 assert!(
743 full.len() <= 2100,
744 "injected dedicated rules must stay ≤2100 chars (~525 tok), got {} chars (~{} tok)",
745 full.len(),
746 full.len() / 4
747 );
748 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
749 assert!(
750 long.len() > full.len(),
751 "Longform ({}) must carry more than the injected profile ({})",
752 long.len(),
753 full.len()
754 );
755 let compact = render(false, Wrapper::Bare, CompressionLevel::Off);
756 assert!(
757 compact.len() < full.len(),
758 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
759 compact.len(),
760 full.len()
761 );
762 }
763
764 #[test]
765 fn compact_profile_has_no_multiline_teaching_sections() {
766 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
769 assert!(
770 !out.contains("AGENT LOOP (phase -> tool):"),
771 "COMPACT must not inline the multi-line AGENT_LOOP block"
772 );
773 assert!(
774 !out.contains("NAVIGATION PARADOX: reading"),
775 "COMPACT must not inline the multi-line NAV_PARADOX block"
776 );
777 assert!(
778 out.contains('≠'),
779 "COMPACT keeps the reading≠understanding thesis via INTENT"
780 );
781 }
782
783 #[test]
784 fn shadow_omits_loop_and_paradox() {
785 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
788 let out = render(true, wrapper, CompressionLevel::Off);
789 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
790 assert!(
791 !out.contains("NAVIGATION PARADOX"),
792 "{wrapper:?} shadow drops paradox"
793 );
794 }
795 }
796
797 #[test]
798 fn recover_reaches_every_non_shadow_carrier() {
799 let long = render(false, Wrapper::Longform, CompressionLevel::Off);
806 assert!(
807 long.contains(RECOVER),
808 "Longform must carry the verbose RECOVER verbatim"
809 );
810 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
811 let out = render(false, wrapper, CompressionLevel::Off);
812 assert!(
813 out.contains(RECOVER_COMPACT),
814 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
815 );
816 assert!(
817 !out.contains(RECOVER),
818 "{wrapper:?} must not inline the verbose RECOVER block"
819 );
820 }
821 for wrapper in [
822 Wrapper::Longform,
823 Wrapper::Dedicated,
824 Wrapper::Shared,
825 Wrapper::Bare,
826 ] {
827 assert!(
828 render(false, wrapper, CompressionLevel::Off).contains("(no MCP)"),
829 "{wrapper:?} recovery line must keep the MCP-free path"
830 );
831 }
832 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
834 let out = render(true, wrapper, CompressionLevel::Off);
835 assert!(
836 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
837 "{wrapper:?} shadow drops all RECOVER guidance"
838 );
839 }
840 }
841
842 #[test]
845 fn dedicated_has_markers_and_version() {
846 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
847 assert!(out.contains(START_MARK));
848 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
849 assert!(out.contains(END_MARK));
850 assert!(out.contains(BULLETS));
851 assert!(out.contains(NEVER));
852 assert!(out.contains("CRITICAL"));
853 }
854
855 #[test]
856 fn dedicated_shadow_is_minimal() {
857 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
861 assert!(out.contains(START_MARK));
862 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
863 assert!(!out.contains(NEVER), "no NEVER in shadow");
864 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
865 assert!(
866 !out.contains("Tool selection by intent"),
867 "routing INTENT block is redundant under interception"
868 );
869 assert!(
870 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
871 "ANTI/PARALLEL routing guidance is dropped in shadow"
872 );
873 assert!(
874 out.contains("shadow mode") && out.contains("ctx_compose"),
875 "shadow keeps the exclusive-tool advert"
876 );
877 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
878 }
879
880 #[test]
881 fn shadow_is_smaller_than_non_shadow() {
882 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
884 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
885 assert!(
886 shadow.len() < full.len(),
887 "shadow ({}) must be smaller than non-shadow ({})",
888 shadow.len(),
889 full.len()
890 );
891 }
892
893 #[test]
894 fn dedicated_litm_structure() {
895 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
896 let lines: Vec<&str> = out.lines().collect();
897 let first_5 = lines[..5.min(lines.len())].join("\n");
898 assert!(
899 first_5.contains("CRITICAL") || first_5.contains("MUST"),
900 "LITM: MUST/CRITICAL instruction near start"
901 );
902 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
904 assert!(
905 tail.contains("PREFERENCE") || tail.contains("NEVER"),
906 "LITM: reinforcement near end, tail={tail:?}"
907 );
908 }
909
910 #[test]
911 fn dedicated_carries_weak_model_invoke_nudge() {
912 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off);
917 assert!(
918 dedicated.contains(MUST_INVOKE),
919 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
920 );
921 assert!(
922 !render(true, Wrapper::Dedicated, CompressionLevel::Off).contains(MUST_INVOKE),
923 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
924 );
925 assert!(
926 !render(false, Wrapper::Bare, CompressionLevel::Off).contains(MUST_INVOKE),
927 "Bare/instructions channel is capped separately and carries no copy"
928 );
929 }
930
931 #[test]
934 fn shared_has_markers_and_header() {
935 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
936 assert!(out.contains(START_MARK));
937 assert!(out.contains(END_MARK));
938 assert!(out.contains("MANDATORY MAPPING"));
939 assert!(out.contains(BULLETS));
940 }
941
942 #[test]
943 fn shared_shadow_omits_mapping() {
944 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
945 assert!(out.contains(START_MARK));
946 assert!(
947 !out.contains("MANDATORY MAPPING"),
948 "shadow must not have header"
949 );
950 assert!(
951 !out.contains("MANDATORY MAPPING"),
952 "shadow must not contain BULLETS"
953 );
954 }
955
956 #[test]
959 fn bare_has_no_markers() {
960 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
961 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
962 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
963 assert!(!out.contains("<!-- version:"), "Bare must not have version");
964 assert!(out.contains(BULLETS));
965 assert!(out.contains(NEVER));
966 }
967
968 #[test]
969 fn bare_shadow_only_read_modes() {
970 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
971 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
972 assert!(
973 !out.contains("MANDATORY MAPPING"),
974 "shadow Bare must not have BULLETS"
975 );
976 }
977
978 #[test]
981 fn render_includes_lite_prompt() {
982 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
983 assert!(out.contains("OUTPUT STYLE: concise"));
984 assert!(out.contains("Bullet points"));
985 }
986
987 #[test]
988 fn render_includes_standard_prompt() {
989 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
990 assert!(out.contains("OUTPUT STYLE: dense"));
991 assert!(out.contains("atomic fact"));
992 }
993
994 #[test]
995 fn render_includes_max_prompt() {
996 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
997 assert!(out.contains("OUTPUT STYLE: expert-terse"));
998 assert!(out.contains("Telegraph"));
999 }
1000
1001 #[test]
1002 fn render_off_excludes_compression() {
1003 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
1004 assert!(!out.contains("OUTPUT STYLE:"));
1005 }
1006
1007 #[test]
1008 fn compression_text_matches_level() {
1009 assert!(compression_text(CompressionLevel::Off).is_empty());
1010 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
1011 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
1012 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
1013 }
1014
1015 #[test]
1018 fn carrier_wrappers_wrap_compression_in_markers() {
1019 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1022 let out = render(false, wrapper, CompressionLevel::Standard);
1023 assert!(
1024 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1025 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1026 );
1027 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1029 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1030 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1031 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1032 }
1033 }
1034
1035 #[test]
1036 fn bare_wrapper_emits_compression_without_markers() {
1037 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
1040 assert!(out.contains("OUTPUT STYLE: dense"));
1041 assert!(!out.contains(COMPRESSION_BLOCK_START));
1042 assert!(!out.contains(COMPRESSION_BLOCK_END));
1043 }
1044
1045 #[test]
1046 fn compression_off_emits_no_markers_in_any_wrapper() {
1047 for wrapper in [
1048 Wrapper::Longform,
1049 Wrapper::Dedicated,
1050 Wrapper::Shared,
1051 Wrapper::Bare,
1052 ] {
1053 let out = render(false, wrapper, CompressionLevel::Off);
1054 assert!(
1055 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1056 "{wrapper:?}: Off must emit no compression markers"
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1063 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1066 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1067 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1068 }
1069
1070 #[test]
1073 fn all_wrappers_produce_output() {
1074 for shadow in [false, true] {
1075 for wrapper in [
1076 Wrapper::Longform,
1077 Wrapper::Dedicated,
1078 Wrapper::Shared,
1079 Wrapper::Bare,
1080 ] {
1081 let out = render(shadow, wrapper, CompressionLevel::Off);
1082 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1083 }
1084 }
1085 }
1086
1087 #[test]
1090 fn rules_file_parses_version() {
1091 let content = format!(
1092 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1093 );
1094 let f = RulesFile::parse(&content);
1095 assert!(f.has_content());
1096 assert_eq!(f.version(), RULES_VERSION);
1097 assert!(f.is_current());
1098 assert!(f.prefix().contains("stuff before"));
1099 assert!(f.suffix().contains("stuff after"));
1100 }
1101
1102 #[test]
1103 fn rules_file_no_version_defaults_to_zero() {
1104 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1105 let f = RulesFile::parse(&content);
1106 assert!(f.has_content());
1107 assert_eq!(f.version(), 0);
1108 assert!(!f.is_current());
1109 }
1110
1111 #[test]
1112 fn rules_file_no_start_marker_no_content() {
1113 let f = RulesFile::parse("just user stuff");
1114 assert!(!f.has_content());
1115 assert_eq!(f.version(), 0);
1116 }
1117
1118 #[test]
1119 fn block_matches_render_true_for_fresh_render() {
1120 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1121 let content = format!("user before\n{fresh}\nuser after");
1122 let f = RulesFile::parse(&content);
1123 assert!(f.is_current(), "fresh render carries the current version");
1124 assert!(
1125 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
1126 "an unchanged block must compare equal to a fresh render"
1127 );
1128 }
1129
1130 #[test]
1131 fn block_matches_render_false_on_compression_change() {
1132 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1135 let f = RulesFile::parse(&content);
1136 assert!(f.is_current());
1137 assert!(
1138 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
1139 "a compression-level change must be detected as drift"
1140 );
1141 }
1142
1143 #[test]
1144 fn block_matches_render_false_on_shadow_change() {
1145 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
1146 let f = RulesFile::parse(&content);
1147 assert!(
1148 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
1149 "a shadow-mode toggle must be detected as drift"
1150 );
1151 }
1152
1153 #[test]
1154 fn block_matches_render_false_without_block() {
1155 let f = RulesFile::parse("plain user content, no markers");
1156 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
1157 }
1158
1159 #[test]
1160 fn rules_file_merged_replaces_section() {
1161 let content =
1162 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1163 let f = RulesFile::parse(&content);
1164 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
1165 assert!(merged.contains("before"), "prefix preserved");
1166 assert!(merged.contains("after"), "suffix preserved");
1167 assert!(!merged.contains("old"), "old content replaced");
1168 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1169 }
1170
1171 #[test]
1172 fn rules_file_merged_appends_when_no_section() {
1173 let content = "user content";
1174 let f = RulesFile::parse(content);
1175 assert!(!f.has_content());
1176 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
1177 assert!(merged.contains("user content"));
1178 assert!(merged.contains(BULLETS));
1179 }
1180
1181 #[test]
1182 fn rules_file_without_section_strips_content() {
1183 let content =
1184 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1185 let f = RulesFile::parse(&content);
1186 let stripped = f.without_section();
1187 assert!(stripped.contains("header"));
1188 assert!(stripped.contains("footer"));
1189 assert!(!stripped.contains("body"));
1190 assert!(!stripped.contains(START_MARK));
1191 }
1192
1193 #[test]
1194 fn rules_file_without_section_noop_when_no_content() {
1195 let content = "just user text";
1196 let f = RulesFile::parse(content);
1197 assert_eq!(f.without_section(), content);
1198 }
1199
1200 #[test]
1201 fn bullets_lead_with_four_core_redirects() {
1202 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1204 let search = BULLETS
1205 .find("ctx_search")
1206 .expect("ctx_search mapping present");
1207 let shell = BULLETS
1208 .find("ctx_shell")
1209 .expect("ctx_shell mapping present");
1210 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1211 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1212 assert!(
1213 read < search && search < shell && shell < glob && glob < tree,
1214 "core redirects (read<search<shell<glob) must precede ctx_tree"
1215 );
1216 }
1217
1218 #[test]
1219 fn never_carries_self_correction() {
1220 assert!(
1222 NEVER.contains("SELF-CORRECT"),
1223 "NEVER must teach self-correction"
1224 );
1225 assert!(
1226 NEVER.contains("call"),
1227 "NEVER must spell out the corrective action"
1228 );
1229 }
1230
1231 #[test]
1232 fn critical_names_ctx_family() {
1233 assert!(
1234 CRITICAL.contains("ctx_*"),
1235 "CRITICAL must name the ctx_* family"
1236 );
1237 }
1238
1239 #[test]
1242 fn hook_covered_drops_unenforceable_mapping() {
1243 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1247 assert!(
1248 !out.contains("MANDATORY MAPPING") && !out.contains(NEVER) && !out.contains(CRITICAL),
1249 "HookCovered must not carry the native-tool prohibition"
1250 );
1251 assert!(
1252 out.contains(HOOK_COVERED_HEADER),
1253 "must state that hooks compress native tools"
1254 );
1255 assert!(
1256 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1257 "must advertise the exclusive capabilities"
1258 );
1259 }
1260
1261 #[test]
1262 fn hook_covered_keeps_markers_version_and_recovery() {
1263 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1267 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1268 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1269 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1270 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1271 }
1272
1273 #[test]
1274 fn hook_covered_is_leaner_than_full() {
1275 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off);
1276 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
1277 assert!(
1278 covered.len() < full.len(),
1279 "HookCovered ({}) must be a strict reduction of FULL ({})",
1280 covered.len(),
1281 full.len()
1282 );
1283 }
1284
1285 #[test]
1286 fn hook_covered_shadow_collapses_to_minimal() {
1287 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off);
1290 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
1291 assert_eq!(covered_shadow, dedicated_shadow);
1292 }
1293
1294 #[test]
1295 fn hook_covered_wraps_compression_in_markers() {
1296 let out = render(false, Wrapper::HookCovered, CompressionLevel::Standard);
1299 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1300 assert!(out.contains("OUTPUT STYLE: dense"));
1301 }
1302}