1use crate::core::config::CompressionLevel;
13
14pub const START_MARK: &str = "<!-- lean-ctx-rules -->";
19
20pub const RULES_MARKER_PREFIX: &str = "<!-- lean-ctx-rules";
24
25pub const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
31
32pub const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
34
35pub const PROJECT_LEAN_CTX_OWNED_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
41
42pub const END_MARK: &str = "<!-- /lean-ctx-rules -->";
44
45pub const COMPRESSION_BLOCK_START: &str = "<!-- lean-ctx-compression -->";
57
58pub const COMPRESSION_BLOCK_END: &str = "<!-- /lean-ctx-compression -->";
61
62pub const RULES_VERSION: usize = 4;
74
75pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx ctx_* tools instead of native equivalents. \
77 This is NOT optional.";
78
79pub const MUST_INVOKE: &str = "MUST actually CALL the ctx_* MCP tools, not just describe them: \
90 before reading, searching, or running a shell command, emit the matching ctx_* tool call \
91 (ctx_compose first, then ctx_read / ctx_search / ctx_shell).";
92
93pub const BULLETS: &str = "\
95MANDATORY MAPPING (4 core redirects first):\n\
96• Read/cat -> ctx_read(path, mode)\n\
97• Grep -> ctx_search(pattern, path)\n\
98• Shell/bash -> ctx_shell(command)\n\
99• Glob/find -> ctx_glob(pattern)\n\
100• ls/find -> ctx_tree(path, depth)";
101
102pub const NEVER: &str = "NEVER use native Read/Grep/Shell/Glob when a ctx_* equivalent \
105 exists. SELF-CORRECT: the moment you reach for a native Read/Grep/Shell/Glob, stop \
106 and call the ctx_* tool instead.";
107
108pub const INTENT: &str = "\
110Tool selection by intent:\n\
111• Understand code / find answers / before editing -> ctx_compose (call FIRST)\n\
112• Read a file -> ctx_read(path, mode=signatures|map|full)\n\
113• Edit code you've read -> ctx_patch (hash-anchored, no exact-recall; read mode=anchored first)\n\
114• Find a symbol by name (exact) -> ctx_symbol\n\
115• Search code by pattern (fuzzy) -> ctx_search\n\
116• Search by meaning (concepts) -> ctx_semantic_search\n\
117• Find files by pattern (glob) -> ctx_glob\n\
118• Project structure -> ctx_tree\n\
119• Who calls this / call graph -> ctx_callgraph\n\
120• Session state / memory -> ctx_session / ctx_knowledge";
121
122pub const ANTI: &str = "\
124Anti-patterns — do NOT:\n\
125• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
126• Grep for symbol definitions — ctx_symbol is faster + more precise\n\
127• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
128• Use ctx_callgraph or ctx_graph for const/static/variable references — they track\n\
129 function call edges and file-level deps only. Use grep or ctx_compose instead";
130
131pub const PARALLEL: &str = "\
133PARALLEL tool calls: fire independent calls in the SAME turn — don't sequence them.\n\
134ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
135cover, batch independent reads/searches together.";
136
137pub const AGENT_LOOP: &str = "\
142AGENT LOOP (phase -> tool):\n\
143• Orient — understand before acting -> ctx_compose\n\
144• Find — exact symbol by name -> ctx_symbol\n\
145• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
146• Locate — a pattern across files -> ctx_search\n\
147• Trace — callers / callees / blast radius -> ctx_callgraph\n\
148• Verify — after an edit -> ctx_shell(test/build) + native lints";
149
150pub const NAV_PARADOX: &str = "\
155NAVIGATION PARADOX: reading more ≠ understanding more.\n\
156• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_semantic_search (meaning), not whole-file reads\n\
157• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
158• Navigate structure (signatures, symbols) before reading entire files";
159
160pub const LOOP_NAV_COMPACT: &str = "\
164AGENT LOOP: Orient(ctx_compose) → Find(ctx_symbol) → Read(ctx_read) → Locate(ctx_search) → Trace(ctx_callgraph) → Verify(ctx_shell). \
165Reading more ≠ understanding more: semantic Qs -> ctx_search/ctx_semantic_search; hidden deps -> ctx_callgraph/ctx_graph only.";
166
167pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
169 ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
170 ctx_shell raw=true=uncompressed. Details: 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 \
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 LITE_PROMPT: &str = "\
219OUTPUT STYLE: concise
220- Bullet points over paragraphs
221- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
222- 1-sentence explanations max, then code/action
223- No repeating what the user said";
224
225pub const STANDARD_PROMPT: &str = "\
227OUTPUT STYLE: dense
228- Each statement = one atomic fact line
229- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
230- Diff lines only (+/-/~), never repeat unchanged code
231- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
232- No narration, no filler, no hedging
233- BUDGET: ≤200 tokens per response unless code block required";
234
235pub const MAX_PROMPT: &str = "\
237OUTPUT STYLE: expert-terse
238- Telegraph format: subject-verb-object, drop articles/prepositions
239- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
240- Code blocks: untouched (never compress code syntax)
241- Each line: max 80 chars
242- Zero narration, zero filler
243- BUDGET: ≤100 tokens per non-code response";
244
245pub fn compression_text(level: CompressionLevel) -> &'static str {
247 match level {
248 CompressionLevel::Off => "",
249 CompressionLevel::Lite => LITE_PROMPT,
250 CompressionLevel::Standard => STANDARD_PROMPT,
251 CompressionLevel::Max => MAX_PROMPT,
252 }
253}
254
255const FULL_NON_SHADOW: &[&str] = &[
256 CRITICAL,
257 MUST_INVOKE,
258 BULLETS,
259 NEVER,
260 INTENT,
261 AGENT_LOOP,
262 ANTI,
263 NAV_PARADOX,
264 PARALLEL,
265 AUTO,
266 RECOVER,
267 CEP,
268 INTELLIGENCE,
269 LITM_END,
270];
271
272const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
277
278const COMPACT_NON_SHADOW: &[&str] = &[
279 CRITICAL,
280 BULLETS,
281 NEVER,
282 INTENT,
283 LOOP_NAV_COMPACT,
284 ANTI,
285 PARALLEL,
286 RECOVER_COMPACT,
287];
288
289const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum Wrapper {
295 Dedicated,
300
301 Shared,
305
306 Bare,
309}
310
311pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
319 let profile = match (wrapper, shadow) {
320 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
321 (Wrapper::Dedicated, true) => FULL_SHADOW,
322 (_, false) => COMPACT_NON_SHADOW,
323 (_, true) => COMPACT_SHADOW,
324 };
325
326 let mut body = profile.join("\n\n");
327
328 let compression = compression_text(level);
333 if !compression.is_empty() {
334 body.push('\n');
335 if matches!(wrapper, Wrapper::Bare) {
336 body.push_str(compression);
337 } else {
338 body.push_str(COMPRESSION_BLOCK_START);
339 body.push('\n');
340 body.push_str(compression);
341 body.push('\n');
342 body.push_str(COMPRESSION_BLOCK_END);
343 }
344 }
345
346 if matches!(wrapper, Wrapper::Bare) {
347 return body;
348 }
349
350 let version_line = format!("<!-- version: {RULES_VERSION} -->");
351
352 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
353}
354#[derive(Debug)]
365pub struct RulesFile<'a> {
366 content: &'a str,
367 start: Option<usize>,
369 end: Option<usize>,
371 version: usize,
373}
374
375fn parse_version_number(s: &str) -> Option<usize> {
378 let prefix = "<!-- version: ";
379 let vs = s.find(prefix)?;
380 let num_start = vs + prefix.len();
381 let end = s[num_start..].find(" -->")?;
382 s[num_start..num_start + end].parse().ok()
383}
384
385impl<'a> RulesFile<'a> {
386 pub fn parse(content: &'a str) -> Self {
392 let start = content.find(START_MARK);
393 let version = start
394 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
395 .unwrap_or(0);
396 let end = content.find(END_MARK);
397 RulesFile {
398 content,
399 start,
400 end,
401 version,
402 }
403 }
404
405 pub fn has_content(&self) -> bool {
407 self.start.is_some()
408 }
409
410 pub fn version(&self) -> usize {
413 self.version
414 }
415
416 pub fn is_current(&self) -> bool {
418 self.version >= RULES_VERSION
419 }
420
421 pub fn prefix(&self) -> &'a str {
424 self.start.map_or("", |s| self.content[..s].trim())
425 }
426
427 pub fn suffix(&self) -> &'a str {
430 self.end
431 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
432 }
433
434 fn block(&self) -> Option<&'a str> {
437 match (self.start, self.end) {
438 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
439 _ => None,
440 }
441 }
442
443 pub fn block_matches_render(
453 &self,
454 shadow: bool,
455 wrapper: Wrapper,
456 level: CompressionLevel,
457 ) -> bool {
458 match self.block() {
459 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
460 None => false,
461 }
462 }
463
464 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
470 let fresh = render(shadow, wrapper, level);
471 if self.start.is_some() {
472 let before = self.prefix();
473 let after = self.suffix();
474 let mut out = String::new();
475 if !before.is_empty() {
476 out.push_str(before);
477 out.push('\n');
478 out.push('\n');
479 }
480 out.push_str(&fresh);
481 if !after.is_empty() {
482 out.push('\n');
483 out.push('\n');
484 out.push_str(after);
485 }
486 if !out.ends_with('\n') {
487 out.push('\n');
488 }
489 out
490 } else {
491 let trimmed = self.content.trim_end();
493 let mut out = trimmed.to_string();
494 if !out.is_empty() {
495 out.push('\n');
496 out.push('\n');
497 }
498 out.push_str(&fresh);
499 out
500 }
501 }
502
503 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
505 render(shadow, wrapper, level)
506 }
507
508 pub fn without_section(&self) -> String {
512 if let Some(start_pos) = self.start {
513 let before = self.content[..start_pos].trim();
514 let after = self.suffix();
515 let mut out = String::new();
516 if !before.is_empty() {
517 out.push_str(before);
518 out.push('\n');
519 }
520 if !after.is_empty() {
521 out.push('\n');
522 out.push_str(after);
523 }
524 out
525 } else {
526 self.content.to_string()
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
538 fn bullets_uses_ctx_shell() {
539 assert!(BULLETS.contains("ctx_shell"));
540 assert!(!BULLETS.contains("lean-ctx -c"));
541 assert!(!BULLETS.contains("ctx_edit"));
542 }
543
544 #[test]
545 fn sections_not_empty() {
546 assert!(!BULLETS.is_empty());
547 assert!(!NEVER.is_empty());
548 assert!(!INTENT.is_empty());
549 assert!(!ANTI.is_empty());
550 assert!(!PARALLEL.is_empty());
551 assert!(!AUTO.is_empty());
552 assert!(!CEP.is_empty());
553 assert!(!INTELLIGENCE.is_empty());
554 assert!(!LITM_END.is_empty());
555 assert!(!CRITICAL.is_empty());
556 }
557
558 #[test]
559 fn intent_contains_ctx_compose() {
560 assert!(INTENT.contains("ctx_compose"));
561 }
562
563 #[test]
564 fn anti_contains_do_not() {
565 assert!(ANTI.contains("do NOT"));
566 }
567
568 #[test]
569 fn parallel_contains_parallel() {
570 assert!(PARALLEL.contains("PARALLEL"));
571 }
572
573 #[test]
576 fn agent_loop_names_every_phase() {
577 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
578 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
579 }
580 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
581 }
582
583 #[test]
584 fn nav_paradox_steers_semantic_vs_graph() {
585 assert!(
586 NAV_PARADOX.contains("ctx_semantic_search"),
587 "semantic route"
588 );
589 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
590 assert!(
591 NAV_PARADOX.contains("≠"),
592 "must carry the reading≠understanding thesis"
593 );
594 }
595
596 #[test]
597 fn full_profile_carries_loop_and_paradox() {
598 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
599 assert!(out.contains("AGENT LOOP"), "FULL must carry AGENT_LOOP");
600 assert!(
601 out.contains("NAVIGATION PARADOX"),
602 "FULL must carry NAV_PARADOX"
603 );
604 }
605
606 #[test]
607 fn compact_profile_uses_one_liner_not_full_sections() {
608 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
611 assert!(
612 out.contains(LOOP_NAV_COMPACT),
613 "COMPACT must carry one-liner"
614 );
615 assert!(
616 !out.contains("AGENT LOOP (phase -> tool):"),
617 "COMPACT must not inline the multi-line AGENT_LOOP block"
618 );
619 assert!(
620 !out.contains("NAVIGATION PARADOX: reading"),
621 "COMPACT must not inline the multi-line NAV_PARADOX block"
622 );
623 }
624
625 #[test]
626 fn shadow_omits_loop_and_paradox() {
627 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
630 let out = render(true, wrapper, CompressionLevel::Off);
631 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
632 assert!(
633 !out.contains("NAVIGATION PARADOX"),
634 "{wrapper:?} shadow drops paradox"
635 );
636 }
637 }
638
639 #[test]
640 fn recover_reaches_every_non_shadow_carrier() {
641 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
647 assert!(
648 full.contains(RECOVER),
649 "FULL non-shadow must carry the verbose RECOVER verbatim"
650 );
651 for wrapper in [Wrapper::Shared, Wrapper::Bare] {
652 let out = render(false, wrapper, CompressionLevel::Off);
653 assert!(
654 out.contains(RECOVER_COMPACT),
655 "{wrapper:?} (COMPACT) must carry RECOVER_COMPACT verbatim"
656 );
657 assert!(
658 !out.contains(RECOVER),
659 "{wrapper:?} (COMPACT) must not inline the verbose RECOVER block"
660 );
661 }
662 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
663 assert!(
664 render(false, wrapper, CompressionLevel::Off).contains("(no MCP)"),
665 "{wrapper:?} recovery line must keep the MCP-free path"
666 );
667 }
668 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
670 let out = render(true, wrapper, CompressionLevel::Off);
671 assert!(
672 !out.contains(RECOVER) && !out.contains(RECOVER_COMPACT),
673 "{wrapper:?} shadow drops all RECOVER guidance"
674 );
675 }
676 }
677
678 #[test]
681 fn dedicated_has_markers_and_version() {
682 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
683 assert!(out.contains(START_MARK));
684 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
685 assert!(out.contains(END_MARK));
686 assert!(out.contains(BULLETS));
687 assert!(out.contains(NEVER));
688 assert!(out.contains("CRITICAL"));
689 }
690
691 #[test]
692 fn dedicated_shadow_is_minimal() {
693 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
697 assert!(out.contains(START_MARK));
698 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
699 assert!(!out.contains(NEVER), "no NEVER in shadow");
700 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
701 assert!(
702 !out.contains("Tool selection by intent"),
703 "routing INTENT block is redundant under interception"
704 );
705 assert!(
706 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
707 "ANTI/PARALLEL routing guidance is dropped in shadow"
708 );
709 assert!(
710 out.contains("shadow mode") && out.contains("ctx_compose"),
711 "shadow keeps the exclusive-tool advert"
712 );
713 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
714 }
715
716 #[test]
717 fn shadow_is_smaller_than_non_shadow() {
718 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
720 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
721 assert!(
722 shadow.len() < full.len(),
723 "shadow ({}) must be smaller than non-shadow ({})",
724 shadow.len(),
725 full.len()
726 );
727 }
728
729 #[test]
730 fn dedicated_litm_structure() {
731 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
732 let lines: Vec<&str> = out.lines().collect();
733 let first_5 = lines[..5.min(lines.len())].join("\n");
734 assert!(
735 first_5.contains("CRITICAL") || first_5.contains("MUST"),
736 "LITM: MUST/CRITICAL instruction near start"
737 );
738 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
740 assert!(
741 tail.contains("PREFERENCE") || tail.contains("NEVER"),
742 "LITM: reinforcement near end, tail={tail:?}"
743 );
744 }
745
746 #[test]
747 fn dedicated_carries_weak_model_invoke_nudge() {
748 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off);
753 assert!(
754 dedicated.contains(MUST_INVOKE),
755 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
756 );
757 assert!(
758 !render(true, Wrapper::Dedicated, CompressionLevel::Off).contains(MUST_INVOKE),
759 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
760 );
761 assert!(
762 !render(false, Wrapper::Bare, CompressionLevel::Off).contains(MUST_INVOKE),
763 "Bare/instructions channel is capped separately and carries no copy"
764 );
765 }
766
767 #[test]
770 fn shared_has_markers_and_header() {
771 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
772 assert!(out.contains(START_MARK));
773 assert!(out.contains(END_MARK));
774 assert!(out.contains("MANDATORY MAPPING"));
775 assert!(out.contains(BULLETS));
776 }
777
778 #[test]
779 fn shared_shadow_omits_mapping() {
780 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
781 assert!(out.contains(START_MARK));
782 assert!(
783 !out.contains("MANDATORY MAPPING"),
784 "shadow must not have header"
785 );
786 assert!(
787 !out.contains("MANDATORY MAPPING"),
788 "shadow must not contain BULLETS"
789 );
790 }
791
792 #[test]
795 fn bare_has_no_markers() {
796 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
797 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
798 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
799 assert!(!out.contains("<!-- version:"), "Bare must not have version");
800 assert!(out.contains(BULLETS));
801 assert!(out.contains(NEVER));
802 }
803
804 #[test]
805 fn bare_shadow_only_read_modes() {
806 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
807 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
808 assert!(
809 !out.contains("MANDATORY MAPPING"),
810 "shadow Bare must not have BULLETS"
811 );
812 }
813
814 #[test]
817 fn render_includes_lite_prompt() {
818 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
819 assert!(out.contains("OUTPUT STYLE: concise"));
820 assert!(out.contains("Bullet points"));
821 }
822
823 #[test]
824 fn render_includes_standard_prompt() {
825 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
826 assert!(out.contains("OUTPUT STYLE: dense"));
827 assert!(out.contains("atomic fact"));
828 }
829
830 #[test]
831 fn render_includes_max_prompt() {
832 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
833 assert!(out.contains("OUTPUT STYLE: expert-terse"));
834 assert!(out.contains("Telegraph"));
835 }
836
837 #[test]
838 fn render_off_excludes_compression() {
839 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
840 assert!(!out.contains("OUTPUT STYLE:"));
841 }
842
843 #[test]
844 fn compression_text_matches_level() {
845 assert!(compression_text(CompressionLevel::Off).is_empty());
846 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
847 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
848 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
849 }
850
851 #[test]
854 fn carrier_wrappers_wrap_compression_in_markers() {
855 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
858 let out = render(false, wrapper, CompressionLevel::Standard);
859 assert!(
860 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
861 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
862 );
863 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
865 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
866 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
867 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
868 }
869 }
870
871 #[test]
872 fn bare_wrapper_emits_compression_without_markers() {
873 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
876 assert!(out.contains("OUTPUT STYLE: dense"));
877 assert!(!out.contains(COMPRESSION_BLOCK_START));
878 assert!(!out.contains(COMPRESSION_BLOCK_END));
879 }
880
881 #[test]
882 fn compression_off_emits_no_markers_in_any_wrapper() {
883 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
884 let out = render(false, wrapper, CompressionLevel::Off);
885 assert!(
886 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
887 "{wrapper:?}: Off must emit no compression markers"
888 );
889 }
890 }
891
892 #[test]
893 fn rendered_carrier_block_is_seen_as_carrying_compression() {
894 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
897 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
898 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
899 }
900
901 #[test]
904 fn all_wrappers_produce_output() {
905 for shadow in [false, true] {
906 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
907 let out = render(shadow, wrapper, CompressionLevel::Off);
908 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
909 }
910 }
911 }
912
913 #[test]
916 fn rules_file_parses_version() {
917 let content = format!(
918 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
919 );
920 let f = RulesFile::parse(&content);
921 assert!(f.has_content());
922 assert_eq!(f.version(), RULES_VERSION);
923 assert!(f.is_current());
924 assert!(f.prefix().contains("stuff before"));
925 assert!(f.suffix().contains("stuff after"));
926 }
927
928 #[test]
929 fn rules_file_no_version_defaults_to_zero() {
930 let content = format!("{START_MARK}\nbody\n{END_MARK}");
931 let f = RulesFile::parse(&content);
932 assert!(f.has_content());
933 assert_eq!(f.version(), 0);
934 assert!(!f.is_current());
935 }
936
937 #[test]
938 fn rules_file_no_start_marker_no_content() {
939 let f = RulesFile::parse("just user stuff");
940 assert!(!f.has_content());
941 assert_eq!(f.version(), 0);
942 }
943
944 #[test]
945 fn block_matches_render_true_for_fresh_render() {
946 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
947 let content = format!("user before\n{fresh}\nuser after");
948 let f = RulesFile::parse(&content);
949 assert!(f.is_current(), "fresh render carries the current version");
950 assert!(
951 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
952 "an unchanged block must compare equal to a fresh render"
953 );
954 }
955
956 #[test]
957 fn block_matches_render_false_on_compression_change() {
958 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
961 let f = RulesFile::parse(&content);
962 assert!(f.is_current());
963 assert!(
964 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
965 "a compression-level change must be detected as drift"
966 );
967 }
968
969 #[test]
970 fn block_matches_render_false_on_shadow_change() {
971 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
972 let f = RulesFile::parse(&content);
973 assert!(
974 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
975 "a shadow-mode toggle must be detected as drift"
976 );
977 }
978
979 #[test]
980 fn block_matches_render_false_without_block() {
981 let f = RulesFile::parse("plain user content, no markers");
982 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
983 }
984
985 #[test]
986 fn rules_file_merged_replaces_section() {
987 let content =
988 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
989 let f = RulesFile::parse(&content);
990 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
991 assert!(merged.contains("before"), "prefix preserved");
992 assert!(merged.contains("after"), "suffix preserved");
993 assert!(!merged.contains("old"), "old content replaced");
994 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
995 }
996
997 #[test]
998 fn rules_file_merged_appends_when_no_section() {
999 let content = "user content";
1000 let f = RulesFile::parse(content);
1001 assert!(!f.has_content());
1002 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
1003 assert!(merged.contains("user content"));
1004 assert!(merged.contains(BULLETS));
1005 }
1006
1007 #[test]
1008 fn rules_file_without_section_strips_content() {
1009 let content =
1010 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
1011 let f = RulesFile::parse(&content);
1012 let stripped = f.without_section();
1013 assert!(stripped.contains("header"));
1014 assert!(stripped.contains("footer"));
1015 assert!(!stripped.contains("body"));
1016 assert!(!stripped.contains(START_MARK));
1017 }
1018
1019 #[test]
1020 fn rules_file_without_section_noop_when_no_content() {
1021 let content = "just user text";
1022 let f = RulesFile::parse(content);
1023 assert_eq!(f.without_section(), content);
1024 }
1025
1026 #[test]
1027 fn bullets_lead_with_four_core_redirects() {
1028 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
1030 let search = BULLETS
1031 .find("ctx_search")
1032 .expect("ctx_search mapping present");
1033 let shell = BULLETS
1034 .find("ctx_shell")
1035 .expect("ctx_shell mapping present");
1036 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
1037 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
1038 assert!(
1039 read < search && search < shell && shell < glob && glob < tree,
1040 "core redirects (read<search<shell<glob) must precede ctx_tree"
1041 );
1042 }
1043
1044 #[test]
1045 fn never_carries_self_correction() {
1046 assert!(
1048 NEVER.contains("SELF-CORRECT"),
1049 "NEVER must teach self-correction"
1050 );
1051 assert!(
1052 NEVER.contains("call"),
1053 "NEVER must spell out the corrective action"
1054 );
1055 }
1056
1057 #[test]
1058 fn critical_names_ctx_family() {
1059 assert!(
1060 CRITICAL.contains("ctx_*"),
1061 "CRITICAL must name the ctx_* family"
1062 );
1063 }
1064}