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 RULES_VERSION: usize = 1;
42
43pub const CRITICAL: &str = "CRITICAL: ALWAYS use lean-ctx tools instead of native equivalents. \
45 This is NOT optional.";
46
47pub const BULLETS: &str = "\
49MANDATORY MAPPING:\n\
50• Read/cat -> ctx_read(path, mode)\n\
51• Glob/find -> ctx_glob(pattern)\n\
52• Shell/bash -> ctx_shell(command)\n\
53• Grep -> ctx_search(pattern, path)\n\
54• ls/find -> ctx_tree(path, depth)";
55
56pub const NEVER: &str =
59 "NEVER use native Read/Grep/Shell/Glob when ctx_* equivalents are available.";
60
61pub const INTENT: &str = "\
63Tool selection by intent:\n\
64• Understand code / find answers / before editing -> ctx_compose (call FIRST)\n\
65• Read a file -> ctx_read(path, mode=signatures|map|full)\n\
66• Find a symbol by name (exact) -> ctx_symbol\n\
67• Search code by pattern (fuzzy) -> ctx_search\n\
68• Search by meaning (concepts) -> ctx_semantic_search\n\
69• Find files by pattern (glob) -> ctx_glob\n\
70• Project structure -> ctx_tree\n\
71• Who calls this / call graph -> ctx_callgraph\n\
72• Session state / memory -> ctx_session / ctx_knowledge";
73
74pub const ANTI: &str = "\
76Anti-patterns — do NOT:\n\
77• Chain ctx_search -> ctx_read -> ctx_symbol — one ctx_compose replaces all three\n\
78• Grep for symbol definitions — ctx_symbol is faster + more precise\n\
79• Use ctx_read(mode=full) for orientation — use mode=signatures\n\
80• Use ctx_callgraph or ctx_graph for const/static/variable references — they track\n\
81 function call edges and file-level deps only. Use grep or ctx_compose instead";
82
83pub const PARALLEL: &str = "\
85PARALLEL tool calls: fire independent calls in the SAME turn — don't sequence them.\n\
86One turn with 5 parallel ctx_read calls completes faster than 5 sequential turns.\n\
87ctx_compose bundles multiple lookups into one call; for anything it doesn't\n\
88cover, batch independent reads/searches together.";
89
90pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \
92 ctx_session=memory, ctx_knowledge=facts, ctx_semantic_search=meaning search, \
93 ctx_shell raw=true=uncompressed. Details: LEAN-CTX.md";
94
95pub const CEP: &str = "CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) \
97 4.ONE LINE PER ACTION 5.QUALITY ANCHOR";
98
99pub const INTELLIGENCE: &str =
101 "OUTPUT: never echo tool output, no narration comments, show only changed code.";
102
103pub const LITM_END: &str = "TOOL PREFERENCE (END): ctx_compose>chain ctx_read>Read ctx_shell>Shell \
105 ctx_search>Grep ctx_glob>Glob ctx_tree>ls | Edit/Write/Delete=native";
106
107pub const LITE_PROMPT: &str = "\
111OUTPUT STYLE: concise
112- Bullet points over paragraphs
113- Skip filler words and hedging (\"I think\", \"probably\", \"it seems\")
114- 1-sentence explanations max, then code/action
115- No repeating what the user said";
116
117pub const STANDARD_PROMPT: &str = "\
119OUTPUT STYLE: dense
120- Each statement = one atomic fact line
121- Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret
122- Diff lines only (+/-/~), never repeat unchanged code
123- Symbols: → (causes), + (adds), − (removes), ~ (modifies), ∴ (therefore)
124- No narration, no filler, no hedging
125- BUDGET: ≤200 tokens per response unless code block required";
126
127pub const MAX_PROMPT: &str = "\
129OUTPUT STYLE: expert-terse
130- Telegraph format: subject-verb-object, drop articles/prepositions
131- Symbolic vocabulary: → cause, ∵ because, ∴ therefore, ⊕ add, ⊖ remove, Δ change, ≈ similar, ≠ different, ∈ in/member, ∅ empty/none, ✓ ok, ✗ fail
132- Code blocks: untouched (never compress code syntax)
133- Each line: max 80 chars
134- Zero narration, zero filler
135- BUDGET: ≤100 tokens per non-code response";
136
137pub fn compression_text(level: CompressionLevel) -> &'static str {
139 match level {
140 CompressionLevel::Off => "",
141 CompressionLevel::Lite => LITE_PROMPT,
142 CompressionLevel::Standard => STANDARD_PROMPT,
143 CompressionLevel::Max => MAX_PROMPT,
144 }
145}
146
147const FULL_NON_SHADOW: &[&str] = &[
148 CRITICAL,
149 BULLETS,
150 NEVER,
151 INTENT,
152 ANTI,
153 PARALLEL,
154 AUTO,
155 CEP,
156 INTELLIGENCE,
157 LITM_END,
158];
159
160const FULL_SHADOW: &[&str] = &[INTENT, ANTI, PARALLEL, AUTO, CEP, INTELLIGENCE, LITM_END];
161
162const COMPACT_NON_SHADOW: &[&str] = &[CRITICAL, BULLETS, NEVER, INTENT, ANTI, PARALLEL];
163
164const COMPACT_SHADOW: &[&str] = &[INTENT, ANTI, PARALLEL];
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Wrapper {
170 Dedicated,
175
176 Shared,
180
181 Bare,
184}
185
186pub fn render(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
194 let profile = match (wrapper, shadow) {
195 (Wrapper::Dedicated, false) => FULL_NON_SHADOW,
196 (Wrapper::Dedicated, true) => FULL_SHADOW,
197 (_, false) => COMPACT_NON_SHADOW,
198 (_, true) => COMPACT_SHADOW,
199 };
200
201 let mut body = profile.join("\n\n");
202
203 let compression = compression_text(level);
205 if !compression.is_empty() {
206 body.push('\n');
207 body.push_str(compression);
208 }
209
210 if matches!(wrapper, Wrapper::Bare) {
211 return body;
212 }
213
214 let version_line = format!("<!-- version: {RULES_VERSION} -->");
215
216 format!("{START_MARK}\n{version_line}\n\n{body}\n{END_MARK}")
217}
218#[derive(Debug)]
229pub struct RulesFile<'a> {
230 content: &'a str,
231 start: Option<usize>,
233 end: Option<usize>,
235 version: usize,
237}
238
239fn parse_version_number(s: &str) -> Option<usize> {
242 let prefix = "<!-- version: ";
243 let vs = s.find(prefix)?;
244 let num_start = vs + prefix.len();
245 let end = s[num_start..].find(" -->")?;
246 s[num_start..num_start + end].parse().ok()
247}
248
249impl<'a> RulesFile<'a> {
250 pub fn parse(content: &'a str) -> Self {
256 let start = content.find(START_MARK);
257 let version = start
258 .and_then(|s| parse_version_number(&content[s + START_MARK.len()..]))
259 .unwrap_or(0);
260 let end = content.find(END_MARK);
261 RulesFile {
262 content,
263 start,
264 end,
265 version,
266 }
267 }
268
269 pub fn has_content(&self) -> bool {
271 self.start.is_some()
272 }
273
274 pub fn version(&self) -> usize {
277 self.version
278 }
279
280 pub fn is_current(&self) -> bool {
282 self.version >= RULES_VERSION
283 }
284
285 pub fn prefix(&self) -> &'a str {
288 self.start.map_or("", |s| self.content[..s].trim())
289 }
290
291 pub fn suffix(&self) -> &'a str {
294 self.end
295 .map_or("", |e| self.content[e + END_MARK.len()..].trim())
296 }
297
298 pub fn merged(&self, shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
304 let fresh = render(shadow, wrapper, level);
305 if self.start.is_some() {
306 let before = self.prefix();
307 let after = self.suffix();
308 let mut out = String::new();
309 if !before.is_empty() {
310 out.push_str(before);
311 out.push('\n');
312 out.push('\n');
313 }
314 out.push_str(&fresh);
315 if !after.is_empty() {
316 out.push('\n');
317 out.push('\n');
318 out.push_str(after);
319 }
320 if !out.ends_with('\n') {
321 out.push('\n');
322 }
323 out
324 } else {
325 let trimmed = self.content.trim_end();
327 let mut out = trimmed.to_string();
328 if !out.is_empty() {
329 out.push('\n');
330 out.push('\n');
331 }
332 out.push_str(&fresh);
333 out
334 }
335 }
336
337 pub fn initial(shadow: bool, wrapper: Wrapper, level: CompressionLevel) -> String {
339 render(shadow, wrapper, level)
340 }
341
342 pub fn without_section(&self) -> String {
346 if let Some(start_pos) = self.start {
347 let before = self.content[..start_pos].trim();
348 let after = self.suffix();
349 let mut out = String::new();
350 if !before.is_empty() {
351 out.push_str(before);
352 out.push('\n');
353 }
354 if !after.is_empty() {
355 out.push('\n');
356 out.push_str(after);
357 }
358 out
359 } else {
360 self.content.to_string()
361 }
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
372 fn bullets_uses_ctx_shell() {
373 assert!(BULLETS.contains("ctx_shell"));
374 assert!(!BULLETS.contains("lean-ctx -c"));
375 assert!(!BULLETS.contains("ctx_edit"));
376 }
377
378 #[test]
379 fn sections_not_empty() {
380 assert!(!BULLETS.is_empty());
381 assert!(!NEVER.is_empty());
382 assert!(!INTENT.is_empty());
383 assert!(!ANTI.is_empty());
384 assert!(!PARALLEL.is_empty());
385 assert!(!AUTO.is_empty());
386 assert!(!CEP.is_empty());
387 assert!(!INTELLIGENCE.is_empty());
388 assert!(!LITM_END.is_empty());
389 assert!(!CRITICAL.is_empty());
390 }
391
392 #[test]
393 fn intent_contains_ctx_compose() {
394 assert!(INTENT.contains("ctx_compose"));
395 }
396
397 #[test]
398 fn anti_contains_do_not() {
399 assert!(ANTI.contains("do NOT"));
400 }
401
402 #[test]
403 fn parallel_contains_parallel() {
404 assert!(PARALLEL.contains("PARALLEL"));
405 }
406
407 #[test]
410 fn dedicated_has_markers_and_version() {
411 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
412 assert!(out.contains(START_MARK));
413 assert!(out.contains(&format!("<!-- version: {RULES_VERSION} -->")));
414 assert!(out.contains(END_MARK));
415 assert!(out.contains(BULLETS));
416 assert!(out.contains(NEVER));
417 assert!(out.contains("CRITICAL"));
418 }
419
420 #[test]
421 fn dedicated_shadow_omits_mapping() {
422 let out = render(true, Wrapper::Dedicated, CompressionLevel::Off);
423 assert!(out.contains(START_MARK));
424 assert!(
425 !out.contains("MANDATORY MAPPING"),
426 "shadow must not contain BULLETS"
427 );
428 assert!(!out.contains(NEVER), "shadow must not contain NEVER");
429 assert!(
430 !out.contains("CRITICAL"),
431 "shadow must not contain CRITICAL"
432 );
433 assert!(
434 out.contains(INTENT),
435 "shadow must keep non-mapping sections"
436 );
437 }
438
439 #[test]
440 fn dedicated_litm_structure() {
441 let out = render(false, Wrapper::Dedicated, CompressionLevel::Off);
442 let lines: Vec<&str> = out.lines().collect();
443 let first_5 = lines[..5.min(lines.len())].join("\n");
444 assert!(
445 first_5.contains("CRITICAL") || first_5.contains("MUST"),
446 "LITM: MUST/CRITICAL instruction near start"
447 );
448 let tail = lines[lines.len().saturating_sub(8)..].join("\n");
450 assert!(
451 tail.contains("PREFERENCE") || tail.contains("NEVER"),
452 "LITM: reinforcement near end, tail={tail:?}"
453 );
454 }
455
456 #[test]
459 fn shared_has_markers_and_header() {
460 let out = render(false, Wrapper::Shared, CompressionLevel::Off);
461 assert!(out.contains(START_MARK));
462 assert!(out.contains(END_MARK));
463 assert!(out.contains("MANDATORY MAPPING"));
464 assert!(out.contains(BULLETS));
465 }
466
467 #[test]
468 fn shared_shadow_omits_mapping() {
469 let out = render(true, Wrapper::Shared, CompressionLevel::Off);
470 assert!(out.contains(START_MARK));
471 assert!(
472 !out.contains("MANDATORY MAPPING"),
473 "shadow must not have header"
474 );
475 assert!(
476 !out.contains("MANDATORY MAPPING"),
477 "shadow must not contain BULLETS"
478 );
479 }
480
481 #[test]
484 fn bare_has_no_markers() {
485 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
486 assert!(!out.contains(START_MARK), "Bare must not have START_MARK");
487 assert!(!out.contains(END_MARK), "Bare must not have END_MARK");
488 assert!(!out.contains("<!-- version:"), "Bare must not have version");
489 assert!(out.contains(BULLETS));
490 assert!(out.contains(NEVER));
491 }
492
493 #[test]
494 fn bare_shadow_only_read_modes() {
495 let out = render(true, Wrapper::Bare, CompressionLevel::Off);
496 assert!(!out.contains(NEVER), "shadow Bare must not have NEVER");
497 assert!(
498 !out.contains("MANDATORY MAPPING"),
499 "shadow Bare must not have BULLETS"
500 );
501 }
502
503 #[test]
506 fn render_includes_lite_prompt() {
507 let out = render(false, Wrapper::Bare, CompressionLevel::Lite);
508 assert!(out.contains("OUTPUT STYLE: concise"));
509 assert!(out.contains("Bullet points"));
510 }
511
512 #[test]
513 fn render_includes_standard_prompt() {
514 let out = render(false, Wrapper::Bare, CompressionLevel::Standard);
515 assert!(out.contains("OUTPUT STYLE: dense"));
516 assert!(out.contains("atomic fact"));
517 }
518
519 #[test]
520 fn render_includes_max_prompt() {
521 let out = render(false, Wrapper::Bare, CompressionLevel::Max);
522 assert!(out.contains("OUTPUT STYLE: expert-terse"));
523 assert!(out.contains("Telegraph"));
524 }
525
526 #[test]
527 fn render_off_excludes_compression() {
528 let out = render(false, Wrapper::Bare, CompressionLevel::Off);
529 assert!(!out.contains("OUTPUT STYLE:"));
530 }
531
532 #[test]
533 fn compression_text_matches_level() {
534 assert!(compression_text(CompressionLevel::Off).is_empty());
535 assert!(compression_text(CompressionLevel::Lite).contains("Bullet"));
536 assert!(compression_text(CompressionLevel::Standard).contains("fn, cfg"));
537 assert!(compression_text(CompressionLevel::Max).contains("Telegraph"));
538 }
539
540 #[test]
543 fn all_wrappers_produce_output() {
544 for shadow in [false, true] {
545 for wrapper in [Wrapper::Dedicated, Wrapper::Shared, Wrapper::Bare] {
546 let out = render(shadow, wrapper, CompressionLevel::Off);
547 assert!(!out.is_empty(), "{wrapper:?} shadow={shadow} is empty");
548 }
549 }
550 }
551
552 #[test]
555 fn rules_file_parses_version() {
556 let content = format!(
557 "stuff before\n{START_MARK}\n<!-- version: {RULES_VERSION} -->\n\nbody\n{END_MARK}\nstuff after"
558 );
559 let f = RulesFile::parse(&content);
560 assert!(f.has_content());
561 assert_eq!(f.version(), RULES_VERSION);
562 assert!(f.is_current());
563 assert!(f.prefix().contains("stuff before"));
564 assert!(f.suffix().contains("stuff after"));
565 }
566
567 #[test]
568 fn rules_file_no_version_defaults_to_zero() {
569 let content = format!("{START_MARK}\nbody\n{END_MARK}");
570 let f = RulesFile::parse(&content);
571 assert!(f.has_content());
572 assert_eq!(f.version(), 0);
573 assert!(!f.is_current());
574 }
575
576 #[test]
577 fn rules_file_no_start_marker_no_content() {
578 let f = RulesFile::parse("just user stuff");
579 assert!(!f.has_content());
580 assert_eq!(f.version(), 0);
581 }
582
583 #[test]
584 fn rules_file_merged_replaces_section() {
585 let content =
586 format!("before\n{START_MARK}\n<!-- version: 1 -->\n\nold\n{END_MARK}\nafter");
587 let f = RulesFile::parse(&content);
588 let merged = f.merged(false, Wrapper::Shared, CompressionLevel::Off);
589 assert!(merged.contains("before"), "prefix preserved");
590 assert!(merged.contains("after"), "suffix preserved");
591 assert!(!merged.contains("old"), "old content replaced");
592 assert!(merged.contains(&format!("<!-- version: {RULES_VERSION} -->")));
593 }
594
595 #[test]
596 fn rules_file_merged_appends_when_no_section() {
597 let content = "user content";
598 let f = RulesFile::parse(content);
599 assert!(!f.has_content());
600 let merged = f.merged(false, Wrapper::Bare, CompressionLevel::Off);
601 assert!(merged.contains("user content"));
602 assert!(merged.contains(BULLETS));
603 }
604
605 #[test]
606 fn rules_file_without_section_strips_content() {
607 let content =
608 format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
609 let f = RulesFile::parse(&content);
610 let stripped = f.without_section();
611 assert!(stripped.contains("header"));
612 assert!(stripped.contains("footer"));
613 assert!(!stripped.contains("body"));
614 assert!(!stripped.contains(START_MARK));
615 }
616
617 #[test]
618 fn rules_file_without_section_noop_when_no_content() {
619 let content = "just user text";
620 let f = RulesFile::parse(content);
621 assert_eq!(f.without_section(), content);
622 }
623}