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 = "Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. \
170 ctx_session=memory; full guide: LEAN-CTX.md";
171
172pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
181 Need full/exact? Read the shown file path with any tool (no MCP), or \
182 ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
183
184pub const RECOVER_COMPACT: &str = "RECOVER: compression is reversible — read the shown path \
191 (no MCP) or ctx_read(raw=true), never re-read line-by-line.";
192
193pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
195 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
196
197pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
199 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
200
201pub const SHADOW_MINIMAL: &str = "\
207lean-ctx shadow mode: native read/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
208File editing → native Edit/StrReplace (lean-ctx only handles reads); if denied, use ctx_patch.\n\
209Exclusive tools (no native trigger): ctx_compose (understand code, call first), ctx_search(action=symbol) (exact symbol), ctx_search(action=semantic) (by meaning), ctx_callgraph (callers), ctx_knowledge / ctx_session (memory).";
210
211pub const HOOK_COVERED_HEADER: &str = "\
219CRITICAL: ALWAYS prefer lean-ctx ctx_* tools over native equivalents — ctx_* tools \
220provide superior caching, compression, and session memory. Hooks compress native \
221Shell/Read/Grep as fallback, but direct ctx_* calls are the primary path.\n\
222ACTUALLY EMIT the ctx_* tool call (ctx_compose first) — describing a tool is not calling it.\n\
223WHY ctx_read > native Read: ctx_read picks the optimal mode (map/signatures/terse) \
224per file, caches for instant re-reads (~13 tokens), and compresses 26-92%. Native \
225Read through hook redirect is limited to verbatim pass-through (~5% savings).";
226
227pub const HOOK_COVERED_TOOLS: &str = "\
231MANDATORY MAPPING (always use ctx_* instead of native):\n\
232• Read/cat -> ctx_read(path, mode) — cached, 10 modes, re-reads ~13 tokens\n\
233• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic for definitions/meaning\n\
234• Shell/bash -> ctx_shell(command) — 95+ compression patterns\n\
235• ctx_compose — orient in code FIRST (bundles search + read + symbols) — call before editing/debugging\n\
236• ctx_callgraph — callers, callees, blast radius — use instead of manual file reading\n\
237• ctx_session / ctx_knowledge — persistent memory — record decisions & progress after milestones\n\
238• ctx_expand — recover full text from [Archived]/compressed output";
239
240pub const LITE_PROMPT: &str = "\
244OUTPUT STYLE: concise
245- Bullet points over paragraphs
246- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
247- 1-sentence explanations max, then code/action
248- No repeating what the user said";
249
250pub const STANDARD_PROMPT: &str = "\
252OUTPUT STYLE: dense
253- Each statement = one atomic fact line
254- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
255- Diff lines only (+/-/~), never repeat unchanged code
256- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
257- No narration, no filler, no hedging
258- BUDGET: ≤200 tokens per response unless code block required";
259
260pub const MAX_PROMPT: &str = "\
262OUTPUT STYLE: expert-terse
263- Telegraph format: subject-verb-object, drop articles/prepositions
264- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
265- Code blocks: untouched (never compress code syntax)
266- Each line: max 80 chars
267- Zero narration, zero filler
268- BUDGET: ≤100 tokens per non-code response";
269
270pub const RAW_PROMPT: &str = "\
273OUTPUT STYLE: raw-dense
274- Bullet points ONLY, zero prose, no intro, no outro, no greetings
275- Diff-style facts: +added, -removed, ~changed, !breaking
276- One fact per line, max 60 chars
277- Symbolic: → ∵ ∴ ⊕ ⊖ Δ ≈ ≠ ∈ ∅ ✓ ✗ (same as expert-terse)
278- Code blocks: untouched
279- BUDGET: ≤50 tokens per non-code response
280- NEVER explain what you did — show only the result";
281
282pub fn compression_text(level: CompressionLevel) -> &'static str {
284 match level {
285 CompressionLevel::Off => "",
286 CompressionLevel::Lite => LITE_PROMPT,
287 CompressionLevel::Standard => STANDARD_PROMPT,
288 CompressionLevel::Max => MAX_PROMPT,
289 CompressionLevel::Raw => RAW_PROMPT,
290 }
291}
292
293fn s(c: &str) -> String {
304 c.to_string()
305}
306
307fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
308 use super::rules_sections as rs;
309 let mut v = vec![
310 s(CRITICAL),
311 s(MUST_INVOKE),
312 s(BULLETS),
313 s(NEVER),
314 rs::intent_section(p),
315 s(AGENT_LOOP), rs::anti_section(p),
317 s(NAV_PARADOX), s(PARALLEL),
319 s(AUTO),
320 s(RECOVER),
321 s(CEP),
322 rs::litm_end_section(p),
323 ];
324 if let Some(fb) = rs::ctx_call_fallback(p) {
325 v.push(fb);
326 }
327 v
328}
329
330fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
331 use super::rules_sections as rs;
332 let mut v = vec![
333 s(CRITICAL),
334 s(MUST_INVOKE),
335 s(BULLETS),
336 s(NEVER),
337 rs::intent_section(p),
338 rs::anti_section(p),
339 s(PARALLEL),
340 s(AUTO),
341 s(RECOVER_COMPACT),
342 rs::litm_end_section(p),
343 ];
344 if let Some(fb) = rs::ctx_call_fallback(p) {
345 v.push(fb);
346 }
347 v
348}
349
350fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
351 use super::rules_sections as rs;
352 vec![rs::shadow_minimal_section(p)]
353}
354
355fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
356 use super::rules_sections as rs;
357 let mut v = vec![
358 s(HOOK_COVERED_HEADER),
359 rs::hook_covered_tools_section(p),
360 s(PARALLEL),
361 s(RECOVER_COMPACT),
362 ];
363 if let Some(fb) = rs::ctx_call_fallback(p) {
364 v.push(fb);
365 }
366 v
367}
368
369fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
370 use super::rules_sections as rs;
371 let mut v = vec![
372 s(CRITICAL),
373 s(BULLETS),
374 s(NEVER),
375 rs::intent_section(p),
376 rs::anti_section(p),
377 s(PARALLEL),
378 s(RECOVER_COMPACT),
379 ];
380 if let Some(fb) = rs::ctx_call_fallback(p) {
381 v.push(fb);
382 }
383 v
384}
385
386fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
387 use super::rules_sections as rs;
388 vec![rs::shadow_minimal_section(p)]
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum Wrapper {
395 Longform,
400
401 Dedicated,
406
407 Shared,
411
412 Bare,
415
416 HookCovered,
423}
424
425pub fn render(
436 shadow: bool,
437 wrapper: Wrapper,
438 level: CompressionLevel,
439 tool_profile: &super::tool_profiles::ToolProfile,
440) -> String {
441 use super::rules_sections as rs;
442
443 let sections: Vec<String> = match (wrapper, shadow) {
444 (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
445 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
446 full_shadow_sections(tool_profile)
447 }
448 (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
449 (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
450 (_, false) => compact_non_shadow_sections(tool_profile),
451 (_, true) => compact_shadow_sections(tool_profile),
452 };
453
454 let mut body: String = sections
457 .into_iter()
458 .filter(|s| !s.is_empty())
459 .collect::<Vec<_>>()
460 .join("\n\n");
461 let _ = rs::intent_section; let compression = compression_text(level);
468 if !compression.is_empty() {
469 body.push('\n');
470 if matches!(wrapper, Wrapper::Bare) {
471 body.push_str(compression);
472 } else {
473 body.push_str(COMPRESSION_BLOCK_START);
474 body.push('\n');
475 body.push_str(compression);
476 body.push('\n');
477 body.push_str(COMPRESSION_BLOCK_END);
478 }
479 }
480
481 if matches!(wrapper, Wrapper::Bare) {
482 return body;
483 }
484
485 let version_line = format!("<!-- version: {RULES_VERSION} -->");
486
487 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
488}
489
490pub fn render_hook_covered_bare(
497 shadow: bool,
498 level: CompressionLevel,
499 tool_profile: &super::tool_profiles::ToolProfile,
500) -> String {
501 if shadow {
502 return render(true, Wrapper::Bare, level, tool_profile);
503 }
504 let sections = hook_covered_non_shadow_sections(tool_profile);
505 let mut body: String = sections
506 .into_iter()
507 .filter(|s| !s.is_empty())
508 .collect::<Vec<_>>()
509 .join("\n\n");
510 let compression = compression_text(level);
511 if !compression.is_empty() {
512 body.push('\n');
513 body.push_str(compression);
514 }
515 body
516}
517pub use super::rules_validation::RulesFile;
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 fn tp() -> super::super::tool_profiles::ToolProfile {
525 super::super::tool_profiles::ToolProfile::Power
526 }
527
528 #[test]
531 fn bullets_uses_ctx_shell() {
532 assert!(BULLETS.contains("ctx_shell"));
533 assert!(!BULLETS.contains("lean-ctx -c"));
534 assert!(!BULLETS.contains("ctx_edit"));
535 }
536
537 #[test]
538 fn sections_not_empty() {
539 assert!(!BULLETS.is_empty());
540 assert!(!NEVER.is_empty());
541 assert!(!INTENT.is_empty());
542 assert!(!ANTI.is_empty());
543 assert!(!PARALLEL.is_empty());
544 assert!(!AUTO.is_empty());
545 assert!(!CEP.is_empty());
546 assert!(!LITM_END.is_empty());
547 assert!(!CRITICAL.is_empty());
548 }
549
550 #[test]
551 fn intent_contains_ctx_compose() {
552 assert!(INTENT.contains("ctx_compose"));
553 }
554
555 #[test]
556 fn anti_contains_do_not() {
557 assert!(ANTI.contains("do NOT"));
558 }
559
560 #[test]
561 fn parallel_contains_parallel() {
562 assert!(PARALLEL.contains("PARALLEL"));
563 }
564
565 #[test]
568 fn agent_loop_names_every_phase() {
569 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
570 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
571 }
572 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
573 }
574
575 #[test]
576 fn nav_paradox_steers_semantic_vs_graph() {
577 assert!(
578 NAV_PARADOX.contains("ctx_search(action=semantic)"),
579 "semantic route must use folded action (#509)"
580 );
581 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
582 assert!(
583 NAV_PARADOX.contains("≠"),
584 "must carry the reading≠understanding thesis"
585 );
586 }
587
588 #[test]
589 fn longform_carries_loop_and_paradox_injected_full_does_not() {
590 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
594 assert!(
595 long.contains("AGENT LOOP"),
596 "LONGFORM must carry AGENT_LOOP"
597 );
598 assert!(
599 long.contains("NAVIGATION PARADOX"),
600 "LONGFORM must carry NAV_PARADOX"
601 );
602 assert!(long.contains(CEP), "LONGFORM must carry CEP");
603
604 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
605 assert!(
606 !full.contains("AGENT LOOP (phase -> tool):"),
607 "injected FULL must not inline the multi-line AGENT_LOOP block"
608 );
609 assert!(
610 !full.contains("NAVIGATION PARADOX: reading"),
611 "injected FULL must not inline the multi-line NAV_PARADOX block"
612 );
613 assert!(
614 full.contains('≠'),
615 "INTENT must keep the reading≠understanding thesis in FULL"
616 );
617 for phase_tool in [
619 "ctx_compose",
620 "ctx_search(action=symbol)",
621 "ctx_search",
622 "ctx_callgraph",
623 ] {
624 assert!(
625 full.contains(phase_tool),
626 "FULL keeps the loop tools via INTENT: {phase_tool}"
627 );
628 }
629 }
630
631 #[test]
632 fn injected_profiles_stay_lean() {
633 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
637 eprintln!(
638 "rules footprint: dedicated={} chars (~{} tok), longform={} chars, bare={} chars",
639 full.len(),
640 full.len() / 4,
641 render(false, Wrapper::Longform, CompressionLevel::Off, &tp()).len(),
642 render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
643 );
644 assert!(
645 full.len() <= 2400,
646 "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
647 full.len(),
648 full.len() / 4
649 );
650 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
651 assert!(
652 long.len() > full.len(),
653 "Longform ({}) must carry more than the injected profile ({})",
654 long.len(),
655 full.len()
656 );
657 let compact = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
658 assert!(
659 compact.len() < full.len(),
660 "COMPACT/Bare ({}) must stay below the dedicated profile ({})",
661 compact.len(),
662 full.len()
663 );
664 }
665
666 #[test]
667 fn compact_profile_has_no_multiline_teaching_sections() {
668 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
671 assert!(
672 !out.contains("AGENT LOOP (phase -> tool):"),
673 "COMPACT must not inline the multi-line AGENT_LOOP block"
674 );
675 assert!(
676 !out.contains("NAVIGATION PARADOX: reading"),
677 "COMPACT must not inline the multi-line NAV_PARADOX block"
678 );
679 assert!(
680 out.contains('≠'),
681 "COMPACT keeps the reading≠understanding thesis via INTENT"
682 );
683 }
684
685 #[test]
686 fn shadow_omits_loop_and_paradox() {
687 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
690 let out = render(true, wrapper, CompressionLevel::Off, &tp());
691 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
692 assert!(
693 !out.contains("NAVIGATION PARADOX"),
694 "{wrapper:?} shadow drops paradox"
695 );
696 }
697 }
698
699 #[test]
700 fn recover_reaches_every_non_shadow_carrier() {
701 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
708 assert!(
709 long.contains(RECOVER),
710 "Longform must carry the verbose RECOVER verbatim"
711 );
712 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
713 let out = render(false, wrapper, CompressionLevel::Off, &tp());
714 assert!(
715 out.contains(RECOVER_COMPACT),
716 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
717 );
718 assert!(
719 !out.contains(RECOVER),
720 "{wrapper:?} must not inline the verbose RECOVER block"
721 );
722 }
723 for wrapper in [
724 Wrapper::Longform,
725 Wrapper::Dedicated,
726 Wrapper::Shared,
727 Wrapper::Bare,
728 ] {
729 assert!(
730 render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
731 "{wrapper:?} recovery line must keep the MCP-free path"
732 );
733 }
734 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
735 let out = render(true, wrapper, CompressionLevel::Off, &tp());
736 assert!(
737 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
738 "{wrapper:?} shadow drops all RECOVER guidance"
739 );
740 }
741 }
742
743 #[test]
746 fn dedicated_has_markers_and_version() {
747 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
748 assert!(out.contains(START_MARK));
749 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
750 assert!(out.contains(END_MARK));
751 assert!(out.contains(BULLETS));
752 assert!(out.contains(NEVER));
753 assert!(out.contains("CRITICAL"));
754 }
755
756 #[test]
757 fn dedicated_shadow_is_minimal() {
758 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
761 assert!(out.contains(START_MARK));
762 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
763 assert!(!out.contains(NEVER), "no NEVER in shadow");
764 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
765 assert!(
766 !out.contains("Tool selection by intent"),
767 "routing INTENT block is redundant under interception"
768 );
769 assert!(
770 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
771 "ANTI/PARALLEL routing guidance is dropped in shadow"
772 );
773 assert!(
774 out.contains("shadow mode") && out.contains("ctx_compose"),
775 "shadow keeps the exclusive-tool advert"
776 );
777 }
778
779 #[test]
780 fn shadow_is_smaller_than_non_shadow() {
781 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
783 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
784 assert!(
785 shadow.len() < full.len(),
786 "shadow ({}) must be smaller than non-shadow ({})",
787 shadow.len(),
788 full.len()
789 );
790 }
791
792 #[test]
793 fn dedicated_litm_structure() {
794 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
795 let lines: Vec<&str> = out.lines().collect();
796 let first_5 = lines[..5.min(lines.len())].join("\n");
797 assert!(
798 first_5.contains("CRITICAL") || first_5.contains("MUST"),
799 "LITM: MUST/CRITICAL instruction near start"
800 );
801 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
803 assert!(
804 tail.contains("PREFERENCE") || tail.contains("NEVER"),
805 "LITM: reinforcement near end, tail={tail:?}"
806 );
807 }
808
809 #[test]
810 fn dedicated_carries_weak_model_invoke_nudge() {
811 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
816 assert!(
817 dedicated.contains(MUST_INVOKE),
818 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
819 );
820 assert!(
821 !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
822 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
823 );
824 assert!(
825 !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
826 "Bare/instructions channel is capped separately and carries no copy"
827 );
828 }
829
830 #[test]
833 fn shared_has_markers_and_header() {
834 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
835 assert!(out.contains(START_MARK));
836 assert!(out.contains(END_MARK));
837 assert!(out.contains("MANDATORY MAPPING"));
838 assert!(out.contains(BULLETS));
839 }
840
841 #[test]
842 fn shared_shadow_omits_mapping() {
843 let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
844 assert!(out.contains(START_MARK));
845 assert!(
846 !out.contains("MANDATORY MAPPING"),
847 "shadow must not have header"
848 );
849 assert!(
850 !out.contains("MANDATORY MAPPING"),
851 "shadow must not contain BULLETS"
852 );
853 }
854
855 #[test]
858 fn bare_has_no_markers() {
859 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
860 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
861 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
862 assert!(!out.contains("<!-- version:"), "Bare must not have version");
863 assert!(out.contains(BULLETS));
864 assert!(out.contains(NEVER));
865 }
866
867 #[test]
868 fn bare_shadow_only_read_modes() {
869 let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
870 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
871 assert!(
872 !out.contains("MANDATORY MAPPING"),
873 "shadow Bare must not have BULLETS"
874 );
875 }
876
877 #[test]
880 fn render_includes_lite_prompt() {
881 let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
882 assert!(out.contains("OUTPUT STYLE: concise"));
883 assert!(out.contains("Bullet points"));
884 }
885
886 #[test]
887 fn render_includes_standard_prompt() {
888 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
889 assert!(out.contains("OUTPUT STYLE: dense"));
890 assert!(out.contains("atomic fact"));
891 }
892
893 #[test]
894 fn render_includes_max_prompt() {
895 let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
896 assert!(out.contains("OUTPUT STYLE: expert-terse"));
897 assert!(out.contains("Telegraph"));
898 }
899
900 #[test]
901 fn render_off_excludes_compression() {
902 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
903 assert!(!out.contains("OUTPUT STYLE:"));
904 }
905
906 #[test]
907 fn compression_text_matches_level() {
908 assert!(compression_text(CompressionLevel::Off).is_empty());
909 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
910 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
911 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
912 }
913
914 #[test]
917 fn carrier_wrappers_wrap_compression_in_markers() {
918 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
921 let out = render(false, wrapper, CompressionLevel::Standard, &tp());
922 assert!(
923 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
924 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
925 );
926 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
928 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
929 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
930 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
931 }
932 }
933
934 #[test]
935 fn bare_wrapper_emits_compression_without_markers() {
936 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
939 assert!(out.contains("OUTPUT STYLE: dense"));
940 assert!(!out.contains(COMPRESSION_BLOCK_START));
941 assert!(!out.contains(COMPRESSION_BLOCK_END));
942 }
943
944 #[test]
945 fn compression_off_emits_no_markers_in_any_wrapper() {
946 for wrapper in [
947 Wrapper::Longform,
948 Wrapper::Dedicated,
949 Wrapper::Shared,
950 Wrapper::Bare,
951 ] {
952 let out = render(false, wrapper, CompressionLevel::Off, &tp());
953 assert!(
954 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
955 "{wrapper:?}: Off must emit no compression markers"
956 );
957 }
958 }
959
960 #[test]
961 fn rendered_carrier_block_is_seen_as_carrying_compression() {
962 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
965 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
966 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
967 }
968
969 #[test]
972 fn all_wrappers_produce_output() {
973 for shadow in [false, true] {
974 for wrapper in [
975 Wrapper::Longform,
976 Wrapper::Dedicated,
977 Wrapper::Shared,
978 Wrapper::Bare,
979 ] {
980 let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
981 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
982 }
983 }
984 }
985
986 #[test]
989 fn rules_file_parses_version() {
990 let content = format!(
991 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
992 );
993 let f = RulesFile::parse(&content);
994 assert!(f.has_content());
995 assert_eq!(f.version(), RULES_VERSION);
996 assert!(f.is_current());
997 assert!(f.prefix().contains("stuff before"));
998 assert!(f.suffix().contains("stuff after"));
999 }
1000
1001 #[test]
1002 fn rules_file_no_version_defaults_to_zero() {
1003 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1004 let f = RulesFile::parse(&content);
1005 assert!(f.has_content());
1006 assert_eq!(f.version(), 0);
1007 assert!(!f.is_current());
1008 }
1009
1010 #[test]
1011 fn rules_file_no_start_marker_no_content() {
1012 let f = RulesFile::parse("just user stuff");
1013 assert!(!f.has_content());
1014 assert_eq!(f.version(), 0);
1015 }
1016
1017 #[test]
1018 fn block_matches_render_true_for_fresh_render() {
1019 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1020 let content = format!("user before\n{fresh}\nuser after");
1021 let f = RulesFile::parse(&content);
1022 assert!(f.is_current(), "fresh render carries the current version");
1023 assert!(
1024 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1025 "an unchanged block must compare equal to a fresh render"
1026 );
1027 }
1028
1029 #[test]
1030 fn block_matches_render_false_on_compression_change() {
1031 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1034 let f = RulesFile::parse(&content);
1035 assert!(f.is_current());
1036 assert!(
1037 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1038 "a compression-level change must be detected as drift"
1039 );
1040 }
1041
1042 #[test]
1043 fn block_matches_render_false_on_shadow_change() {
1044 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1045 let f = RulesFile::parse(&content);
1046 assert!(
1047 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1048 "a shadow-mode toggle must be detected as drift"
1049 );
1050 }
1051
1052 #[test]
1053 fn block_matches_render_false_without_block() {
1054 let f = RulesFile::parse("plain user content, no markers");
1055 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1056 }
1057
1058 #[test]
1059 fn rules_file_merged_replaces_section() {
1060 let content =
1061 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1062 let f = RulesFile::parse(&content);
1063 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1064 assert!(merged.contains("before"), "prefix preserved");
1065 assert!(merged.contains("after"), "suffix preserved");
1066 assert!(!merged.contains("old"), "old content replaced");
1067 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1068 }
1069
1070 #[test]
1071 fn rules_file_merged_appends_when_no_section() {
1072 let content = "user content";
1073 let f = RulesFile::parse(content);
1074 assert!(!f.has_content());
1075 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1076 assert!(merged.contains("user content"));
1077 assert!(merged.contains(BULLETS));
1078 }
1079
1080 #[test]
1081 fn rules_file_without_section_strips_content() {
1082 let content =
1083 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1084 let f = RulesFile::parse(&content);
1085 let stripped = f.without_section();
1086 assert!(stripped.contains("header"));
1087 assert!(stripped.contains("footer"));
1088 assert!(!stripped.contains("body"));
1089 assert!(!stripped.contains(START_MARK));
1090 }
1091
1092 #[test]
1093 fn rules_file_without_section_noop_when_no_content() {
1094 let content = "just user text";
1095 let f = RulesFile::parse(content);
1096 assert_eq!(f.without_section(), content);
1097 }
1098
1099 #[test]
1100 fn bullets_lead_with_four_core_redirects() {
1101 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1103 let search = BULLETS
1104 .find("ctx_search")
1105 .expect("ctx_search mapping present");
1106 let shell = BULLETS
1107 .find("ctx_shell")
1108 .expect("ctx_shell mapping present");
1109 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1110 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1111 assert!(
1112 read < search && search < shell && shell < glob && glob < tree,
1113 "core redirects (read<search<shell<glob) must precede ctx_tree"
1114 );
1115 }
1116
1117 #[test]
1118 fn never_carries_self_correction() {
1119 assert!(
1121 NEVER.contains("SELF-CORRECT"),
1122 "NEVER must teach self-correction"
1123 );
1124 assert!(
1125 NEVER.contains("call"),
1126 "NEVER must spell out the corrective action"
1127 );
1128 }
1129
1130 #[test]
1131 fn critical_names_ctx_family() {
1132 assert!(
1133 CRITICAL.contains("ctx_*"),
1134 "CRITICAL must name the ctx_* family"
1135 );
1136 }
1137
1138 #[test]
1141 fn hook_covered_carries_strict_mapping() {
1142 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1145 assert!(
1146 !out.contains(NEVER),
1147 "HookCovered uses its own strict header, not the NEVER constant"
1148 );
1149 assert!(
1150 out.contains(HOOK_COVERED_HEADER),
1151 "must carry the strict preference header"
1152 );
1153 assert!(
1154 out.contains("MANDATORY MAPPING")
1155 && out.contains("ctx_read")
1156 && out.contains("ctx_search"),
1157 "must carry the full mandatory mapping"
1158 );
1159 assert!(
1160 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1161 "must advertise the search capabilities"
1162 );
1163 }
1164
1165 #[test]
1166 fn hook_covered_keeps_markers_version_and_recovery() {
1167 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1171 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1172 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1173 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1174 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1175 }
1176
1177 #[test]
1178 fn hook_covered_is_leaner_than_full() {
1179 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1180 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1181 assert!(
1182 covered.len() < full.len(),
1183 "HookCovered ({}) must be a strict reduction of FULL ({})",
1184 covered.len(),
1185 full.len()
1186 );
1187 }
1188
1189 #[test]
1190 fn hook_covered_shadow_collapses_to_minimal() {
1191 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1194 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1195 assert_eq!(covered_shadow, dedicated_shadow);
1196 }
1197
1198 #[test]
1199 fn hook_covered_wraps_compression_in_markers() {
1200 let out = render(
1201 false,
1202 Wrapper::HookCovered,
1203 CompressionLevel::Standard,
1204 &tp(),
1205 );
1206 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1207 assert!(out.contains("OUTPUT STYLE: dense"));
1208 }
1209
1210 #[test]
1213 fn rules_only_mention_enabled_tools() {
1214 use super::super::rules_sections;
1215 use super::super::tool_profiles::ToolProfile;
1216 let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1217 for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1218 let mut sections = vec![
1219 rules_sections::intent_section(&profile),
1220 rules_sections::hook_covered_tools_section(&profile),
1221 rules_sections::shadow_minimal_section(&profile),
1222 rules_sections::anti_section(&profile),
1223 rules_sections::litm_end_section(&profile),
1224 ];
1225 if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1226 sections.push(fb);
1227 }
1228 for tool in &exclusive_tools {
1229 if !profile.is_tool_enabled(tool) {
1230 for section in §ions {
1231 assert!(
1232 !section.contains(tool),
1233 "profile {:?} dynamic section must not mention disabled tool {tool}",
1234 profile.as_str()
1235 );
1236 }
1237 }
1238 }
1239 }
1240 }
1241
1242 #[test]
1243 fn minimal_rules_shorter_than_standard() {
1244 use super::super::tool_profiles::ToolProfile;
1245 let min = render(
1246 false,
1247 Wrapper::Dedicated,
1248 CompressionLevel::Off,
1249 &ToolProfile::Minimal,
1250 );
1251 let std = render(
1252 false,
1253 Wrapper::Dedicated,
1254 CompressionLevel::Off,
1255 &ToolProfile::Standard,
1256 );
1257 assert!(
1258 min.len() < std.len(),
1259 "minimal ({}) must be shorter than standard ({})",
1260 min.len(),
1261 std.len()
1262 );
1263 }
1264
1265 #[test]
1266 fn power_profile_output_identical_to_default() {
1267 use super::super::tool_profiles::ToolProfile;
1268 let power = render(
1269 false,
1270 Wrapper::Dedicated,
1271 CompressionLevel::Off,
1272 &ToolProfile::Power,
1273 );
1274 assert!(
1275 power.contains("ctx_compose"),
1276 "Power must include all tools"
1277 );
1278 assert!(
1279 power.contains("ctx_callgraph"),
1280 "Power must include all tools"
1281 );
1282 assert!(
1283 power.contains("ctx_session"),
1284 "Power must include all tools"
1285 );
1286 }
1287
1288 #[test]
1289 fn render_is_deterministic_across_profiles() {
1290 use super::super::tool_profiles::ToolProfile;
1291 for profile in [
1292 ToolProfile::Minimal,
1293 ToolProfile::Standard,
1294 ToolProfile::Power,
1295 ] {
1296 let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1297 let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1298 assert_eq!(
1299 a,
1300 b,
1301 "render must be deterministic for {:?}",
1302 profile.as_str()
1303 );
1304 }
1305 }
1306
1307 #[test]
1308 fn minimal_has_ctx_call_fallback() {
1309 use super::super::tool_profiles::ToolProfile;
1310 let rules = render(
1311 false,
1312 Wrapper::Dedicated,
1313 CompressionLevel::Off,
1314 &ToolProfile::Minimal,
1315 );
1316 assert!(
1317 rules.contains("ctx_call"),
1318 "minimal profile must include ctx_call gateway hint"
1319 );
1320 }
1321
1322 #[test]
1323 fn power_has_no_ctx_call_fallback() {
1324 use super::super::tool_profiles::ToolProfile;
1325 let rules = render(
1326 false,
1327 Wrapper::Dedicated,
1328 CompressionLevel::Off,
1329 &ToolProfile::Power,
1330 );
1331 assert!(
1332 !rules.contains("ctx_call(tool="),
1333 "power profile must not include ctx_call gateway hint"
1334 );
1335 }
1336}