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\
103One turn with 5 parallel ctx_read calls completes faster than 5 sequential turns.\n\
104ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
105cover, batch independent reads/searches together.";
106
107pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
109 ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
110 ctx_shell raw=true=uncompressed. Details: LEAN-CTX.md";
111
112pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
114 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
115
116pub const INTELLIGENCE: &str =
118 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
119
120pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
122 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
123
124pub const LITE_PROMPT: &str = "\
128OUTPUT STYLE: concise
129- Bullet points over paragraphs
130- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
131- 1-sentence explanations max, then code/action
132- No repeating what the user said";
133
134pub const STANDARD_PROMPT: &str = "\
136OUTPUT STYLE: dense
137- Each statement = one atomic fact line
138- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
139- Diff lines only (+/-/~), never repeat unchanged code
140- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
141- No narration, no filler, no hedging
142- BUDGET: ≤200 tokens per response unless code block required";
143
144pub const MAX_PROMPT: &str = "\
146OUTPUT STYLE: expert-terse
147- Telegraph format: subject-verb-object, drop articles/prepositions
148- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
149- Code blocks: untouched (never compress code syntax)
150- Each line: max 80 chars
151- Zero narration, zero filler
152- BUDGET: ≤100 tokens per non-code response";
153
154pub fn compression_text(level: CompressionLevel) -> &'static str {
156 match level {
157 CompressionLevel::Off => "",
158 CompressionLevel::Lite => LITE_PROMPT,
159 CompressionLevel::Standard => STANDARD_PROMPT,
160 CompressionLevel::Max => MAX_PROMPT,
161 }
162}
163
164const FULL_NON_SHADOW: &[&str] = &[
165 CRITICAL,
166 BULLETS,
167 NEVER,
168 INTENT,
169 ANTI,
170 PARALLEL,
171 AUTO,
172 CEP,
173 INTELLIGENCE,
174 LITM_END,
175];
176
177const FULL_SHADOW: &[&str] = &[INTENT, ANTI, PARALLEL, AUTO, CEP, INTELLIGENCE, LITM_END];
178
179const COMPACT_NON_SHADOW: &[&str] = &[CRITICAL, BULLETS, NEVER, INTENT, ANTI, PARALLEL];
180
181const COMPACT_SHADOW: &[&str] = &[INTENT, ANTI, PARALLEL];
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Wrapper {
187 Dedicated,
192
193 Shared,
197
198 Bare,
201}
202
203pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
211 let profile = match (wrapper, shadow) {
212 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
213 (Wrapper::Dedicated, true) => FULL_SHADOW,
214 (_, false) => COMPACT_NON_SHADOW,
215 (_, true) => COMPACT_SHADOW,
216 };
217
218 let mut body = profile.join("\n\n");
219
220 let compression = compression_text(level);
225 if !compression.is_empty() {
226 body.push('\n');
227 if matches!(wrapper, Wrapper::Bare) {
228 body.push_str(compression);
229 } else {
230 body.push_str(COMPRESSION_BLOCK_START);
231 body.push('\n');
232 body.push_str(compression);
233 body.push('\n');
234 body.push_str(COMPRESSION_BLOCK_END);
235 }
236 }
237
238 if matches!(wrapper, Wrapper::Bare) {
239 return body;
240 }
241
242 let version_line = format!("<!-- version: {RULES_VERSION} -->");
243
244 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
245}
246#[derive(Debug)]
257pub struct RulesFile<'a> {
258 content: &'a str,
259 start: Option<usize>,
261 end: Option<usize>,
263 version: usize,
265}
266
267fn parse_version_number(s: &str) -> Option<usize> {
270 let prefix = "<!-- version: ";
271 let vs = s.find(prefix)?;
272 let num_start = vs + prefix.len();
273 let end = s[num_start..].find(" -->")?;
274 s[num_start..num_start + end].parse().ok()
275}
276
277impl<'a> RulesFile<'a> {
278 pub fn parse(content: &'a str) -> Self {
284 let start = content.find(START_MARK);
285 let version = start
286 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
287 .unwrap_or(0);
288 let end = content.find(END_MARK);
289 RulesFile {
290 content,
291 start,
292 end,
293 version,
294 }
295 }
296
297 pub fn has_content(&self) -> bool {
299 self.start.is_some()
300 }
301
302 pub fn version(&self) -> usize {
305 self.version
306 }
307
308 pub fn is_current(&self) -> bool {
310 self.version >= RULES_VERSION
311 }
312
313 pub fn prefix(&self) -> &'a str {
316 self.start.map_or("", |s| self.content[..s].trim())
317 }
318
319 pub fn suffix(&self) -> &'a str {
322 self.end
323 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
324 }
325
326 fn block(&self) -> Option<&'a str> {
329 match (self.start, self.end) {
330 (Some(s), Some(e)) if e >= s => Some(&self.content[s..e + END_MARK.len()]),
331 _ => None,
332 }
333 }
334
335 pub fn block_matches_render(
345 &self,
346 shadow: bool,
347 wrapper: Wrapper,
348 level: CompressionLevel,
349 ) -> bool {
350 match self.block() {
351 Some(block) => block.trim() == render(shadow, wrapper, level).trim(),
352 None => false,
353 }
354 }
355
356 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
362 let fresh = render(shadow, wrapper, level);
363 if self.start.is_some() {
364 let before = self.prefix();
365 let after = self.suffix();
366 let mut out = String::new();
367 if !before.is_empty() {
368 out.push_str(before);
369 out.push('\n');
370 out.push('\n');
371 }
372 out.push_str(&fresh);
373 if !after.is_empty() {
374 out.push('\n');
375 out.push('\n');
376 out.push_str(after);
377 }
378 if !out.ends_with('\n') {
379 out.push('\n');
380 }
381 out
382 } else {
383 let trimmed = self.content.trim_end();
385 let mut out = trimmed.to_string();
386 if !out.is_empty() {
387 out.push('\n');
388 out.push('\n');
389 }
390 out.push_str(&fresh);
391 out
392 }
393 }
394
395 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
397 render(shadow, wrapper, level)
398 }
399
400 pub fn without_section(&self) -> String {
404 if let Some(start_pos) = self.start {
405 let before = self.content[..start_pos].trim();
406 let after = self.suffix();
407 let mut out = String::new();
408 if !before.is_empty() {
409 out.push_str(before);
410 out.push('\n');
411 }
412 if !after.is_empty() {
413 out.push('\n');
414 out.push_str(after);
415 }
416 out
417 } else {
418 self.content.to_string()
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
430 fn bullets_uses_ctx_shell() {
431 assert!(BULLETS.contains("ctx_shell"));
432 assert!(!BULLETS.contains("lean-ctx -c"));
433 assert!(!BULLETS.contains("ctx_edit"));
434 }
435
436 #[test]
437 fn sections_not_empty() {
438 assert!(!BULLETS.is_empty());
439 assert!(!NEVER.is_empty());
440 assert!(!INTENT.is_empty());
441 assert!(!ANTI.is_empty());
442 assert!(!PARALLEL.is_empty());
443 assert!(!AUTO.is_empty());
444 assert!(!CEP.is_empty());
445 assert!(!INTELLIGENCE.is_empty());
446 assert!(!LITM_END.is_empty());
447 assert!(!CRITICAL.is_empty());
448 }
449
450 #[test]
451 fn intent_contains_ctx_compose() {
452 assert!(INTENT.contains("ctx_compose"));
453 }
454
455 #[test]
456 fn anti_contains_do_not() {
457 assert!(ANTI.contains("do NOT"));
458 }
459
460 #[test]
461 fn parallel_contains_parallel() {
462 assert!(PARALLEL.contains("PARALLEL"));
463 }
464
465 #[test]
468 fn dedicated_has_markers_and_version() {
469 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
470 assert!(out.contains(START_MARK));
471 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
472 assert!(out.contains(END_MARK));
473 assert!(out.contains(BULLETS));
474 assert!(out.contains(NEVER));
475 assert!(out.contains("CRITICAL"));
476 }
477
478 #[test]
479 fn dedicated_shadow_omits_mapping() {
480 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
481 assert!(out.contains(START_MARK));
482 assert!(
483 !out.contains("MANDATORY MAPPING"),
484 "shadow must not contain BULLETS"
485 );
486 assert!(!out.contains(NEVER), "shadow must not contain NEVER");
487 assert!(
488 !out.contains("CRITICAL"),
489 "shadow must not contain CRITICAL"
490 );
491 assert!(
492 out.contains(INTENT),
493 "shadow must keep non-mapping sections"
494 );
495 }
496
497 #[test]
498 fn dedicated_litm_structure() {
499 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
500 let lines: Vec<&str> = out.lines().collect();
501 let first_5 = lines[..5.min(lines.len())].join("\n");
502 assert!(
503 first_5.contains("CRITICAL") || first_5.contains("MUST"),
504 "LITM: MUST/CRITICAL instruction near start"
505 );
506 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
508 assert!(
509 tail.contains("PREFERENCE") || tail.contains("NEVER"),
510 "LITM: reinforcement near end, tail={tail:?}"
511 );
512 }
513
514 #[test]
517 fn shared_has_markers_and_header() {
518 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
519 assert!(out.contains(START_MARK));
520 assert!(out.contains(END_MARK));
521 assert!(out.contains("MANDATORY MAPPING"));
522 assert!(out.contains(BULLETS));
523 }
524
525 #[test]
526 fn shared_shadow_omits_mapping() {
527 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
528 assert!(out.contains(START_MARK));
529 assert!(
530 !out.contains("MANDATORY MAPPING"),
531 "shadow must not have header"
532 );
533 assert!(
534 !out.contains("MANDATORY MAPPING"),
535 "shadow must not contain BULLETS"
536 );
537 }
538
539 #[test]
542 fn bare_has_no_markers() {
543 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
544 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
545 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
546 assert!(!out.contains("<!-- version:"), "Bare must not have version");
547 assert!(out.contains(BULLETS));
548 assert!(out.contains(NEVER));
549 }
550
551 #[test]
552 fn bare_shadow_only_read_modes() {
553 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
554 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
555 assert!(
556 !out.contains("MANDATORY MAPPING"),
557 "shadow Bare must not have BULLETS"
558 );
559 }
560
561 #[test]
564 fn render_includes_lite_prompt() {
565 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
566 assert!(out.contains("OUTPUT STYLE: concise"));
567 assert!(out.contains("Bullet points"));
568 }
569
570 #[test]
571 fn render_includes_standard_prompt() {
572 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
573 assert!(out.contains("OUTPUT STYLE: dense"));
574 assert!(out.contains("atomic fact"));
575 }
576
577 #[test]
578 fn render_includes_max_prompt() {
579 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
580 assert!(out.contains("OUTPUT STYLE: expert-terse"));
581 assert!(out.contains("Telegraph"));
582 }
583
584 #[test]
585 fn render_off_excludes_compression() {
586 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
587 assert!(!out.contains("OUTPUT STYLE:"));
588 }
589
590 #[test]
591 fn compression_text_matches_level() {
592 assert!(compression_text(CompressionLevel::Off).is_empty());
593 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
594 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
595 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
596 }
597
598 #[test]
601 fn carrier_wrappers_wrap_compression_in_markers() {
602 for wrapper in [Wrapper::Dedicated, Wrapper::Shared] {
605 let out = render(false, wrapper, CompressionLevel::Standard);
606 assert!(
607 out.contains(COMPRESSION_BLOCK_START) && out.contains(COMPRESSION_BLOCK_END),
608 "{wrapper:?} must wrap compression in COMPRESSION_BLOCK markers"
609 );
610 let start = out.find(COMPRESSION_BLOCK_START).unwrap();
612 let end = out.find(COMPRESSION_BLOCK_END).unwrap();
613 assert!(start < end, "{wrapper:?}: start marker precedes end marker");
614 assert!(out[start..end].contains("OUTPUT STYLE: dense"));
615 }
616 }
617
618 #[test]
619 fn bare_wrapper_emits_compression_without_markers() {
620 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
623 assert!(out.contains("OUTPUT STYLE: dense"));
624 assert!(!out.contains(COMPRESSION_BLOCK_START));
625 assert!(!out.contains(COMPRESSION_BLOCK_END));
626 }
627
628 #[test]
629 fn compression_off_emits_no_markers_in_any_wrapper() {
630 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
631 let out = render(false, wrapper, CompressionLevel::Off);
632 assert!(
633 !out.contains(COMPRESSION_BLOCK_START) && !out.contains(COMPRESSION_BLOCK_END),
634 "{wrapper:?}: Off must emit no compression markers"
635 );
636 }
637 }
638
639 #[test]
640 fn rendered_carrier_block_is_seen_as_carrying_compression() {
641 let dedicated = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
644 assert!(crate::core::rules_channel::carries_full_rules(&dedicated));
645 assert!(dedicated.contains(COMPRESSION_BLOCK_START));
646 }
647
648 #[test]
651 fn all_wrappers_produce_output() {
652 for shadow in [false, true] {
653 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
654 let out = render(shadow, wrapper, CompressionLevel::Off);
655 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
656 }
657 }
658 }
659
660 #[test]
663 fn rules_file_parses_version() {
664 let content = format!(
665 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
666 );
667 let f = RulesFile::parse(&content);
668 assert!(f.has_content());
669 assert_eq!(f.version(), RULES_VERSION);
670 assert!(f.is_current());
671 assert!(f.prefix().contains("stuff before"));
672 assert!(f.suffix().contains("stuff after"));
673 }
674
675 #[test]
676 fn rules_file_no_version_defaults_to_zero() {
677 let content = format!("{START_MARK}\nbody\n{END_MARK}");
678 let f = RulesFile::parse(&content);
679 assert!(f.has_content());
680 assert_eq!(f.version(), 0);
681 assert!(!f.is_current());
682 }
683
684 #[test]
685 fn rules_file_no_start_marker_no_content() {
686 let f = RulesFile::parse("just user stuff");
687 assert!(!f.has_content());
688 assert_eq!(f.version(), 0);
689 }
690
691 #[test]
692 fn block_matches_render_true_for_fresh_render() {
693 let fresh = render(false, Wrapper::Dedicated, CompressionLevel::Off);
694 let content = format!("user before\n{fresh}\nuser after");
695 let f = RulesFile::parse(&content);
696 assert!(f.is_current(), "fresh render carries the current version");
697 assert!(
698 f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off),
699 "an unchanged block must compare equal to a fresh render"
700 );
701 }
702
703 #[test]
704 fn block_matches_render_false_on_compression_change() {
705 let content = render(false, Wrapper::Dedicated, CompressionLevel::Off);
708 let f = RulesFile::parse(&content);
709 assert!(f.is_current());
710 assert!(
711 !f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Max),
712 "a compression-level change must be detected as drift"
713 );
714 }
715
716 #[test]
717 fn block_matches_render_false_on_shadow_change() {
718 let content = render(false, Wrapper::Dedicated, CompressionLevel::Lite);
719 let f = RulesFile::parse(&content);
720 assert!(
721 !f.block_matches_render(true, Wrapper::Dedicated, CompressionLevel::Lite),
722 "a shadow-mode toggle must be detected as drift"
723 );
724 }
725
726 #[test]
727 fn block_matches_render_false_without_block() {
728 let f = RulesFile::parse("plain user content, no markers");
729 assert!(!f.block_matches_render(false, Wrapper::Dedicated, CompressionLevel::Off));
730 }
731
732 #[test]
733 fn rules_file_merged_replaces_section() {
734 let content =
735 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
736 let f = RulesFile::parse(&content);
737 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
738 assert!(merged.contains("before"), "prefix preserved");
739 assert!(merged.contains("after"), "suffix preserved");
740 assert!(!merged.contains("old"), "old content replaced");
741 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
742 }
743
744 #[test]
745 fn rules_file_merged_appends_when_no_section() {
746 let content = "user content";
747 let f = RulesFile::parse(content);
748 assert!(!f.has_content());
749 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
750 assert!(merged.contains("user content"));
751 assert!(merged.contains(BULLETS));
752 }
753
754 #[test]
755 fn rules_file_without_section_strips_content() {
756 let content =
757 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
758 let f = RulesFile::parse(&content);
759 let stripped = f.without_section();
760 assert!(stripped.contains("header"));
761 assert!(stripped.contains("footer"));
762 assert!(!stripped.contains("body"));
763 assert!(!stripped.contains(START_MARK));
764 }
765
766 #[test]
767 fn rules_file_without_section_noop_when_no_content() {
768 let content = "just user text";
769 let f = RulesFile::parse(content);
770 assert_eq!(f.without_section(), content);
771 }
772}