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 END_MARK: &str = "<!-- /lean-ctx-rules -->";
37
38pub const COMPRESSION_BLOCK_START: &str = "<!-- lean-ctx-compression -->";
50
51pub const COMPRESSION_BLOCK_END: &str = "<!-- /lean-ctx-compression -->";
54
55pub const RULES_VERSION: usize = 1;
59
60pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx tools instead of native equivalents. \
62 This is NOT optional.";
63
64pub const BULLETS: &str = "\
66MANDATORY MAPPING:\n\
67• Read/cat -> ctx_read(path, mode)\n\
68• Glob/find -> ctx_glob(pattern)\n\
69• Shell/bash -> ctx_shell(command)\n\
70• Grep -> ctx_search(pattern, path)\n\
71• ls/find -> ctx_tree(path, depth)";
72
73pub const NEVER: &str =
76 "NEVER use native Read/Grep/Shell/Glob when ctx_* equivalents are available.";
77
78pub const INTENT: &str = "\
80Tool selection by intent:\n\
81• Understand code / find answers / before editing -> ctx_compose (call FIRST)\n\
82• Read a file -> ctx_read(path, mode=signatures|map|full)\n\
83• Find a symbol by name (exact) -> ctx_symbol\n\
84• Search code by pattern (fuzzy) -> ctx_search\n\
85• Search by meaning (concepts) -> ctx_semantic_search\n\
86• Find files by pattern (glob) -> ctx_glob\n\
87• Project structure -> ctx_tree\n\
88• Who calls this / call graph -> ctx_callgraph\n\
89• Session state / memory -> ctx_session / ctx_knowledge";
90
91pub const ANTI: &str = "\
93Anti-patterns — do NOT:\n\
94• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
95• Grep for symbol definitions — ctx_symbol is faster + more precise\n\
96• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
97• Use ctx_callgraph or ctx_graph for const/static/variable references — they track\n\
98 function call edges and file-level deps only. Use grep or ctx_compose instead";
99
100pub const PARALLEL: &str = "\
102PARALLEL tool calls: fire independent calls in the SAME turn — don't sequence them.\n\
103ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
104cover, batch independent reads/searches together.";
105
106pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
108 ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
109 ctx_shell raw=true=uncompressed. Details: LEAN-CTX.md";
110
111pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
113 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
114
115pub const INTELLIGENCE: &str =
117 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
118
119pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
121 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
122
123pub const SHADOW_MINIMAL: &str = "\
129lean-ctx shadow mode: native file/search/shell calls auto-route to ctx_* — no tool-mapping needed.\n\
130Exclusive 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).";
131
132pub const LITE_PROMPT: &str = "\
136OUTPUT STYLE: concise
137- Bullet points over paragraphs
138- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
139- 1-sentence explanations max, then code/action
140- No repeating what the user said";
141
142pub const STANDARD_PROMPT: &str = "\
144OUTPUT STYLE: dense
145- Each statement = one atomic fact line
146- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
147- Diff lines only (+/-/~), never repeat unchanged code
148- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
149- No narration, no filler, no hedging
150- BUDGET: ≤200 tokens per response unless code block required";
151
152pub const MAX_PROMPT: &str = "\
154OUTPUT STYLE: expert-terse
155- Telegraph format: subject-verb-object, drop articles/prepositions
156- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
157- Code blocks: untouched (never compress code syntax)
158- Each line: max 80 chars
159- Zero narration, zero filler
160- BUDGET: ≤100 tokens per non-code response";
161
162pub fn compression_text(level: CompressionLevel) -> &'static str {
164 match level {
165 CompressionLevel::Off => "",
166 CompressionLevel::Lite => LITE_PROMPT,
167 CompressionLevel::Standard => STANDARD_PROMPT,
168 CompressionLevel::Max => MAX_PROMPT,
169 }
170}
171
172const FULL_NON_SHADOW: &[&str] = &[
173 CRITICAL,
174 BULLETS,
175 NEVER,
176 INTENT,
177 ANTI,
178 PARALLEL,
179 AUTO,
180 CEP,
181 INTELLIGENCE,
182 LITM_END,
183];
184
185const FULL_SHADOW: &[&str] = &[SHADOW_MINIMAL, INTELLIGENCE];
190
191const COMPACT_NON_SHADOW: &[&str] = &[CRITICAL, BULLETS, NEVER, INTENT, ANTI, PARALLEL];
192
193const COMPACT_SHADOW: &[&str] = &[SHADOW_MINIMAL];
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum Wrapper {
199 Dedicated,
204
205 Shared,
209
210 Bare,
213}
214
215pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
223 let profile = match (wrapper, shadow) {
224 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
225 (Wrapper::Dedicated, true) => FULL_SHADOW,
226 (_, false) => COMPACT_NON_SHADOW,
227 (_, true) => COMPACT_SHADOW,
228 };
229
230 let mut body = profile.join("\n\n");
231
232 let compression = compression_text(level);
237 if !compression.is_empty() {
238 body.push('\n');
239 if matches!(wrapper, Wrapper::Bare) {
240 body.push_str(compression);
241 } else {
242 body.push_str(COMPRESSION_BLOCK_START);
243 body.push('\n');
244 body.push_str(compression);
245 body.push('\n');
246 body.push_str(COMPRESSION_BLOCK_END);
247 }
248 }
249
250 if matches!(wrapper, Wrapper::Bare) {
251 return body;
252 }
253
254 let version_line = format!("<!-- version: {RULES_VERSION} -->");
255
256 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
257}
258#[derive(Debug)]
269pub struct RulesFile<'a> {
270 content: &'a str,
271 start: Option<usize>,
273 end: Option<usize>,
275 version: usize,
277}
278
279fn parse_version_number(s: &str) -> Option<usize> {
282 let prefix = "<!-- version: ";
283 let vs = s.find(prefix)?;
284 let num_start = vs + prefix.len();
285 let end = s[num_start..].find(" -->")?;
286 s[num_start..num_start + end].parse().ok()
287}
288
289impl<'a> RulesFile<'a> {
290 pub fn parse(content: &'a str) -> Self {
296 let start = content.find(START_MARK);
297 let version = start
298 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
299 .unwrap_or(0);
300 let end = content.find(END_MARK);
301 RulesFile {
302 content,
303 start,
304 end,
305 version,
306 }
307 }
308
309 pub fn has_content(&self) -> bool {
311 self.start.is_some()
312 }
313
314 pub fn version(&self) -> usize {
317 self.version
318 }
319
320 pub fn is_current(&self) -> bool {
322 self.version >= RULES_VERSION
323 }
324
325 pub fn prefix(&self) -> &'a str {
328 self.start.map_or("", |s| self.content[..s].trim())
329 }
330
331 pub fn suffix(&self) -> &'a str {
334 self.end
335 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
336 }
337
338 fn block(&self) -> Option<&'a str> {
341 match (self.start, self.end) {
342 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
343 _ => None,
344 }
345 }
346
347 pub fn block_matches_render(
357 &self,
358 shadow: bool,
359 wrapper: Wrapper,
360 level: CompressionLevel,
361 ) -> bool {
362 match self.block() {
363 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
364 None => false,
365 }
366 }
367
368 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
374 let fresh = render(shadow, wrapper, level);
375 if self.start.is_some() {
376 let before = self.prefix();
377 let after = self.suffix();
378 let mut out = String::new();
379 if !before.is_empty() {
380 out.push_str(before);
381 out.push('\n');
382 out.push('\n');
383 }
384 out.push_str(&fresh);
385 if !after.is_empty() {
386 out.push('\n');
387 out.push('\n');
388 out.push_str(after);
389 }
390 if !out.ends_with('\n') {
391 out.push('\n');
392 }
393 out
394 } else {
395 let trimmed = self.content.trim_end();
397 let mut out = trimmed.to_string();
398 if !out.is_empty() {
399 out.push('\n');
400 out.push('\n');
401 }
402 out.push_str(&fresh);
403 out
404 }
405 }
406
407 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
409 render(shadow, wrapper, level)
410 }
411
412 pub fn without_section(&self) -> String {
416 if let Some(start_pos) = self.start {
417 let before = self.content[..start_pos].trim();
418 let after = self.suffix();
419 let mut out = String::new();
420 if !before.is_empty() {
421 out.push_str(before);
422 out.push('\n');
423 }
424 if !after.is_empty() {
425 out.push('\n');
426 out.push_str(after);
427 }
428 out
429 } else {
430 self.content.to_string()
431 }
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
442 fn bullets_uses_ctx_shell() {
443 assert!(BULLETS.contains("ctx_shell"));
444 assert!(!BULLETS.contains("lean-ctx -c"));
445 assert!(!BULLETS.contains("ctx_edit"));
446 }
447
448 #[test]
449 fn sections_not_empty() {
450 assert!(!BULLETS.is_empty());
451 assert!(!NEVER.is_empty());
452 assert!(!INTENT.is_empty());
453 assert!(!ANTI.is_empty());
454 assert!(!PARALLEL.is_empty());
455 assert!(!AUTO.is_empty());
456 assert!(!CEP.is_empty());
457 assert!(!INTELLIGENCE.is_empty());
458 assert!(!LITM_END.is_empty());
459 assert!(!CRITICAL.is_empty());
460 }
461
462 #[test]
463 fn intent_contains_ctx_compose() {
464 assert!(INTENT.contains("ctx_compose"));
465 }
466
467 #[test]
468 fn anti_contains_do_not() {
469 assert!(ANTI.contains("do NOT"));
470 }
471
472 #[test]
473 fn parallel_contains_parallel() {
474 assert!(PARALLEL.contains("PARALLEL"));
475 }
476
477 #[test]
480 fn dedicated_has_markers_and_version() {
481 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
482 assert!(out.contains(START_MARK));
483 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
484 assert!(out.contains(END_MARK));
485 assert!(out.contains(BULLETS));
486 assert!(out.contains(NEVER));
487 assert!(out.contains("CRITICAL"));
488 }
489
490 #[test]
491 fn dedicated_shadow_is_minimal() {
492 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
496 assert!(out.contains(START_MARK));
497 assert!(!out.contains("MANDATORY MAPPING"), "no BULLETS in shadow");
498 assert!(!out.contains(NEVER), "no NEVER in shadow");
499 assert!(!out.contains("CRITICAL"), "no CRITICAL banner in shadow");
500 assert!(
501 !out.contains("Tool selection by intent"),
502 "routing INTENT block is redundant under interception"
503 );
504 assert!(
505 !out.contains("Anti-patterns") && !out.contains("PARALLEL tool calls"),
506 "ANTI/PARALLEL routing guidance is dropped in shadow"
507 );
508 assert!(
509 out.contains("shadow mode") && out.contains("ctx_compose"),
510 "shadow keeps the exclusive-tool advert"
511 );
512 assert!(out.contains(INTELLIGENCE), "shadow keeps the output style");
513 }
514
515 #[test]
516 fn shadow_is_smaller_than_non_shadow() {
517 let shadow = render(true, Wrapper::Dedicated, CompressionLevel::Off);
519 let full = render(false, Wrapper::Dedicated, CompressionLevel::Off);
520 assert!(
521 shadow.len() < full.len(),
522 "shadow ({}) must be smaller than non-shadow ({})",
523 shadow.len(),
524 full.len()
525 );
526 }
527
528 #[test]
529 fn dedicated_litm_structure() {
530 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
531 let lines: Vec<&str> = out.lines().collect();
532 let first_5 = lines[..5.min(lines.len())].join("\n");
533 assert!(
534 first_5.contains("CRITICAL") || first_5.contains("MUST"),
535 "LITM: MUST/CRITICAL instruction near start"
536 );
537 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
539 assert!(
540 tail.contains("PREFERENCE") || tail.contains("NEVER"),
541 "LITM: reinforcement near end, tail={tail:?}"
542 );
543 }
544
545 #[test]
548 fn shared_has_markers_and_header() {
549 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
550 assert!(out.contains(START_MARK));
551 assert!(out.contains(END_MARK));
552 assert!(out.contains("MANDATORY MAPPING"));
553 assert!(out.contains(BULLETS));
554 }
555
556 #[test]
557 fn shared_shadow_omits_mapping() {
558 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
559 assert!(out.contains(START_MARK));
560 assert!(
561 !out.contains("MANDATORY MAPPING"),
562 "shadow must not have header"
563 );
564 assert!(
565 !out.contains("MANDATORY MAPPING"),
566 "shadow must not contain BULLETS"
567 );
568 }
569
570 #[test]
573 fn bare_has_no_markers() {
574 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
575 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
576 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
577 assert!(!out.contains("<!-- version:"), "Bare must not have version");
578 assert!(out.contains(BULLETS));
579 assert!(out.contains(NEVER));
580 }
581
582 #[test]
583 fn bare_shadow_only_read_modes() {
584 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
585 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
586 assert!(
587 !out.contains("MANDATORY MAPPING"),
588 "shadow Bare must not have BULLETS"
589 );
590 }
591
592 #[test]
595 fn render_includes_lite_prompt() {
596 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
597 assert!(out.contains("OUTPUT STYLE: concise"));
598 assert!(out.contains("Bullet points"));
599 }
600
601 #[test]
602 fn render_includes_standard_prompt() {
603 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
604 assert!(out.contains("OUTPUT STYLE: dense"));
605 assert!(out.contains("atomic fact"));
606 }
607
608 #[test]
609 fn render_includes_max_prompt() {
610 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
611 assert!(out.contains("OUTPUT STYLE: expert-terse"));
612 assert!(out.contains("Telegraph"));
613 }
614
615 #[test]
616 fn render_off_excludes_compression() {
617 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
618 assert!(!out.contains("OUTPUT STYLE:"));
619 }
620
621 #[test]
622 fn compression_text_matches_level() {
623 assert!(compression_text(CompressionLevel::Off).is_empty());
624 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
625 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
626 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
627 }
628
629 #[test]
632 fn carrier_wrappers_wrap_compression_in_markers() {
633 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
636 let out = render(false, wrapper, CompressionLevel::Standard);
637 assert!(
638 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
639 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
640 );
641 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
643 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
644 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
645 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
646 }
647 }
648
649 #[test]
650 fn bare_wrapper_emits_compression_without_markers() {
651 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
654 assert!(out.contains("OUTPUT STYLE: dense"));
655 assert!(!out.contains(COMPRESSION_BLOCK_START));
656 assert!(!out.contains(COMPRESSION_BLOCK_END));
657 }
658
659 #[test]
660 fn compression_off_emits_no_markers_in_any_wrapper() {
661 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
662 let out = render(false, wrapper, CompressionLevel::Off);
663 assert!(
664 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
665 "{wrapper:?}: Off must emit no compression markers"
666 );
667 }
668 }
669
670 #[test]
671 fn rendered_carrier_block_is_seen_as_carrying_compression() {
672 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
675 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
676 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
677 }
678
679 #[test]
682 fn all_wrappers_produce_output() {
683 for shadow in [false, true] {
684 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
685 let out = render(shadow, wrapper, CompressionLevel::Off);
686 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
687 }
688 }
689 }
690
691 #[test]
694 fn rules_file_parses_version() {
695 let content = format!(
696 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
697 );
698 let f = RulesFile::parse(&content);
699 assert!(f.has_content());
700 assert_eq!(f.version(), RULES_VERSION);
701 assert!(f.is_current());
702 assert!(f.prefix().contains("stuff before"));
703 assert!(f.suffix().contains("stuff after"));
704 }
705
706 #[test]
707 fn rules_file_no_version_defaults_to_zero() {
708 let content = format!("{START_MARK}\nbody\n{END_MARK}");
709 let f = RulesFile::parse(&content);
710 assert!(f.has_content());
711 assert_eq!(f.version(), 0);
712 assert!(!f.is_current());
713 }
714
715 #[test]
716 fn rules_file_no_start_marker_no_content() {
717 let f = RulesFile::parse("just user stuff");
718 assert!(!f.has_content());
719 assert_eq!(f.version(), 0);
720 }
721
722 #[test]
723 fn block_matches_render_true_for_fresh_render() {
724 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
725 let content = format!("user before\n{fresh}\nuser after");
726 let f = RulesFile::parse(&content);
727 assert!(f.is_current(), "fresh render carries the current version");
728 assert!(
729 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
730 "an unchanged block must compare equal to a fresh render"
731 );
732 }
733
734 #[test]
735 fn block_matches_render_false_on_compression_change() {
736 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
739 let f = RulesFile::parse(&content);
740 assert!(f.is_current());
741 assert!(
742 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
743 "a compression-level change must be detected as drift"
744 );
745 }
746
747 #[test]
748 fn block_matches_render_false_on_shadow_change() {
749 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
750 let f = RulesFile::parse(&content);
751 assert!(
752 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
753 "a shadow-mode toggle must be detected as drift"
754 );
755 }
756
757 #[test]
758 fn block_matches_render_false_without_block() {
759 let f = RulesFile::parse("plain user content, no markers");
760 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
761 }
762
763 #[test]
764 fn rules_file_merged_replaces_section() {
765 let content =
766 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
767 let f = RulesFile::parse(&content);
768 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
769 assert!(merged.contains("before"), "prefix preserved");
770 assert!(merged.contains("after"), "suffix preserved");
771 assert!(!merged.contains("old"), "old content replaced");
772 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
773 }
774
775 #[test]
776 fn rules_file_merged_appends_when_no_section() {
777 let content = "user content";
778 let f = RulesFile::parse(content);
779 assert!(!f.has_content());
780 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
781 assert!(merged.contains("user content"));
782 assert!(merged.contains(BULLETS));
783 }
784
785 #[test]
786 fn rules_file_without_section_strips_content() {
787 let content =
788 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
789 let f = RulesFile::parse(&content);
790 let stripped = f.without_section();
791 assert!(stripped.contains("header"));
792 assert!(stripped.contains("footer"));
793 assert!(!stripped.contains("body"));
794 assert!(!stripped.contains(START_MARK));
795 }
796
797 #[test]
798 fn rules_file_without_section_noop_when_no_content() {
799 let content = "just user text";
800 let f = RulesFile::parse(content);
801 assert_eq!(f.without_section(), content);
802 }
803}