1use crate::core::config::CompressionLevel;
2use crate::core::protocol::CrpMode;
3use crate::core::rules_canonical::{self as rc, Wrapper};
4
5pub mod solution;
6pub use solution::solution_ladder_text;
7
8const INSTRUCTION_CAP_TOKENS: usize = 800;
10
11#[cfg(test)]
23const STATIC_INSTRUCTION_BUDGET_TOKENS: usize = 665;
24#[cfg(test)]
25const STATIC_INSTRUCTION_BUDGET_TDD_TOKENS: usize = 795;
26#[cfg(all(test, windows))]
28const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 25;
29#[cfg(all(test, not(windows)))]
30const STATIC_INSTRUCTION_SHELL_HINT_TOKENS: usize = 0;
31
32#[must_use]
33pub fn build_instructions(crp_mode: CrpMode) -> String {
34 build_instructions_with_client(crp_mode, "")
35}
36
37#[must_use]
38pub fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
39 build_instructions_with_client_and_optional_session(crp_mode, client_name, None)
40}
41
42#[must_use]
48pub fn build_instructions_with_client_and_session(
49 crp_mode: CrpMode,
50 client_name: &str,
51 session: &crate::core::session::SessionState,
52) -> String {
53 build_instructions_with_client_and_optional_session(crp_mode, client_name, Some(session))
54}
55
56fn client_instruction_char_cap(client_name: &str) -> Option<usize> {
60 let id = crate::core::client_capabilities::ClientMcpCapabilities::detect(client_name).client_id;
61 crate::core::client_constraints::by_client_id(&id).and_then(|c| c.mcp_instructions_max_chars)
62}
63
64fn trim_to_char_budget(s: &str, budget: usize) -> String {
69 if s.chars().count() <= budget {
70 return s.to_string();
71 }
72 let best = s
74 .char_indices()
75 .enumerate()
76 .take_while(|(n, _)| *n <= budget)
77 .filter(|(_, (_, c))| *c == '\n')
78 .map(|(_, (i, _))| i)
79 .last()
80 .unwrap_or(0);
81 if best == 0 {
82 return s.chars().take(budget).collect();
83 }
84 s[..best].trim_end().to_string()
85}
86
87fn build_instructions_with_client_and_optional_session(
88 crp_mode: CrpMode,
89 client_name: &str,
90 session: Option<&crate::core::session::SessionState>,
91) -> String {
92 let cfg = crate::core::config::Config::load();
93 let minimal = cfg.minimal_overhead_effective_for_client(client_name);
94 let shadow = cfg.shadow_mode;
95 let level = if client_loads_compression_from_file(client_name) {
98 CompressionLevel::Off
99 } else {
100 CompressionLevel::effective(&cfg)
101 };
102 let persona_block = crate::core::persona::Persona::resolve(&cfg).prompt_block();
106 build_full_instructions(
107 crp_mode,
108 client_name,
109 minimal,
110 level,
111 shadow,
112 &persona_block,
113 session,
114 )
115}
116
117#[must_use]
126pub fn claude_code_static_instructions_for_test() -> String {
127 build_full_instructions(
128 CrpMode::Off,
129 "",
130 true,
131 CompressionLevel::Off,
132 false,
133 "",
134 None,
135 )
136}
137
138#[must_use]
140pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
141 let shadow = false;
142 let level = CompressionLevel::effective(&crate::core::config::Config::load());
145 let tp =
146 crate::core::tool_profiles::ToolProfile::from_config(&crate::core::config::Config::load());
147 let skeleton = rc::render(shadow, Wrapper::Bare, level, &tp);
148 let shell_hint = build_shell_hint();
149
150 let base = format!(
151 "{skeleton}\n\
152 {shell_hint}\n\
153 {decoder_block}\n\
154 {origin}",
155 decoder_block =
156 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
157 origin = crate::core::integrity::origin_line(),
158 );
159
160 match crp_mode_suffix(crp_mode) {
161 "" => base,
162 crp => format!("{base}\n\n{crp}"),
163 }
164}
165
166#[must_use]
170pub fn build_instructions_with_client_for_compiler(
171 crp_mode: CrpMode,
172 client_name: &str,
173 _unified_tool_mode: bool,
174) -> String {
175 let tp = crate::core::tool_visibility::guidance_profile(
176 &crate::core::config::Config::load(),
177 client_name,
178 );
179 let skeleton = rc::render(true, Wrapper::Bare, CompressionLevel::Off, &tp);
180 let shell_hint = build_shell_hint();
181
182 let base = format!(
183 "{skeleton}\n\
184 {shell_hint}\n\
185 {decoder_block}\n\
186 {origin}",
187 decoder_block =
188 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
189 origin = crate::core::integrity::origin_line(),
190 );
191
192 match crp_mode_suffix(crp_mode) {
193 "" => base,
194 crp => format!("{base}\n\n{crp}"),
195 }
196}
197
198#[must_use]
200pub fn claude_config_dir_display() -> String {
201 match std::env::var("CLAUDE_CONFIG_DIR") {
202 Ok(dir) if !dir.trim().is_empty() => {
203 let dir = dir.trim().to_string();
204 if dir.starts_with('~') {
205 dir
206 } else if let Some(home) = dirs::home_dir() {
207 let home_str = home.to_string_lossy();
208 if let Some(rest) = dir.strip_prefix(home_str.as_ref()) {
209 format!("~{rest}")
210 } else {
211 dir
212 }
213 } else {
214 dir
215 }
216 }
217 _ => "~/.claude".to_string(),
218 }
219}
220
221fn build_full_instructions(
224 crp_mode: CrpMode,
225 client_name: &str,
226 minimal: bool,
227 level: CompressionLevel,
228 shadow: bool,
229 persona_block: &str,
230 session: Option<&crate::core::session::SessionState>,
231) -> String {
232 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
233 let loaded_session = (!minimal).then_some(session).flatten();
234
235 let (session_block, litm_end_block) = match loaded_session {
236 Some(ref session) => {
237 let mut positioned = crate::core::litm::position_optimize(session);
238 positioned.enforce_token_budget(crate::core::litm::active_session_budget());
241 let begin = format!(
242 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
243 profile.name, positioned.begin_block
244 );
245 let end = if positioned.end_block.is_empty() {
246 String::new()
247 } else {
248 format!(
249 "\n--- SESSION RESUME (post-compaction) ---\n{}\n---\n",
250 positioned.end_block
251 )
252 };
253 (begin, end)
254 }
255 None => (String::new(), String::new()),
256 };
257
258 let project_root_for_blocks = if minimal {
259 None
260 } else {
261 loaded_session
262 .as_ref()
263 .and_then(|s| s.project_root.clone())
264 .or_else(|| {
265 std::env::current_dir()
266 .ok()
267 .map(|p| p.to_string_lossy().to_string())
268 })
269 };
270
271 let knowledge_block = match &project_root_for_blocks {
272 Some(root) => {
273 let knowledge = crate::core::knowledge::ProjectKnowledge::load(root);
274 match knowledge {
275 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
276 let memory = k.format_aaak();
283 if memory.is_empty() {
284 String::new()
285 } else {
286 format!("\n--- PROJECT MEMORY (AAAK) ---\n{}\n---\n", memory.trim())
287 }
288 }
289 _ => String::new(),
290 }
291 }
292 None => String::new(),
293 };
294
295 let gotcha_block = match &project_root_for_blocks {
296 Some(root) => {
297 let store = crate::core::gotcha_tracker::GotchaStore::load(root);
298 let files: Vec<String> = loaded_session
299 .as_ref()
300 .map(|s| s.files_touched.iter().map(|ft| ft.path.clone()).collect())
301 .unwrap_or_default();
302 let block = store.format_injection_block(&files);
303 if block.is_empty() {
304 String::new()
305 } else {
306 format!("\n{block}\n")
307 }
308 }
309 None => String::new(),
310 };
311
312 let health_block = match &project_root_for_blocks {
313 Some(root) => {
314 let block = crate::core::code_health::persist::format_session_block(root);
315 if block.is_empty() {
316 String::new()
317 } else {
318 format!("\n{block}\n")
319 }
320 }
321 None => String::new(),
322 };
323
324 let shell_hint = build_shell_hint();
325
326 let cfg = crate::core::config::Config::load();
340 let tool_profile = crate::core::tool_visibility::guidance_profile(&cfg, client_name);
344 let is_shadow_only = is_shadow_surface_active();
345 let skeleton = if client_loads_rules_from_file(client_name) {
346 let anchor = if client_is_hook_covered(client_name) {
347 if is_shadow_only {
348 shadow_only_anchor(&tool_profile)
349 } else {
350 hook_covered_anchor(&tool_profile)
351 }
352 } else {
353 skeleton_anchor(&tool_profile)
354 };
355 let compression = rc::compression_text(level);
356 if compression.is_empty() {
357 anchor
358 } else {
359 format!("{anchor}\n{compression}")
360 }
361 } else {
362 let effective_shadow = shadow && client_is_hook_covered(client_name);
370 rc::render(effective_shadow, Wrapper::Bare, level, &tool_profile)
371 };
372
373 let config_dir = claude_config_dir_display();
376
377 let persona_section = if persona_block.is_empty() {
380 String::new()
381 } else {
382 format!("\n{persona_block}")
383 };
384
385 let solution_ladder = {
387 let loaded_config = crate::core::config::Config::load();
388 let sol_cfg = &loaded_config.solution;
389 let is_subagent = crate::core::conversation::is_subagent_context();
390 let inject_solution = if is_subagent {
391 sol_cfg.inject_in_subagents
392 } else {
393 sol_cfg.inject_in_instructions
394 };
395 if sol_cfg.enabled && inject_solution {
396 let ladder =
397 crate::instructions::solution::solution_ladder_text(sol_cfg.effective_intensity());
398 if ladder.is_empty() {
399 String::new()
400 } else {
401 format!("\n{ladder}\n")
402 }
403 } else {
404 String::new()
405 }
406 };
407
408 let base = format!(
409 "{skeleton}\n\
410 {persona_section}\
411 {shell_hint}\n\
412 {decoder_block}\n\
413 Full instructions at {config_dir}/CLAUDE.md\n\
414 {session_block}\n\
415 {knowledge_block}\n\
416 {gotcha_block}\n\
417 {health_block}\n\
418 {origin}\n\
419 {litm_end_block}",
420 decoder_block =
421 crate::core::protocol::instruction_decoder_block(matches!(crp_mode, CrpMode::Tdd)),
422 origin = crate::core::integrity::origin_line(),
423 litm_end_block = litm_end_block
424 );
425
426 let guidance_suffix = format!("{}{}", crp_mode_suffix(crp_mode), solution_ladder);
430
431 let base = match client_instruction_char_cap(client_name) {
434 Some(max) => {
435 let suffix_chars = guidance_suffix.trim_end_matches('\n').chars().count();
436 trim_to_char_budget(&base, max.saturating_sub(suffix_chars + 2))
437 }
438 None => base,
439 };
440 assemble_within_cap(&base, &guidance_suffix, INSTRUCTION_CAP_TOKENS)
441}
442
443fn crp_mode_suffix(crp_mode: CrpMode) -> &'static str {
444 match crp_mode {
445 CrpMode::Off => "",
446 CrpMode::Compact => {
447 "CRP MODE: compact — omit filler; abbreviate fn,cfg,impl,deps,req,res; \
448 diff lines (+/-) only; <=200 tok; trust tool outputs."
449 }
450 CrpMode::Tdd => {
451 "CRP MODE: tdd — max density; Fn refs + diff lines only \
452 (+F1:42 | -F1:10-15 | ~F1:42 old->new); <=150 tok; zero narration."
453 }
454 }
455}
456
457fn assemble_within_cap(base: &str, suffix: &str, cap_tokens: usize) -> String {
458 use crate::core::tokens::count_tokens;
459 let suffix = suffix.trim_end_matches('\n');
460 if suffix.is_empty() {
461 let full = base.to_string();
462 return if count_tokens(&full) > cap_tokens {
463 truncate_to_token_cap(&full, cap_tokens)
464 } else {
465 full
466 };
467 }
468
469 let full = format!("{base}\n\n{suffix}");
470 if count_tokens(&full) <= cap_tokens {
471 return full;
472 }
473
474 let suffix_tokens = count_tokens(suffix);
475 let Some(base_budget) = cap_tokens.checked_sub(suffix_tokens + 1) else {
476 return truncate_to_token_cap(&full, cap_tokens);
477 };
478 let trimmed_base = truncate_to_token_cap(base, base_budget);
479 format!("{trimmed_base}\n\n{suffix}")
480}
481
482fn truncate_to_token_cap(s: &str, cap_tokens: usize) -> String {
483 use crate::core::tokens::count_tokens;
484 if count_tokens(s) <= cap_tokens {
485 return s.to_string();
486 }
487 let cuts: Vec<usize> = s.match_indices('\n').map(|(i, _)| i).collect();
488 let (mut lo, mut hi) = (0usize, cuts.len());
489 let mut best: Option<usize> = None;
490 while lo < hi {
491 let mid = lo + (hi - lo) / 2;
492 let end = cuts[mid];
493 if end > 0 && count_tokens(&s[..end]) <= cap_tokens {
494 best = Some(end);
495 lo = mid + 1;
496 } else {
497 hi = mid;
498 }
499 }
500 if let Some(end) = best {
501 return s[..end].to_string();
502 }
503 let byte_approx = cap_tokens * 4;
504 let safe = s.floor_char_boundary(byte_approx.min(s.len()));
505 s[..safe].to_string()
506}
507
508#[must_use]
510pub fn claude_code_instructions() -> String {
511 build_instructions(CrpMode::Off)
512}
513
514fn skeleton_anchor(_tp: &crate::core::tool_profiles::ToolProfile) -> String {
518 SKELETON_ANCHOR.into()
519}
520
521fn hook_covered_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
523 let mut s =
524 String::from("lean-ctx active — hooks compress native Shell/Read/Grep transparently");
525 if tp.is_tool_enabled("ctx_compose") {
526 s.push_str("; call ctx_compose to orient");
527 }
528 if tp.is_tool_enabled("ctx_session") || tp.is_tool_enabled("ctx_knowledge") {
530 s.push_str(", ctx_search(action=semantic) / ctx_knowledge for meaning & memory");
531 }
532 s.push('.');
533 s
534}
535
536fn shadow_only_anchor(tp: &crate::core::tool_profiles::ToolProfile) -> String {
540 let mut s =
541 String::from("lean-ctx active — hooks compress native Shell/Read/Grep transparently");
542 let has_exclusive = tp.is_tool_enabled("ctx_compose")
543 || tp.is_tool_enabled("ctx_knowledge")
544 || tp.is_tool_enabled("ctx_session");
545 if has_exclusive {
546 s.push_str("; use ctx_call for compose/knowledge/session");
547 }
548 s.push('.');
549 s
550}
551
552fn is_shadow_surface_active() -> bool {
554 let cfg = crate::core::config::Config::load();
555 match cfg.tool_surface.as_deref() {
556 Some("shadow") => true,
557 Some("mcp") => false,
558 _ => cfg.shadow_mode, }
560}
561
562const SKELETON_ANCHOR: &str = "lean-ctx active — your auto-loaded lean-ctx rules apply: \
563 ctx_* tools replace native Read/Grep/Shell/Glob.";
564
565fn client_loads_compression_from_file(client_name: &str) -> bool {
566 crate::core::home::resolve_home_dir().is_some_and(|home| {
567 crate::core::rules_channel::client_autoloads_compression(client_name, &home)
568 })
569}
570
571fn client_loads_rules_from_file(client_name: &str) -> bool {
572 crate::core::home::resolve_home_dir()
573 .is_some_and(|home| crate::core::rules_channel::client_autoloads_rules(client_name, &home))
574}
575
576fn client_is_hook_covered(client_name: &str) -> bool {
577 crate::core::home::resolve_home_dir()
578 .is_some_and(|home| crate::core::rules_channel::client_hook_covered(client_name, &home))
579}
580
581fn build_shell_hint() -> String {
582 if !cfg!(windows) {
583 return String::new();
584 }
585 shell_hint_text(&crate::shell::shell_name())
586}
587
588pub(crate) fn shell_hint_text(name: &str) -> String {
597 let is_posix = matches!(name, "bash" | "sh" | "zsh" | "fish");
598 if is_posix {
599 format!("\nSHELL: {name} (POSIX) — no PowerShell cmdlets.\n")
600 } else if name.contains("powershell") || name.contains("pwsh") {
601 format!("\nSHELL: {name}. Use PowerShell cmdlets.\n")
602 } else {
603 format!("\nSHELL: {name}.\n")
604 }
605}
606
607#[cfg(test)]
614pub(crate) fn max_shell_hint_tokens() -> usize {
615 const BUDGETED_SHELL_NAMES: &[&str] = &[
616 "powershell",
617 "pwsh",
618 "cmd",
619 "bash",
620 "sh",
621 "zsh",
622 "fish",
623 "git-bash",
624 ];
625 BUDGETED_SHELL_NAMES
626 .iter()
627 .map(|name| crate::core::tokens::count_tokens(&shell_hint_text(name)))
628 .max()
629 .unwrap_or(0)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::core::tokens::count_tokens;
636
637 #[test]
642 fn default_prefix_is_byte_identical_across_sessions() {
643 use crate::core::session::SessionState;
644
645 fn busy_session() -> SessionState {
646 let mut s = SessionState::default();
647 s.set_task("refactor the payment retry loop", Some("edit"));
648 s.touch_file("src/payments/retry.rs", None, "full", 812);
649 s.add_finding(
650 Some("src/payments/retry.rs"),
651 Some(42),
652 "backoff ignores jitter",
653 );
654 s.add_decision("keep exponential backoff", None);
655 s
656 }
657 fn prefix(session: &SessionState) -> String {
658 let tools = serde_json::to_string(
659 &crate::core::tool_visibility::advertised_tool_defs_default(),
660 )
661 .expect("tool defs serialize");
662 let instructions = build_instructions_with_client_and_session(
663 CrpMode::effective(),
664 "claude-code",
665 session,
666 );
667 format!(
668 "{tools}\n{instructions}\n{}",
669 crate::rules_inject::canonical_rules_block()
670 )
671 }
672
673 let fresh = {
674 let _iso = crate::core::data_dir::isolated_data_dir();
675 prefix(&SessionState::default())
676 };
677 let busy = {
678 let _iso = crate::core::data_dir::isolated_data_dir();
679 prefix(&busy_session())
680 };
681 assert!(fresh == busy, "default prefix depends on session state");
682
683 let _iso = crate::core::data_dir::isolated_data_dir();
687 let render = |s: &SessionState| {
688 build_full_instructions(
689 CrpMode::Off,
690 "",
691 false,
692 CompressionLevel::Off,
693 false,
694 "",
695 Some(s),
696 )
697 };
698 assert_ne!(render(&SessionState::default()), render(&busy_session()));
699 }
700
701 #[test]
705 fn char_budget_cuts_at_line_ends_counting_chars() {
706 let line = "→".repeat(10); let text = format!("{line}\r\n{line}\r\n{line}");
708 assert_eq!(
711 trim_to_char_budget(&text, 25),
712 format!("{line}\r\n{line}"),
713 "CRLF-safe, whole lines"
714 );
715 assert_eq!(trim_to_char_budget(&text, 15), line);
716 assert_eq!(
717 trim_to_char_budget(&text, 100),
718 text,
719 "under budget untouched"
720 );
721 assert_eq!(
722 trim_to_char_budget(&"é".repeat(50), 7),
723 "é".repeat(7),
724 "one long line"
725 );
726 assert_eq!(client_instruction_char_cap("claude-code"), Some(2048));
727 assert_eq!(client_instruction_char_cap("cursor"), None);
728 }
729
730 #[test]
731 fn guidance_suffix_survives_oversized_base() {
732 let base = "SESSION LINE\n".repeat(4000);
733 let suffix = "OUTPUT STYLE: expert-terse\nFn refs only, diff lines only.";
734 let out = assemble_within_cap(&base, suffix, INSTRUCTION_CAP_TOKENS);
735 assert!(out.contains("OUTPUT STYLE: expert-terse"));
736 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
737 assert!(out.len() < base.len());
738 }
739
740 #[test]
741 fn empty_client_never_dedups_compression() {
742 assert!(!client_loads_compression_from_file(""));
743 assert!(!client_loads_compression_from_file("totally-unknown-agent"));
744 }
745
746 #[test]
747 fn live_session_instructions_include_only_the_owned_session() {
748 let mut current = crate::core::session::SessionState::new();
749 current.add_finding(None, None, "current-instance-marker");
750 let mut other = crate::core::session::SessionState::new();
751 other.add_finding(None, None, "other-instance-marker");
752
753 let out = build_full_instructions(
754 CrpMode::Off,
755 "",
756 false,
757 CompressionLevel::Off,
758 false,
759 "",
760 Some(¤t),
761 );
762
763 assert!(out.contains("current-instance-marker"));
764 assert!(!out.contains("other-instance-marker"));
765 assert!(!out.contains("never echo tool output"));
766 }
767
768 #[test]
769 fn covered_client_gets_anchor_instead_of_skeleton() {
770 let _guard = crate::core::data_dir::test_env_lock();
773 let tmp = tempfile::tempdir().unwrap();
774 let home = tmp.path();
775 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
776 let cfg = crate::core::config::Config::load();
777 let shadow = cfg.shadow_mode;
778 let tp = crate::core::tool_profiles::ToolProfile::Power;
779 std::fs::write(
780 home.join(".cursor/rules/lean-ctx.mdc"),
781 rc::render(
782 shadow,
783 Wrapper::Dedicated,
784 crate::core::config::CompressionLevel::Standard,
785 &tp,
786 ),
787 )
788 .unwrap();
789 let old_home = std::env::var("HOME").ok();
790 crate::test_env::set_var("HOME", home);
791 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
792
793 let covered = build_instructions_with_client(CrpMode::Off, "cursor");
794 let uncovered = build_instructions_with_client(CrpMode::Off, "some-other-agent");
795
796 if let Some(h) = old_home {
797 crate::test_env::set_var("HOME", h);
798 } else {
799 crate::test_env::remove_var("HOME");
800 }
801 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
802
803 assert!(
804 covered.contains(SKELETON_ANCHOR),
805 "covered client must get the anchor:\n{covered}"
806 );
807 if shadow {
808 assert!(
809 !covered.contains("auto-route"),
810 "covered shadow client must not re-pay the shadow nudge:\n{covered}"
811 );
812 } else {
813 assert!(
814 !covered.contains("lean-ctx routing"),
815 "covered client must not re-pay the skeleton:\n{covered}"
816 );
817 }
818 assert!(
820 !covered.contains("OUTPUT STYLE:"),
821 "covered client must not re-pay the compression prompt:\n{covered}"
822 );
823 if shadow {
827 assert!(
828 !uncovered.contains("auto-route"),
829 "#987: uncovered client WITHOUT hooks must NOT get shadow nudge:\n{uncovered}"
830 );
831 assert!(
832 uncovered.contains("lean-ctx routing") || uncovered.contains("ctx_"),
833 "uncovered client without hooks gets explicit tool mapping:\n{uncovered}"
834 );
835 } else {
836 assert!(
837 uncovered.contains("lean-ctx routing"),
838 "uncovered client keeps the full skeleton:\n{uncovered}"
839 );
840 }
841 eprintln!(
842 "instructions footprint: covered={} tok, uncovered={} tok",
843 count_tokens(&covered),
844 count_tokens(&uncovered)
845 );
846 assert!(
847 count_tokens(&covered) < count_tokens(&uncovered),
848 "anchor path must be strictly cheaper"
849 );
850 }
851
852 #[test]
853 fn hook_covered_client_gets_hook_aware_anchor() {
854 let _guard = crate::core::data_dir::test_env_lock();
858 let tmp = tempfile::tempdir().unwrap();
859 let home = tmp.path();
860 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
861 let tp = crate::core::tool_profiles::ToolProfile::Power;
862 std::fs::write(
863 home.join(".cursor/rules/lean-ctx.mdc"),
864 rc::render(
865 false,
866 Wrapper::HookCovered,
867 crate::core::config::CompressionLevel::Off,
868 &tp,
869 ),
870 )
871 .unwrap();
872 std::fs::write(
873 home.join(".cursor/hooks.json"),
874 r#"{"version":1,"hooks":{"preToolUse":[
875 {"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
876 {"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
877 ]}}"#,
878 )
879 .unwrap();
880 let old_home = std::env::var("HOME").ok();
881 crate::test_env::set_var("HOME", home);
882 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
883
884 let covered = build_instructions_with_client(CrpMode::Off, "cursor");
885
886 if let Some(h) = old_home {
887 crate::test_env::set_var("HOME", h);
888 } else {
889 crate::test_env::remove_var("HOME");
890 }
891 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
892
893 assert!(
894 covered.contains("hooks compress native Shell/Read/Grep"),
895 "hook-covered client must get the hook-aware anchor:\n{covered}"
896 );
897 assert!(
898 !covered.contains("ctx_* tools replace native")
899 && !covered.contains("lean-ctx routing"),
900 "hook-covered client must not carry the replace-native wording:\n{covered}"
901 );
902 }
903
904 #[test]
905 fn non_coding_persona_block_lands_in_instructions() {
906 let _guard = crate::core::data_dir::test_env_lock();
907 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
908
909 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
910 let research = build_instructions_with_client(CrpMode::Off, "");
911
912 crate::test_env::set_var("LEAN_CTX_PERSONA", "coding");
913 let coding = build_instructions_with_client(CrpMode::Off, "");
914
915 crate::test_env::remove_var("LEAN_CTX_PERSONA");
916 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
917
918 assert!(
919 research.contains("PERSONA: research"),
920 "research persona must announce its domain block:\n{research}"
921 );
922 assert!(
923 research.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
924 "research persona must carry its intent vocabulary:\n{research}"
925 );
926 assert!(
927 !coding.contains("PERSONA:"),
928 "the coding default must keep the instructions byte-stable (#498):\n{coding}"
929 );
930 }
931
932 #[test]
933 fn under_cap_keeps_everything() {
934 let base = "tool mapping block";
935 let suffix = "OUTPUT STYLE: dense";
936 let out = assemble_within_cap(base, suffix, INSTRUCTION_CAP_TOKENS);
937 assert!(out.contains(base));
938 assert!(out.contains(suffix));
939 }
940
941 #[test]
942 fn empty_suffix_caps_base_only() {
943 let base = "x\n".repeat(4000);
944 let out = assemble_within_cap(&base, "", INSTRUCTION_CAP_TOKENS);
945 assert!(count_tokens(&out) <= INSTRUCTION_CAP_TOKENS);
946 }
947
948 #[cfg(windows)]
949 #[test]
950 fn shell_hint_stays_within_its_budget() {
951 let hint = build_shell_hint();
952 let tokens = count_tokens(&hint);
953 assert!(
954 tokens <= STATIC_INSTRUCTION_SHELL_HINT_TOKENS,
955 "shell hint = {tokens} tok, budget {STATIC_INSTRUCTION_SHELL_HINT_TOKENS}: {hint}"
956 );
957 }
958
959 #[test]
960 fn minimal_overhead_instructions_stay_within_budget() {
961 const MINIMAL_INSTRUCTION_BUDGET_TOKENS: usize =
962 STATIC_INSTRUCTION_BUDGET_TDD_TOKENS + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
963 let _iso = crate::core::data_dir::isolated_data_dir();
964 crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
965 let out = build_instructions(CrpMode::Compact);
966 crate::test_env::remove_var("LEAN_CTX_MINIMAL");
967 let tokens = count_tokens(&out);
968 assert!(
969 tokens <= MINIMAL_INSTRUCTION_BUDGET_TOKENS,
970 "minimal-overhead instructions = {tokens} tok, budget {MINIMAL_INSTRUCTION_BUDGET_TOKENS}\n---\n{out}\n---"
971 );
972 }
973
974 #[test]
975 fn static_skeleton_stays_within_budget() {
976 let _iso = crate::core::data_dir::isolated_data_dir();
977 crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
980 for (mode, base_budget) in [
981 (CrpMode::Off, STATIC_INSTRUCTION_BUDGET_TOKENS),
982 (CrpMode::Compact, STATIC_INSTRUCTION_BUDGET_TOKENS),
983 (CrpMode::Tdd, STATIC_INSTRUCTION_BUDGET_TDD_TOKENS),
984 ] {
985 let budget = base_budget + STATIC_INSTRUCTION_SHELL_HINT_TOKENS;
986 let out = build_instructions_for_test(mode);
987 let tokens = count_tokens(&out);
988 assert!(
989 tokens <= budget,
990 "static instructions for {mode:?} = {tokens} tok, budget {budget}\n---\n{out}\n---"
991 );
992 }
993 crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
994 }
995}