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 SOLUTION_BLOCK_START: &str = "<!-- lean-ctx-solution -->";
70
71pub const SOLUTION_BLOCK_END: &str = "<!-- /lean-ctx-solution -->";
73
74pub fn enabled_solution_rules_block() -> String {
77 let solution = crate::core::config::Config::load().solution;
80 if !solution.enabled || !solution.inject_in_instructions {
81 return String::new();
82 }
83
84 format!(
85 "{SOLUTION_BLOCK_START}\n\
86SOLUTION EFFICIENCY: stop at first level that applies:\n\
87skip (YAGNI) → reuse codebase → stdlib → native platform → installed dep → one-line → minimum code.\n\
88Never skip: validation, security, error handling.\n\
89{SOLUTION_BLOCK_END}"
90 )
91}
92
93pub const RULES_VERSION: usize = 13;
124
125pub const GATEWAY_IDENTITY: &str = "LeanCTX (lean-ctx) — Context Gateway for AI agents.";
127
128pub const CRITICAL: &str = "lean-ctx routing — the host's built-in tools map to:";
140
141pub const MUST_INVOKE: &str = "Call the tool; describing a call does not run it.";
152
153pub const BULLETS: &str = "\
155• Read/cat -> ctx_read(path, mode)\n\
156• Grep -> ctx_search(pattern, path)\n\
157• Shell/bash -> ctx_shell(command)\n\
158• Glob/find -> ctx_glob(pattern)\n\
159• ls/find -> ctx_tree(path, depth)";
160
161pub const NEVER: &str = "This covers built-in file/search/shell tools only; \
175 other MCP servers keep their own jobs.";
176
177pub const INTENT: &str = "\
182By task:\n\
183• Unfamiliar code -> ctx_compose (search + read + symbols in one call)\n\
184• Read -> ctx_read(mode=signatures|map|full); edit -> ctx_patch\n\
185• Symbol -> ctx_search(action=symbol); meaning -> ctx_search(action=semantic)\n\
186• Callers/impact -> ctx_callgraph; memory -> ctx_session / ctx_knowledge";
187
188pub const ANTI: &str = "\
190Anti-patterns — do NOT:\n\
191• Chain ctx_search -> ctx_read -> ctx_search(action=symbol) — one ctx_compose replaces all three\n\
192• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
193• Use ctx_callgraph/ctx_graph for const/static/variable refs — they track call\n\
194 edges and file deps only; use ctx_search instead";
195
196pub const PARALLEL: &str = "\
198PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles \
199multiple lookups into one call.";
200
201pub const AGENT_LOOP: &str = "\
206AGENT LOOP (phase -> tool):\n\
207• Orient — understand before acting -> ctx_compose\n\
208• Find — exact symbol by name -> ctx_search(action=symbol)\n\
209• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
210• Locate — a pattern across files -> ctx_search\n\
211• Trace — callers / callees / blast radius -> ctx_callgraph\n\
212• Verify — after an edit -> ctx_shell(test/build) + native lints";
213
214pub const NAV_PARADOX: &str = "\
219NAVIGATION PARADOX: reading more ≠ understanding more.\n\
220• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_search(action=semantic) (meaning), not whole-file reads\n\
221• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
222• Navigate structure (signatures, symbols) before reading entire files";
223
224pub const AUTO: &str = "Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. \
226 ctx_session=memory; full guide: LEAN-CTX.md";
227
228pub const RECOVER: &str = "RECOVER: compressed output is reversible — never re-read line-by-line. \
237 Need full/exact? Read the shown file path with any tool (no MCP), or \
238 ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...).";
239
240pub const RECOVER_COMPACT: &str = "RECOVER: compression is reversible — read the shown path \
247 (no MCP) or ctx_read(raw=true), never re-read line-by-line.";
248
249pub const ROOT_RESTRICTION: &str = "ROOTS: ctx_* read ~ projects, write the active one; ~/.ssh etc. closed. \
255 Use native Read for out-of-root; `lean-ctx doctor` shows effective roots.";
256
257pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
259 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
260
261pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
263 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
264
265pub const SHADOW_MINIMAL: &str = "\
271lean-ctx shadow mode: native read/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
272File editing → the host's native edit tool (lean-ctx only handles reads); if denied, use ctx_patch.\n\
273Exclusive tools (no native trigger): ctx_compose (search + read + symbols), ctx_search(action=symbol) (exact symbol), ctx_search(action=semantic) (by meaning), ctx_callgraph (callers), ctx_knowledge / ctx_session (memory).";
274
275pub const HOOK_COVERED_HEADER: &str = "\
283lean-ctx hooks compress native Shell/Read/Grep. Direct ctx_* calls add read depth \
284per file (map/signatures/lines), cached re-reads and session memory.";
285
286pub const HOOK_COVERED_TOOLS: &str = "\
290lean-ctx tools:\n\
291• Read/cat -> ctx_read(path, mode) — structural views, cached re-reads\n\
292• Grep/search -> ctx_search(pattern, path) — also action=symbol|semantic\n\
293• Shell/bash -> ctx_shell(command) — shaped command output\n\
294• ctx_compose — search + read + symbols in one call\n\
295• ctx_callgraph — callers, callees, blast radius\n\
296• ctx_session / ctx_knowledge — persistent memory\n\
297• ctx_expand — full text of [Archived]/compressed output";
298
299pub const LITE_PROMPT: &str = "\
303OUTPUT STYLE: concise
304- Bullet points over paragraphs
305- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
306- 1-sentence explanations max, then code/action
307- No repeating what the user said";
308
309pub const STANDARD_PROMPT: &str = "\
311OUTPUT STYLE: dense
312- Each statement = one atomic fact line
313- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
314- Diff lines only (+/-/~), never repeat unchanged code
315- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
316- No narration, no filler, no hedging
317- BUDGET: ≤200 tokens per response unless code block required";
318
319pub const MAX_PROMPT: &str = "\
321OUTPUT STYLE: expert-terse
322- Telegraph format: subject-verb-object, drop articles/prepositions
323- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
324- Code blocks: untouched (never compress code syntax)
325- Each line: max 80 chars
326- Zero narration, zero filler
327- BUDGET: ≤100 tokens per non-code response";
328
329pub const RAW_PROMPT: &str = "\
332OUTPUT STYLE: raw-dense
333- Bullet points ONLY, zero prose, no intro, no outro, no greetings
334- Diff-style facts: +added, -removed, ~changed, !breaking
335- One fact per line, max 60 chars
336- Symbolic: → ∵ ∴ ⊕ ⊖ Δ ≈ ≠ ∈ ∅ ✓ ✗ (same as expert-terse)
337- Code blocks: untouched
338- BUDGET: ≤50 tokens per non-code response
339- NEVER explain what you did — show only the result";
340
341pub fn compression_text(level: CompressionLevel) -> &'static str {
343 match level {
344 CompressionLevel::Off => "",
345 CompressionLevel::Lite => LITE_PROMPT,
346 CompressionLevel::Standard => STANDARD_PROMPT,
347 CompressionLevel::Max => MAX_PROMPT,
348 CompressionLevel::Raw => RAW_PROMPT,
349 }
350}
351
352fn s(c: &str) -> String {
363 c.to_string()
364}
365
366fn routing() -> String {
368 format!("{CRITICAL}\n{BULLETS}\n{NEVER}")
369}
370
371fn longform_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
372 use super::rules_sections as rs;
373 let mut v = vec![
374 s(GATEWAY_IDENTITY),
375 routing(),
376 rs::must_invoke_section(p),
377 rs::intent_section(p),
378 s(AGENT_LOOP), rs::anti_section(p),
380 s(NAV_PARADOX), rs::parallel_section(p),
382 s(AUTO),
383 s(RECOVER),
384 s(ROOT_RESTRICTION),
385 s(CEP),
386 rs::litm_end_section(p),
387 ];
388 if let Some(fb) = rs::ctx_call_fallback(p) {
389 v.push(fb);
390 }
391 v
392}
393
394fn full_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
395 use super::rules_sections as rs;
396 let mut v = vec![
399 routing(),
400 rs::must_invoke_section(p),
401 rs::intent_section(p),
402 s(AUTO),
403 s(RECOVER_COMPACT),
404 s(ROOT_RESTRICTION),
405 ];
406 if let Some(fb) = rs::ctx_call_fallback(p) {
407 v.push(fb);
408 }
409 v
410}
411
412fn full_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
413 use super::rules_sections as rs;
414 vec![rs::shadow_minimal_section(p)]
415}
416
417fn hook_covered_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
418 use super::rules_sections as rs;
419 let mut v = vec![
420 s(HOOK_COVERED_HEADER),
421 rs::hook_covered_tools_section(p),
422 s(RECOVER_COMPACT),
423 s(ROOT_RESTRICTION),
424 ];
425 if let Some(fb) = rs::ctx_call_fallback(p) {
426 v.push(fb);
427 }
428 v
429}
430
431fn compact_non_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
432 use super::rules_sections as rs;
433 let mut v = vec![
434 routing(),
435 rs::intent_section(p),
436 s(RECOVER_COMPACT),
437 s(ROOT_RESTRICTION),
438 ];
439 if let Some(fb) = rs::ctx_call_fallback(p) {
440 v.push(fb);
441 }
442 v
443}
444
445fn compact_shadow_sections(p: &super::tool_profiles::ToolProfile) -> Vec<String> {
446 use super::rules_sections as rs;
447 vec![rs::shadow_minimal_section(p)]
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum Wrapper {
454 Longform,
459
460 Dedicated,
465
466 Shared,
470
471 Bare,
474
475 HookCovered,
482}
483
484pub fn render(
495 shadow: bool,
496 wrapper: Wrapper,
497 level: CompressionLevel,
498 tool_profile: &super::tool_profiles::ToolProfile,
499) -> String {
500 let cfg = crate::core::config::Config::load();
501 render_with_solution(shadow, wrapper, level, tool_profile, &cfg.solution)
502}
503
504pub fn render_with_solution(
509 shadow: bool,
510 wrapper: Wrapper,
511 level: CompressionLevel,
512 tool_profile: &super::tool_profiles::ToolProfile,
513 solution: &super::config::solution::SolutionConfig,
514) -> String {
515 use super::rules_sections as rs;
516
517 let sections: Vec<String> = match (wrapper, shadow) {
518 (Wrapper::Longform, false) => longform_non_shadow_sections(tool_profile),
519 (Wrapper::Longform | Wrapper::Dedicated | Wrapper::HookCovered, true) => {
520 full_shadow_sections(tool_profile)
521 }
522 (Wrapper::Dedicated, false) => full_non_shadow_sections(tool_profile),
523 (Wrapper::HookCovered, false) => hook_covered_non_shadow_sections(tool_profile),
524 (_, false) => compact_non_shadow_sections(tool_profile),
525 (_, true) => compact_shadow_sections(tool_profile),
526 };
527
528 let mut body: String = sections
531 .into_iter()
532 .filter(|s| !s.is_empty())
533 .collect::<Vec<_>>()
534 .join("\n\n");
535 let _ = rs::intent_section; let compression = compression_text(level);
542 if !compression.is_empty() {
543 body.push('\n');
544 if matches!(wrapper, Wrapper::Bare) {
545 body.push_str(compression);
546 } else {
547 body.push_str(COMPRESSION_BLOCK_START);
548 body.push('\n');
549 body.push_str(compression);
550 body.push('\n');
551 body.push_str(COMPRESSION_BLOCK_END);
552 }
553 }
554
555 let ladder = if matches!(wrapper, Wrapper::Longform) || solution.inject_in_instructions {
559 solution.ladder_text()
560 } else {
561 ""
562 };
563 if !ladder.is_empty() {
564 body.push('\n');
565 if matches!(wrapper, Wrapper::Bare) {
566 body.push_str(ladder);
567 } else {
568 body.push_str(SOLUTION_BLOCK_START);
569 body.push('\n');
570 body.push_str("SOLUTION EFFICIENCY: stop at first level that applies:\n");
571 body.push_str("skip (YAGNI) → reuse codebase → stdlib → native platform → installed dep → one-line → minimum code.\n");
572 body.push_str("Never skip: validation, security, error handling.\n");
573 body.push_str(SOLUTION_BLOCK_END);
574 }
575 }
576
577 if matches!(wrapper, Wrapper::Bare) {
578 return body;
579 }
580
581 let version_line = format!("<!-- version: {RULES_VERSION} -->");
582
583 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
584}
585
586pub fn render_hook_covered_bare(
593 shadow: bool,
594 level: CompressionLevel,
595 tool_profile: &super::tool_profiles::ToolProfile,
596) -> String {
597 if shadow {
598 return render(true, Wrapper::Bare, level, tool_profile);
599 }
600 let sections = hook_covered_non_shadow_sections(tool_profile);
601 let mut body: String = sections
602 .into_iter()
603 .filter(|s| !s.is_empty())
604 .collect::<Vec<_>>()
605 .join("\n\n");
606 let compression = compression_text(level);
607 if !compression.is_empty() {
608 body.push('\n');
609 body.push_str(compression);
610 }
611 body
612}
613pub use super::rules_validation::RulesFile;
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 fn tp() -> super::super::tool_profiles::ToolProfile {
621 super::super::tool_profiles::ToolProfile::Power
622 }
623
624 #[test]
627 fn bullets_uses_ctx_shell() {
628 assert!(BULLETS.contains("ctx_shell"));
629 assert!(!BULLETS.contains("lean-ctx -c"));
630 assert!(!BULLETS.contains("ctx_edit"));
631 }
632
633 #[test]
634 fn sections_not_empty() {
635 assert!(!BULLETS.is_empty(), "`BULLETS` is empty");
636 assert!(!NEVER.is_empty(), "`NEVER` is empty");
637 assert!(!INTENT.is_empty(), "`INTENT` is empty");
638 assert!(!ANTI.is_empty(), "`ANTI` is empty");
639 assert!(!PARALLEL.is_empty(), "`PARALLEL` is empty");
640 assert!(!AUTO.is_empty(), "`AUTO` is empty");
641 assert!(!CEP.is_empty(), "`CEP` is empty");
642 assert!(!LITM_END.is_empty(), "`LITM_END` is empty");
643 assert!(!CRITICAL.is_empty(), "`CRITICAL` is empty");
644 }
645
646 #[test]
647 fn intent_contains_ctx_compose() {
648 assert!(INTENT.contains("ctx_compose"));
649 }
650
651 #[test]
652 fn anti_contains_do_not() {
653 assert!(ANTI.contains("do NOT"));
654 }
655
656 #[test]
657 fn parallel_contains_parallel() {
658 assert!(PARALLEL.contains("PARALLEL"));
659 }
660
661 #[test]
664 fn agent_loop_names_every_phase() {
665 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
666 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
667 }
668 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
669 }
670
671 #[test]
672 fn nav_paradox_steers_semantic_vs_graph() {
673 assert!(
674 NAV_PARADOX.contains("ctx_search(action=semantic)"),
675 "semantic route must use folded action (#509)"
676 );
677 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
678 assert!(
679 NAV_PARADOX.contains("≠"),
680 "must carry the reading≠understanding thesis"
681 );
682 }
683
684 #[test]
685 fn longform_carries_loop_and_paradox_injected_full_does_not() {
686 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
690 assert!(
691 long.contains("AGENT LOOP"),
692 "LONGFORM must carry AGENT_LOOP"
693 );
694 assert!(
695 long.contains("NAVIGATION PARADOX"),
696 "LONGFORM must carry NAV_PARADOX"
697 );
698 assert!(long.contains(CEP), "LONGFORM must carry CEP");
699
700 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
701 assert!(
702 !full.contains("AGENT LOOP (phase -> tool):"),
703 "injected FULL must not inline the multi-line AGENT_LOOP block"
704 );
705 assert!(
706 !full.contains("NAVIGATION PARADOX: reading"),
707 "injected FULL must not inline the multi-line NAV_PARADOX block"
708 );
709 for phase_tool in [
711 "ctx_compose",
712 "ctx_search(action=symbol)",
713 "ctx_search",
714 "ctx_callgraph",
715 ] {
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, &tp());
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, &tp()).len(),
734 render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).len(),
735 );
736 assert!(
737 full.len() <= 2400,
738 "injected dedicated rules must stay ≤2400 chars (~600 tok), got {} chars (~{} tok)",
739 full.len(),
740 full.len() / 4
741 );
742 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
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, &tp());
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, &tp());
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 }
772
773 #[test]
774 fn shadow_omits_loop_and_paradox() {
775 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
778 let out = render(true, wrapper, CompressionLevel::Off, &tp());
779 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
780 assert!(
781 !out.contains("NAVIGATION PARADOX"),
782 "{wrapper:?} shadow drops paradox"
783 );
784 }
785 }
786
787 #[test]
788 fn recover_reaches_every_non_shadow_carrier() {
789 let long = render(false, Wrapper::Longform, CompressionLevel::Off, &tp());
796 assert!(
797 long.contains(RECOVER),
798 "Longform must carry the verbose RECOVER verbatim"
799 );
800 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
801 let out = render(false, wrapper, CompressionLevel::Off, &tp());
802 assert!(
803 out.contains(RECOVER_COMPACT),
804 "{wrapper:?} must carry RECOVER_COMPACT verbatim"
805 );
806 assert!(
807 !out.contains(RECOVER),
808 "{wrapper:?} must not inline the verbose RECOVER block"
809 );
810 }
811 for wrapper in [
812 Wrapper::Longform,
813 Wrapper::Dedicated,
814 Wrapper::Shared,
815 Wrapper::Bare,
816 ] {
817 assert!(
818 render(false, wrapper, CompressionLevel::Off, &tp()).contains("(no MCP)"),
819 "{wrapper:?} recovery line must keep the MCP-free path"
820 );
821 }
822 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
823 let out = render(true, wrapper, CompressionLevel::Off, &tp());
824 assert!(
825 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
826 "{wrapper:?} shadow drops all RECOVER guidance"
827 );
828 }
829 }
830
831 #[test]
834 fn dedicated_has_markers_and_version() {
835 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
836 assert!(out.contains(START_MARK));
837 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
838 assert!(out.contains(END_MARK));
839 assert!(out.contains(BULLETS));
840 assert!(out.contains(NEVER));
841 assert!(out.contains(CRITICAL));
842 }
843
844 #[test]
845 fn dedicated_shadow_is_minimal() {
846 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
849 assert!(out.contains(START_MARK));
850 assert!(!out.contains(BULLETS), "no BULLETS in shadow");
851 assert!(!out.contains(NEVER), "no NEVER in shadow");
852 assert!(!out.contains(CRITICAL), "no routing header in shadow");
853 assert!(
854 !out.contains("By task:"),
855 "routing INTENT block is redundant under interception"
856 );
857 assert!(
858 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
859 "ANTI/PARALLEL routing guidance is dropped in shadow"
860 );
861 assert!(
862 out.contains("shadow mode") && out.contains("ctx_compose"),
863 "shadow keeps the exclusive-tool advert"
864 );
865 }
866
867 #[test]
868 fn shadow_is_smaller_than_non_shadow() {
869 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
871 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
872 assert!(
873 shadow.len() < full.len(),
874 "shadow ({}) must be smaller than non-shadow ({})",
875 shadow.len(),
876 full.len()
877 );
878 }
879
880 #[test]
881 fn dedicated_litm_structure() {
882 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
883 let lines: Vec<&str> = out.lines().collect();
884 let first_5 = lines[..5.min(lines.len())].join("\n");
885 assert!(
888 first_5.contains(CRITICAL),
889 "LITM: routing near start, head={first_5:?}"
890 );
891 assert!(!out.contains("TOOL PREFERENCE (END)"));
892 }
893
894 #[test]
895 fn dedicated_carries_weak_model_invoke_nudge() {
896 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
901 assert!(
902 dedicated.contains(MUST_INVOKE),
903 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
904 );
905 assert!(
906 !render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
907 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
908 );
909 assert!(
910 !render(false, Wrapper::Bare, CompressionLevel::Off, &tp()).contains(MUST_INVOKE),
911 "Bare/instructions channel is capped separately and carries no copy"
912 );
913 }
914
915 #[test]
918 fn shared_has_markers_and_header() {
919 let out = render(false, Wrapper::Shared, CompressionLevel::Off, &tp());
920 assert!(out.contains(START_MARK));
921 assert!(out.contains(END_MARK));
922 assert!(out.contains(CRITICAL));
923 assert!(out.contains(BULLETS));
924 }
925
926 #[test]
927 fn shared_shadow_omits_mapping() {
928 let out = render(true, Wrapper::Shared, CompressionLevel::Off, &tp());
929 assert!(out.contains(START_MARK));
930 assert!(!out.contains(CRITICAL), "shadow must not have header");
931 assert!(!out.contains(BULLETS), "shadow must not contain BULLETS");
932 }
933
934 #[test]
937 fn bare_has_no_markers() {
938 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
939 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
940 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
941 assert!(!out.contains("<!-- version:"), "Bare must not have version");
942 assert!(out.contains(BULLETS));
943 assert!(out.contains(NEVER));
944 }
945
946 #[test]
947 fn bare_shadow_only_read_modes() {
948 let out = render(true, Wrapper::Bare, CompressionLevel::Off, &tp());
949 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
950 assert!(!out.contains(BULLETS), "shadow Bare must not have BULLETS");
951 }
952
953 #[test]
956 fn render_includes_lite_prompt() {
957 let out = render(false, Wrapper::Bare, CompressionLevel::Lite, &tp());
958 assert!(out.contains("OUTPUT STYLE: concise"));
959 assert!(out.contains("Bullet points"));
960 }
961
962 #[test]
963 fn render_includes_standard_prompt() {
964 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
965 assert!(out.contains("OUTPUT STYLE: dense"));
966 assert!(out.contains("atomic fact"));
967 }
968
969 #[test]
970 fn render_includes_max_prompt() {
971 let out = render(false, Wrapper::Bare, CompressionLevel::Max, &tp());
972 assert!(out.contains("OUTPUT STYLE: expert-terse"));
973 assert!(out.contains("Telegraph"));
974 }
975
976 #[test]
977 fn render_off_excludes_compression() {
978 let out = render(false, Wrapper::Bare, CompressionLevel::Off, &tp());
979 assert!(!out.contains("OUTPUT STYLE:"));
980 }
981
982 #[test]
983 fn compression_text_matches_level() {
984 {
985 let found = &compression_text(CompressionLevel::Off);
986 assert!(found.is_empty(), "{found:?}");
987 }
988 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
989 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
990 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
991 }
992
993 #[test]
996 fn carrier_wrappers_wrap_compression_in_markers() {
997 for wrapper in [Wrapper::Longform, Wrapper::Dedicated, Wrapper::Shared] {
1000 let out = render(false, wrapper, CompressionLevel::Standard, &tp());
1001 assert!(
1002 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
1003 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
1004 );
1005 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
1007 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
1008 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
1009 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
1010 }
1011 }
1012
1013 #[test]
1014 fn bare_wrapper_emits_compression_without_markers() {
1015 let out = render(false, Wrapper::Bare, CompressionLevel::Standard, &tp());
1018 assert!(out.contains("OUTPUT STYLE: dense"));
1019 assert!(!out.contains(COMPRESSION_BLOCK_START));
1020 assert!(!out.contains(COMPRESSION_BLOCK_END));
1021 }
1022
1023 #[test]
1024 fn compression_off_emits_no_markers_in_any_wrapper() {
1025 for wrapper in [
1026 Wrapper::Longform,
1027 Wrapper::Dedicated,
1028 Wrapper::Shared,
1029 Wrapper::Bare,
1030 ] {
1031 let out = render(false, wrapper, CompressionLevel::Off, &tp());
1032 assert!(
1033 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
1034 "{wrapper:?}: Off must emit no compression markers"
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn rendered_carrier_block_is_seen_as_carrying_compression() {
1041 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1044 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
1045 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
1046 }
1047
1048 #[test]
1051 fn all_wrappers_produce_output() {
1052 for shadow in [false, true] {
1053 for wrapper in [
1054 Wrapper::Longform,
1055 Wrapper::Dedicated,
1056 Wrapper::Shared,
1057 Wrapper::Bare,
1058 ] {
1059 let out = render(shadow, wrapper, CompressionLevel::Off, &tp());
1060 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
1061 }
1062 }
1063 }
1064
1065 #[test]
1068 fn rules_file_parses_version() {
1069 let content = format!(
1070 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
1071 );
1072 let f = RulesFile::parse(&content);
1073 assert!(f.has_content());
1074 assert_eq!(f.version(), RULES_VERSION);
1075 assert!(f.is_current());
1076 assert!(f.prefix().contains("stuff before"));
1077 assert!(f.suffix().contains("stuff after"));
1078 }
1079
1080 #[test]
1081 fn rules_file_no_version_defaults_to_zero() {
1082 let content = format!("{START_MARK}\nbody\n{END_MARK}");
1083 let f = RulesFile::parse(&content);
1084 assert!(f.has_content());
1085 assert_eq!(f.version(), 0);
1086 assert!(!f.is_current());
1087 }
1088
1089 #[test]
1090 fn rules_file_no_start_marker_no_content() {
1091 let f = RulesFile::parse("just user stuff");
1092 assert!(!f.has_content());
1093 assert_eq!(f.version(), 0);
1094 }
1095
1096 #[test]
1097 fn block_matches_render_true_for_fresh_render() {
1098 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1099 let content = format!("user before\n{fresh}\nuser after");
1100 let f = RulesFile::parse(&content);
1101 assert!(f.is_current(), "fresh render carries the current version");
1102 assert!(
1103 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()),
1104 "an unchanged block must compare equal to a fresh render"
1105 );
1106 }
1107
1108 #[test]
1109 fn block_matches_render_false_on_compression_change() {
1110 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1113 let f = RulesFile::parse(&content);
1114 assert!(f.is_current());
1115 assert!(
1116 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max, &tp()),
1117 "a compression-level change must be detected as drift"
1118 );
1119 }
1120
1121 #[test]
1122 fn block_matches_render_false_on_shadow_change() {
1123 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite, &tp());
1124 let f = RulesFile::parse(&content);
1125 assert!(
1126 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite, &tp()),
1127 "a shadow-mode toggle must be detected as drift"
1128 );
1129 }
1130
1131 #[test]
1132 fn block_matches_render_false_without_block() {
1133 let f = RulesFile::parse("plain user content, no markers");
1134 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp()));
1135 }
1136
1137 #[test]
1138 fn rules_file_merged_replaces_section() {
1139 let content =
1140 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
1141 let f = RulesFile::parse(&content);
1142 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off, &tp());
1143 assert!(merged.contains("before"), "prefix preserved");
1144 assert!(merged.contains("after"), "suffix preserved");
1145 assert!(!merged.contains("old"), "old content replaced");
1146 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1147 }
1148
1149 #[test]
1150 fn rules_file_merged_appends_when_no_section() {
1151 let content = "user content";
1152 let f = RulesFile::parse(content);
1153 assert!(!f.has_content());
1154 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off, &tp());
1155 assert!(merged.contains("user content"));
1156 assert!(merged.contains(BULLETS));
1157 }
1158
1159 #[test]
1160 fn rules_file_without_section_strips_content() {
1161 let content =
1162 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1163 let f = RulesFile::parse(&content);
1164 let stripped = f.without_section();
1165 assert!(stripped.contains("header"));
1166 assert!(stripped.contains("footer"));
1167 assert!(!stripped.contains("body"));
1168 assert!(!stripped.contains(START_MARK));
1169 }
1170
1171 #[test]
1172 fn rules_file_without_section_noop_when_no_content() {
1173 let content = "just user text";
1174 let f = RulesFile::parse(content);
1175 assert_eq!(f.without_section(), content);
1176 }
1177
1178 #[test]
1179 fn bullets_lead_with_four_core_redirects() {
1180 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1182 let search = BULLETS
1183 .find("ctx_search")
1184 .expect("ctx_search mapping present");
1185 let shell = BULLETS
1186 .find("ctx_shell")
1187 .expect("ctx_shell mapping present");
1188 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1189 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1190 assert!(
1191 read < search && search < shell && shell < glob && glob < tree,
1192 "core redirects (read<search<shell<glob) must precede ctx_tree"
1193 );
1194 }
1195
1196 #[test]
1197 fn injected_rules_state_routing_without_shouting() {
1198 assert!(NEVER.contains("other MCP servers keep their own jobs"));
1202 for wrapper in [
1203 Wrapper::Dedicated,
1204 Wrapper::Shared,
1205 Wrapper::Bare,
1206 Wrapper::HookCovered,
1207 ] {
1208 let out = render(false, wrapper, CompressionLevel::Off, &tp());
1209 for shout in [
1210 "ALWAYS",
1211 "NEVER",
1212 "SELF-CORRECT",
1213 "FIRST",
1214 "MANDATORY",
1215 "CRITICAL",
1216 ] {
1217 assert!(
1218 !out.contains(shout),
1219 "{wrapper:?} carries {shout:?}:\n{out}"
1220 );
1221 }
1222 }
1223 }
1224
1225 #[test]
1228 fn hook_covered_carries_strict_mapping() {
1229 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1232 assert!(
1233 !out.contains(NEVER),
1234 "HookCovered uses its own strict header, not the NEVER constant"
1235 );
1236 assert!(
1237 out.contains(HOOK_COVERED_HEADER),
1238 "must carry the strict preference header"
1239 );
1240 assert!(
1241 out.contains("lean-ctx tools:")
1242 && out.contains("ctx_read")
1243 && out.contains("ctx_search"),
1244 "must carry the full mandatory mapping"
1245 );
1246 assert!(
1247 out.contains("ctx_compose") && out.contains("action=symbol|semantic"),
1248 "must advertise the search capabilities"
1249 );
1250 }
1251
1252 #[test]
1253 fn hook_covered_keeps_markers_version_and_recovery() {
1254 let out = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1258 assert!(out.contains(START_MARK) && out.contains(END_MARK));
1259 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
1260 assert!(out.contains(RECOVER_COMPACT), "recovery line must survive");
1261 assert!(out.contains("(no MCP)"), "MCP-free recovery path stays");
1262 }
1263
1264 #[test]
1265 fn hook_covered_is_leaner_than_full() {
1266 let covered = render(false, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1267 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1268 assert!(
1269 covered.len() < full.len(),
1270 "HookCovered ({}) must be a strict reduction of FULL ({})",
1271 covered.len(),
1272 full.len()
1273 );
1274 }
1275
1276 #[test]
1277 fn hook_covered_shadow_collapses_to_minimal() {
1278 let covered_shadow = render(true, Wrapper::HookCovered, CompressionLevel::Off, &tp());
1281 let dedicated_shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off, &tp());
1282 assert_eq!(covered_shadow, dedicated_shadow);
1283 }
1284
1285 #[test]
1286 fn hook_covered_wraps_compression_in_markers() {
1287 let out = render(
1288 false,
1289 Wrapper::HookCovered,
1290 CompressionLevel::Standard,
1291 &tp(),
1292 );
1293 assert!(out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END));
1294 assert!(out.contains("OUTPUT STYLE: dense"));
1295 }
1296
1297 #[test]
1300 fn rules_only_mention_enabled_tools() {
1301 use super::super::rules_sections;
1302 use super::super::tool_profiles::ToolProfile;
1303 let exclusive_tools = ["ctx_compose", "ctx_callgraph", "ctx_patch"];
1304 for profile in [ToolProfile::Minimal, ToolProfile::Standard] {
1305 let mut sections = vec![
1306 rules_sections::intent_section(&profile),
1307 rules_sections::hook_covered_tools_section(&profile),
1308 rules_sections::shadow_minimal_section(&profile),
1309 rules_sections::anti_section(&profile),
1310 rules_sections::litm_end_section(&profile),
1311 ];
1312 if let Some(fb) = rules_sections::ctx_call_fallback(&profile) {
1313 sections.push(fb);
1314 }
1315 for tool in &exclusive_tools {
1316 if !profile.is_tool_enabled(tool) {
1317 for section in §ions {
1318 assert!(
1319 !section.contains(tool),
1320 "profile {:?} dynamic section must not mention disabled tool {tool}",
1321 profile.as_str()
1322 );
1323 }
1324 }
1325 }
1326 }
1327 }
1328
1329 #[test]
1330 fn minimal_rules_shorter_than_standard() {
1331 use super::super::tool_profiles::ToolProfile;
1332 let min = render(
1333 false,
1334 Wrapper::Dedicated,
1335 CompressionLevel::Off,
1336 &ToolProfile::Minimal,
1337 );
1338 let std = render(
1339 false,
1340 Wrapper::Dedicated,
1341 CompressionLevel::Off,
1342 &ToolProfile::Standard,
1343 );
1344 assert!(
1345 min.len() < std.len(),
1346 "minimal ({}) must be shorter than standard ({})",
1347 min.len(),
1348 std.len()
1349 );
1350 }
1351
1352 #[test]
1353 fn power_profile_output_identical_to_default() {
1354 use super::super::tool_profiles::ToolProfile;
1355 let power = render(
1356 false,
1357 Wrapper::Dedicated,
1358 CompressionLevel::Off,
1359 &ToolProfile::Power,
1360 );
1361 assert!(
1362 power.contains("ctx_compose"),
1363 "Power must include all tools"
1364 );
1365 assert!(
1366 power.contains("ctx_callgraph"),
1367 "Power must include all tools"
1368 );
1369 assert!(
1370 power.contains("ctx_session"),
1371 "Power must include all tools"
1372 );
1373 }
1374
1375 #[test]
1376 fn render_is_deterministic_across_profiles() {
1377 use super::super::tool_profiles::ToolProfile;
1378 for profile in [
1379 ToolProfile::Minimal,
1380 ToolProfile::Standard,
1381 ToolProfile::Power,
1382 ] {
1383 let a = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1384 let b = render(false, Wrapper::Dedicated, CompressionLevel::Off, &profile);
1385 assert_eq!(
1386 a,
1387 b,
1388 "render must be deterministic for {:?}",
1389 profile.as_str()
1390 );
1391 }
1392 }
1393
1394 #[test]
1395 fn minimal_has_ctx_call_fallback() {
1396 use super::super::tool_profiles::ToolProfile;
1397 let rules = render(
1398 false,
1399 Wrapper::Dedicated,
1400 CompressionLevel::Off,
1401 &ToolProfile::Minimal,
1402 );
1403 assert!(
1404 rules.contains("ctx_call"),
1405 "minimal profile must include ctx_call gateway hint"
1406 );
1407 }
1408
1409 #[test]
1410 fn power_has_no_ctx_call_fallback() {
1411 use super::super::tool_profiles::ToolProfile;
1412 let rules = render(
1413 false,
1414 Wrapper::Dedicated,
1415 CompressionLevel::Off,
1416 &ToolProfile::Power,
1417 );
1418 assert!(
1419 !rules.contains("ctx_call(tool="),
1420 "power profile must not include ctx_call gateway hint"
1421 );
1422 }
1423}