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 = 3;
70
71pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx ctx_* tools instead of native equivalents. \
73 This is NOT optional.";
74
75pub const MUST_INVOKE: &str = "MUST actually CALL the ctx_* MCP tools, not just describe them: \
86 before reading, searching, or running a shell command, emit the matching ctx_* tool call \
87 (ctx_compose first, then ctx_read / ctx_search / ctx_shell).";
88
89pub const BULLETS: &str = "\
91MANDATORY MAPPING (4 core redirects first):\n\
92• Read/cat -> ctx_read(path, mode)\n\
93• Grep -> ctx_search(pattern, path)\n\
94• Shell/bash -> ctx_shell(command)\n\
95• Glob/find -> ctx_glob(pattern)\n\
96• ls/find -> ctx_tree(path, depth)";
97
98pub const NEVER: &str = "NEVER use native Read/Grep/Shell/Glob when a ctx_* equivalent \
101 exists. SELF-CORRECT: the moment you reach for a native Read/Grep/Shell/Glob, stop \
102 and call the ctx_* tool instead.";
103
104pub const INTENT: &str = "\
106Tool selection by intent:\n\
107• Understand code / find answers / before editing -> ctx_compose (call FIRST)\n\
108• Read a file -> ctx_read(path, mode=signatures|map|full)\n\
109• Edit code you've read -> ctx_patch (hash-anchored, no exact-recall; read mode=anchored first)\n\
110• Find a symbol by name (exact) -> ctx_symbol\n\
111• Search code by pattern (fuzzy) -> ctx_search\n\
112• Search by meaning (concepts) -> ctx_semantic_search\n\
113• Find files by pattern (glob) -> ctx_glob\n\
114• Project structure -> ctx_tree\n\
115• Who calls this / call graph -> ctx_callgraph\n\
116• Session state / memory -> ctx_session / ctx_knowledge";
117
118pub const ANTI: &str = "\
120Anti-patterns — do NOT:\n\
121• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
122• Grep for symbol definitions — ctx_symbol is faster + more precise\n\
123• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
124• Use ctx_callgraph or ctx_graph for const/static/variable references — they track\n\
125 function call edges and file-level deps only. Use grep or ctx_compose instead";
126
127pub const PARALLEL: &str = "\
129PARALLEL tool calls: fire independent calls in the SAME turn — don't sequence them.\n\
130ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
131cover, batch independent reads/searches together.";
132
133pub const AGENT_LOOP: &str = "\
138AGENT LOOP (phase -> tool):\n\
139• Orient — understand before acting -> ctx_compose\n\
140• Find — exact symbol by name -> ctx_symbol\n\
141• Read — a file, structurally -> ctx_read(mode=signatures|map)\n\
142• Locate — a pattern across files -> ctx_search\n\
143• Trace — callers / callees / blast radius -> ctx_callgraph\n\
144• Verify — after an edit -> ctx_shell(test/build) + native lints";
145
146pub const NAV_PARADOX: &str = "\
151NAVIGATION PARADOX: reading more ≠ understanding more.\n\
152• Semantic question (\"where/how is X handled?\") -> ctx_search (BM25) + ctx_semantic_search (meaning), not whole-file reads\n\
153• Hidden architectural deps (who calls this, what breaks) -> ctx_callgraph / ctx_graph — for these only\n\
154• Navigate structure (signatures, symbols) before reading entire files";
155
156pub const LOOP_NAV_COMPACT: &str = "\
160AGENT LOOP: Orient(ctx_compose) → Find(ctx_symbol) → Read(ctx_read) → Locate(ctx_search) → Trace(ctx_callgraph) → Verify(ctx_shell). \
161Reading more ≠ understanding more: semantic Qs -> ctx_search/ctx_semantic_search; hidden deps -> ctx_callgraph/ctx_graph only.";
162
163pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
165 ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
166 ctx_shell raw=true=uncompressed. Details: LEAN-CTX.md";
167
168pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
170 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
171
172pub const INTELLIGENCE: &str =
174 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
175
176pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
178 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
179
180pub const SHADOW_MINIMAL: &str = "\
186lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
187Exclusive 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).";
188
189pub const LITE_PROMPT: &str = "\
193OUTPUT STYLE: concise
194- Bullet points over paragraphs
195- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
196- 1-sentence explanations max, then code/action
197- No repeating what the user said";
198
199pub const STANDARD_PROMPT: &str = "\
201OUTPUT STYLE: dense
202- Each statement = one atomic fact line
203- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
204- Diff lines only (+/-/~), never repeat unchanged code
205- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
206- No narration, no filler, no hedging
207- BUDGET: ≤200 tokens per response unless code block required";
208
209pub const MAX_PROMPT: &str = "\
211OUTPUT STYLE: expert-terse
212- Telegraph format: subject-verb-object, drop articles/prepositions
213- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
214- Code blocks: untouched (never compress code syntax)
215- Each line: max 80 chars
216- Zero narration, zero filler
217- BUDGET: ≤100 tokens per non-code response";
218
219pub fn compression_text(level: CompressionLevel) -> &'static str {
221 match level {
222 CompressionLevel::Off => "",
223 CompressionLevel::Lite => LITE_PROMPT,
224 CompressionLevel::Standard => STANDARD_PROMPT,
225 CompressionLevel::Max => MAX_PROMPT,
226 }
227}
228
229const FULL_NON_SHADOW: &[&str] = &[
230 CRITICAL,
231 MUST_INVOKE,
232 BULLETS,
233 NEVER,
234 INTENT,
235 AGENT_LOOP,
236 ANTI,
237 NAV_PARADOX,
238 PARALLEL,
239 AUTO,
240 CEP,
241 INTELLIGENCE,
242 LITM_END,
243];
244
245const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
250
251const COMPACT_NON_SHADOW: &[&str] = &[
252 CRITICAL,
253 BULLETS,
254 NEVER,
255 INTENT,
256 LOOP_NAV_COMPACT,
257 ANTI,
258 PARALLEL,
259];
260
261const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum Wrapper {
267 Dedicated,
272
273 Shared,
277
278 Bare,
281}
282
283pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
291 let profile = match (wrapper, shadow) {
292 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
293 (Wrapper::Dedicated, true) => FULL_SHADOW,
294 (_, false) => COMPACT_NON_SHADOW,
295 (_, true) => COMPACT_SHADOW,
296 };
297
298 let mut body = profile.join("\n\n");
299
300 let compression = compression_text(level);
305 if !compression.is_empty() {
306 body.push('\n');
307 if matches!(wrapper, Wrapper::Bare) {
308 body.push_str(compression);
309 } else {
310 body.push_str(COMPRESSION_BLOCK_START);
311 body.push('\n');
312 body.push_str(compression);
313 body.push('\n');
314 body.push_str(COMPRESSION_BLOCK_END);
315 }
316 }
317
318 if matches!(wrapper, Wrapper::Bare) {
319 return body;
320 }
321
322 let version_line = format!("<!-- version: {RULES_VERSION} -->");
323
324 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
325}
326#[derive(Debug)]
337pub struct RulesFile<'a> {
338 content: &'a str,
339 start: Option<usize>,
341 end: Option<usize>,
343 version: usize,
345}
346
347fn parse_version_number(s: &str) -> Option<usize> {
350 let prefix = "<!-- version: ";
351 let vs = s.find(prefix)?;
352 let num_start = vs + prefix.len();
353 let end = s[num_start..].find(" -->")?;
354 s[num_start..num_start + end].parse().ok()
355}
356
357impl<'a> RulesFile<'a> {
358 pub fn parse(content: &'a str) -> Self {
364 let start = content.find(START_MARK);
365 let version = start
366 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
367 .unwrap_or(0);
368 let end = content.find(END_MARK);
369 RulesFile {
370 content,
371 start,
372 end,
373 version,
374 }
375 }
376
377 pub fn has_content(&self) -> bool {
379 self.start.is_some()
380 }
381
382 pub fn version(&self) -> usize {
385 self.version
386 }
387
388 pub fn is_current(&self) -> bool {
390 self.version >= RULES_VERSION
391 }
392
393 pub fn prefix(&self) -> &'a str {
396 self.start.map_or("", |s| self.content[..s].trim())
397 }
398
399 pub fn suffix(&self) -> &'a str {
402 self.end
403 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
404 }
405
406 fn block(&self) -> Option<&'a str> {
409 match (self.start, self.end) {
410 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
411 _ => None,
412 }
413 }
414
415 pub fn block_matches_render(
425 &self,
426 shadow: bool,
427 wrapper: Wrapper,
428 level: CompressionLevel,
429 ) -> bool {
430 match self.block() {
431 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
432 None => false,
433 }
434 }
435
436 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
442 let fresh = render(shadow, wrapper, level);
443 if self.start.is_some() {
444 let before = self.prefix();
445 let after = self.suffix();
446 let mut out = String::new();
447 if !before.is_empty() {
448 out.push_str(before);
449 out.push('\n');
450 out.push('\n');
451 }
452 out.push_str(&fresh);
453 if !after.is_empty() {
454 out.push('\n');
455 out.push('\n');
456 out.push_str(after);
457 }
458 if !out.ends_with('\n') {
459 out.push('\n');
460 }
461 out
462 } else {
463 let trimmed = self.content.trim_end();
465 let mut out = trimmed.to_string();
466 if !out.is_empty() {
467 out.push('\n');
468 out.push('\n');
469 }
470 out.push_str(&fresh);
471 out
472 }
473 }
474
475 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
477 render(shadow, wrapper, level)
478 }
479
480 pub fn without_section(&self) -> String {
484 if let Some(start_pos) = self.start {
485 let before = self.content[..start_pos].trim();
486 let after = self.suffix();
487 let mut out = String::new();
488 if !before.is_empty() {
489 out.push_str(before);
490 out.push('\n');
491 }
492 if !after.is_empty() {
493 out.push('\n');
494 out.push_str(after);
495 }
496 out
497 } else {
498 self.content.to_string()
499 }
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
510 fn bullets_uses_ctx_shell() {
511 assert!(BULLETS.contains("ctx_shell"));
512 assert!(!BULLETS.contains("lean-ctx -c"));
513 assert!(!BULLETS.contains("ctx_edit"));
514 }
515
516 #[test]
517 fn sections_not_empty() {
518 assert!(!BULLETS.is_empty());
519 assert!(!NEVER.is_empty());
520 assert!(!INTENT.is_empty());
521 assert!(!ANTI.is_empty());
522 assert!(!PARALLEL.is_empty());
523 assert!(!AUTO.is_empty());
524 assert!(!CEP.is_empty());
525 assert!(!INTELLIGENCE.is_empty());
526 assert!(!LITM_END.is_empty());
527 assert!(!CRITICAL.is_empty());
528 }
529
530 #[test]
531 fn intent_contains_ctx_compose() {
532 assert!(INTENT.contains("ctx_compose"));
533 }
534
535 #[test]
536 fn anti_contains_do_not() {
537 assert!(ANTI.contains("do NOT"));
538 }
539
540 #[test]
541 fn parallel_contains_parallel() {
542 assert!(PARALLEL.contains("PARALLEL"));
543 }
544
545 #[test]
548 fn agent_loop_names_every_phase() {
549 for phase in ["Orient", "Find", "Read", "Locate", "Trace", "Verify"] {
550 assert!(AGENT_LOOP.contains(phase), "AGENT_LOOP must name {phase}");
551 }
552 assert!(AGENT_LOOP.contains("ctx_compose") && AGENT_LOOP.contains("ctx_callgraph"));
553 }
554
555 #[test]
556 fn nav_paradox_steers_semantic_vs_graph() {
557 assert!(
558 NAV_PARADOX.contains("ctx_semantic_search"),
559 "semantic route"
560 );
561 assert!(NAV_PARADOX.contains("ctx_callgraph"), "graph route");
562 assert!(
563 NAV_PARADOX.contains("≠"),
564 "must carry the reading≠understanding thesis"
565 );
566 }
567
568 #[test]
569 fn full_profile_carries_loop_and_paradox() {
570 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
571 assert!(out.contains("AGENT LOOP"), "FULL must carry AGENT_LOOP");
572 assert!(
573 out.contains("NAVIGATION PARADOX"),
574 "FULL must carry NAV_PARADOX"
575 );
576 }
577
578 #[test]
579 fn compact_profile_uses_one_liner_not_full_sections() {
580 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
583 assert!(
584 out.contains(LOOP_NAV_COMPACT),
585 "COMPACT must carry one-liner"
586 );
587 assert!(
588 !out.contains("AGENT LOOP (phase -> tool):"),
589 "COMPACT must not inline the multi-line AGENT_LOOP block"
590 );
591 assert!(
592 !out.contains("NAVIGATION PARADOX: reading"),
593 "COMPACT must not inline the multi-line NAV_PARADOX block"
594 );
595 }
596
597 #[test]
598 fn shadow_omits_loop_and_paradox() {
599 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
602 let out = render(true, wrapper, CompressionLevel::Off);
603 assert!(!out.contains("AGENT LOOP"), "{wrapper:?} shadow drops loop");
604 assert!(
605 !out.contains("NAVIGATION PARADOX"),
606 "{wrapper:?} shadow drops paradox"
607 );
608 }
609 }
610
611 #[test]
614 fn dedicated_has_markers_and_version() {
615 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
616 assert!(out.contains(START_MARK));
617 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
618 assert!(out.contains(END_MARK));
619 assert!(out.contains(BULLETS));
620 assert!(out.contains(NEVER));
621 assert!(out.contains("CRITICAL"));
622 }
623
624 #[test]
625 fn dedicated_shadow_is_minimal() {
626 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
630 assert!(out.contains(START_MARK));
631 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
632 assert!(!out.contains(NEVER), "no NEVER in shadow");
633 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
634 assert!(
635 !out.contains("Tool selection by intent"),
636 "routing INTENT block is redundant under interception"
637 );
638 assert!(
639 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
640 "ANTI/PARALLEL routing guidance is dropped in shadow"
641 );
642 assert!(
643 out.contains("shadow mode") && out.contains("ctx_compose"),
644 "shadow keeps the exclusive-tool advert"
645 );
646 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
647 }
648
649 #[test]
650 fn shadow_is_smaller_than_non_shadow() {
651 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
653 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
654 assert!(
655 shadow.len() < full.len(),
656 "shadow ({}) must be smaller than non-shadow ({})",
657 shadow.len(),
658 full.len()
659 );
660 }
661
662 #[test]
663 fn dedicated_litm_structure() {
664 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
665 let lines: Vec<&str> = out.lines().collect();
666 let first_5 = lines[..5.min(lines.len())].join("\n");
667 assert!(
668 first_5.contains("CRITICAL") || first_5.contains("MUST"),
669 "LITM: MUST/CRITICAL instruction near start"
670 );
671 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
673 assert!(
674 tail.contains("PREFERENCE") || tail.contains("NEVER"),
675 "LITM: reinforcement near end, tail={tail:?}"
676 );
677 }
678
679 #[test]
680 fn dedicated_carries_weak_model_invoke_nudge() {
681 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Off);
686 assert!(
687 dedicated.contains(MUST_INVOKE),
688 "dedicated non-shadow rules must carry the MUST_INVOKE nudge"
689 );
690 assert!(
691 !render(true, Wrapper::Dedicated, CompressionLevel::Off).contains(MUST_INVOKE),
692 "shadow mode must not carry the nudge (routing is enforced at the call layer)"
693 );
694 assert!(
695 !render(false, Wrapper::Bare, CompressionLevel::Off).contains(MUST_INVOKE),
696 "Bare/instructions channel is capped separately and carries no copy"
697 );
698 }
699
700 #[test]
703 fn shared_has_markers_and_header() {
704 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
705 assert!(out.contains(START_MARK));
706 assert!(out.contains(END_MARK));
707 assert!(out.contains("MANDATORY MAPPING"));
708 assert!(out.contains(BULLETS));
709 }
710
711 #[test]
712 fn shared_shadow_omits_mapping() {
713 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
714 assert!(out.contains(START_MARK));
715 assert!(
716 !out.contains("MANDATORY MAPPING"),
717 "shadow must not have header"
718 );
719 assert!(
720 !out.contains("MANDATORY MAPPING"),
721 "shadow must not contain BULLETS"
722 );
723 }
724
725 #[test]
728 fn bare_has_no_markers() {
729 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
730 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
731 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
732 assert!(!out.contains("<!-- version:"), "Bare must not have version");
733 assert!(out.contains(BULLETS));
734 assert!(out.contains(NEVER));
735 }
736
737 #[test]
738 fn bare_shadow_only_read_modes() {
739 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
740 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
741 assert!(
742 !out.contains("MANDATORY MAPPING"),
743 "shadow Bare must not have BULLETS"
744 );
745 }
746
747 #[test]
750 fn render_includes_lite_prompt() {
751 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
752 assert!(out.contains("OUTPUT STYLE: concise"));
753 assert!(out.contains("Bullet points"));
754 }
755
756 #[test]
757 fn render_includes_standard_prompt() {
758 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
759 assert!(out.contains("OUTPUT STYLE: dense"));
760 assert!(out.contains("atomic fact"));
761 }
762
763 #[test]
764 fn render_includes_max_prompt() {
765 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
766 assert!(out.contains("OUTPUT STYLE: expert-terse"));
767 assert!(out.contains("Telegraph"));
768 }
769
770 #[test]
771 fn render_off_excludes_compression() {
772 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
773 assert!(!out.contains("OUTPUT STYLE:"));
774 }
775
776 #[test]
777 fn compression_text_matches_level() {
778 assert!(compression_text(CompressionLevel::Off).is_empty());
779 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
780 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
781 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
782 }
783
784 #[test]
787 fn carrier_wrappers_wrap_compression_in_markers() {
788 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
791 let out = render(false, wrapper, CompressionLevel::Standard);
792 assert!(
793 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
794 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
795 );
796 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
798 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
799 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
800 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
801 }
802 }
803
804 #[test]
805 fn bare_wrapper_emits_compression_without_markers() {
806 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
809 assert!(out.contains("OUTPUT STYLE: dense"));
810 assert!(!out.contains(COMPRESSION_BLOCK_START));
811 assert!(!out.contains(COMPRESSION_BLOCK_END));
812 }
813
814 #[test]
815 fn compression_off_emits_no_markers_in_any_wrapper() {
816 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
817 let out = render(false, wrapper, CompressionLevel::Off);
818 assert!(
819 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
820 "{wrapper:?}: Off must emit no compression markers"
821 );
822 }
823 }
824
825 #[test]
826 fn rendered_carrier_block_is_seen_as_carrying_compression() {
827 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
830 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
831 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
832 }
833
834 #[test]
837 fn all_wrappers_produce_output() {
838 for shadow in [false, true] {
839 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
840 let out = render(shadow, wrapper, CompressionLevel::Off);
841 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
842 }
843 }
844 }
845
846 #[test]
849 fn rules_file_parses_version() {
850 let content = format!(
851 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
852 );
853 let f = RulesFile::parse(&content);
854 assert!(f.has_content());
855 assert_eq!(f.version(), RULES_VERSION);
856 assert!(f.is_current());
857 assert!(f.prefix().contains("stuff before"));
858 assert!(f.suffix().contains("stuff after"));
859 }
860
861 #[test]
862 fn rules_file_no_version_defaults_to_zero() {
863 let content = format!("{START_MARK}\nbody\n{END_MARK}");
864 let f = RulesFile::parse(&content);
865 assert!(f.has_content());
866 assert_eq!(f.version(), 0);
867 assert!(!f.is_current());
868 }
869
870 #[test]
871 fn rules_file_no_start_marker_no_content() {
872 let f = RulesFile::parse("just user stuff");
873 assert!(!f.has_content());
874 assert_eq!(f.version(), 0);
875 }
876
877 #[test]
878 fn block_matches_render_true_for_fresh_render() {
879 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
880 let content = format!("user before\n{fresh}\nuser after");
881 let f = RulesFile::parse(&content);
882 assert!(f.is_current(), "fresh render carries the current version");
883 assert!(
884 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
885 "an unchanged block must compare equal to a fresh render"
886 );
887 }
888
889 #[test]
890 fn block_matches_render_false_on_compression_change() {
891 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
894 let f = RulesFile::parse(&content);
895 assert!(f.is_current());
896 assert!(
897 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
898 "a compression-level change must be detected as drift"
899 );
900 }
901
902 #[test]
903 fn block_matches_render_false_on_shadow_change() {
904 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
905 let f = RulesFile::parse(&content);
906 assert!(
907 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
908 "a shadow-mode toggle must be detected as drift"
909 );
910 }
911
912 #[test]
913 fn block_matches_render_false_without_block() {
914 let f = RulesFile::parse("plain user content, no markers");
915 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
916 }
917
918 #[test]
919 fn rules_file_merged_replaces_section() {
920 let content =
921 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
922 let f = RulesFile::parse(&content);
923 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
924 assert!(merged.contains("before"), "prefix preserved");
925 assert!(merged.contains("after"), "suffix preserved");
926 assert!(!merged.contains("old"), "old content replaced");
927 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
928 }
929
930 #[test]
931 fn rules_file_merged_appends_when_no_section() {
932 let content = "user content";
933 let f = RulesFile::parse(content);
934 assert!(!f.has_content());
935 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
936 assert!(merged.contains("user content"));
937 assert!(merged.contains(BULLETS));
938 }
939
940 #[test]
941 fn rules_file_without_section_strips_content() {
942 let content =
943 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
944 let f = RulesFile::parse(&content);
945 let stripped = f.without_section();
946 assert!(stripped.contains("header"));
947 assert!(stripped.contains("footer"));
948 assert!(!stripped.contains("body"));
949 assert!(!stripped.contains(START_MARK));
950 }
951
952 #[test]
953 fn rules_file_without_section_noop_when_no_content() {
954 let content = "just user text";
955 let f = RulesFile::parse(content);
956 assert_eq!(f.without_section(), content);
957 }
958
959 #[test]
960 fn bullets_lead_with_four_core_redirects() {
961 let read = BULLETS.find("ctx_read").expect("ctx_read mapping present");
963 let search = BULLETS
964 .find("ctx_search")
965 .expect("ctx_search mapping present");
966 let shell = BULLETS
967 .find("ctx_shell")
968 .expect("ctx_shell mapping present");
969 let glob = BULLETS.find("ctx_glob").expect("ctx_glob mapping present");
970 let tree = BULLETS.find("ctx_tree").expect("ctx_tree mapping present");
971 assert!(
972 read < search && search < shell && shell < glob && glob < tree,
973 "core redirects (read<search<shell<glob) must precede ctx_tree"
974 );
975 }
976
977 #[test]
978 fn never_carries_self_correction() {
979 assert!(
981 NEVER.contains("SELF-CORRECT"),
982 "NEVER must teach self-correction"
983 );
984 assert!(
985 NEVER.contains("call"),
986 "NEVER must spell out the corrective action"
987 );
988 }
989
990 #[test]
991 fn critical_names_ctx_family() {
992 assert!(
993 CRITICAL.contains("ctx_*"),
994 "CRITICAL must name the ctx_* family"
995 );
996 }
997}