1use std::path::Path;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum CrpMode {
8 Off,
9 Compact,
10 Tdd,
11}
12
13impl CrpMode {
14 pub fn parse(s: &str) -> Option<Self> {
15 match s.trim().to_lowercase().as_str() {
16 "off" => Some(Self::Off),
17 "compact" => Some(Self::Compact),
18 "tdd" => Some(Self::Tdd),
19 _ => None,
20 }
21 }
22
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Self::Off => "off",
26 Self::Compact => "compact",
27 Self::Tdd => "tdd",
28 }
29 }
30
31 pub fn resolve(profile_crp: Option<&str>) -> Self {
35 use crate::core::config::{CompressionLevel, Config};
36 let env = std::env::var("LEAN_CTX_CRP_MODE").ok();
37 let forced = CompressionLevel::session_degrade_level().or_else(CompressionLevel::from_env);
38 let configured = CompressionLevel::effective(&Config::load());
39 Self::resolve_from(env.as_deref(), forced, configured, profile_crp)
40 }
41
42 pub(crate) fn resolve_from(
47 env: Option<&str>,
48 forced: Option<crate::core::config::CompressionLevel>,
49 configured: crate::core::config::CompressionLevel,
50 profile_crp: Option<&str>,
51 ) -> Self {
52 let level_crp = |level: crate::core::config::CompressionLevel| {
53 Self::parse(level.to_components().2).unwrap_or(Self::Off)
54 };
55 if let Some(v) = env.filter(|v| !v.trim().is_empty()) {
56 return Self::parse(v).unwrap_or(Self::Off);
57 }
58 if let Some(level) = forced {
59 return level_crp(level);
60 }
61 let configured = level_crp(configured);
62 if configured != Self::Off {
63 return configured;
64 }
65 profile_crp.and_then(Self::parse).unwrap_or(Self::Off)
66 }
67
68 pub fn effective() -> Self {
70 Self::resolve(
71 crate::core::profiles::active_profile()
72 .compression
73 .crp_mode
74 .as_deref(),
75 )
76 }
77
78 pub fn is_tdd(&self) -> bool {
80 *self == Self::Tdd
81 }
82}
83
84#[derive(Clone, Debug)]
86pub struct ToolCallRecord {
87 pub tool: String,
88 pub original_tokens: usize,
89 pub saved_tokens: usize,
90 pub mode: Option<String>,
91 pub duration_ms: u64,
92 pub timestamp: String,
93}
94
95pub fn detect_project_root(file_path: &str) -> Option<String> {
105 let start = Path::new(file_path);
106 let mut dir = if start.is_dir() {
107 start
108 } else {
109 start.parent()?
110 };
111 let (mut git, mut workspace, mut generic): (Option<&Path>, Option<&Path>, Option<&Path>) =
112 (None, None, None);
113
114 loop {
115 if !crate::core::pathutil::is_broad_or_unsafe_root(dir) {
118 if dir.join(".git").exists() {
119 git = Some(dir);
120 } else if is_workspace_root(dir) {
121 workspace = Some(dir);
122 } else if generic.is_none() && is_project_root_marker(dir) {
123 generic = Some(dir);
124 }
125 }
126 match dir.parent() {
127 Some(parent) if parent != dir => dir = parent,
128 _ => break,
129 }
130 }
131 git.or(workspace)
132 .or(generic)
133 .map(|dir| dir.to_string_lossy().to_string())
134}
135
136fn is_workspace_root(dir: &Path) -> bool {
139 const WORKSPACE_MARKERS: &[&str] = &[
140 "go.work",
141 "pnpm-workspace.yaml",
142 "lerna.json",
143 "nx.json",
144 "turbo.json",
145 ];
146 let declares = |file: &str, needle: &str| {
147 std::fs::read_to_string(dir.join(file)).is_ok_and(|text| text.contains(needle))
148 };
149 WORKSPACE_MARKERS.iter().any(|m| dir.join(m).exists())
150 || declares("Cargo.toml", "[workspace]")
151 || declares("package.json", "\"workspaces\"")
152}
153
154pub fn request_project_unresolved() -> bool {
158 crate::core::policy::runtime::REQUEST_PROJECT
159 .try_with(|slot| slot.borrow().is_none())
160 .unwrap_or(false)
161}
162
163fn is_project_root_marker(dir: &Path) -> bool {
165 const MARKERS: &[&str] = &[
166 ".git",
167 "Cargo.toml",
168 "package.json",
169 "go.work",
170 "pnpm-workspace.yaml",
171 "lerna.json",
172 "nx.json",
173 "turbo.json",
174 ".projectile",
175 "pyproject.toml",
176 "setup.py",
177 "Makefile",
178 "CMakeLists.txt",
179 "BUILD.bazel",
180 ];
181 MARKERS.iter().any(|m| dir.join(m).exists())
182}
183
184pub fn detect_project_root_or_cwd(file_path: &str) -> String {
189 if let Ok(project) =
194 crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
195 {
196 return match project {
197 Some(root) => root.to_string_lossy().into_owned(),
198 None => detect_project_root(file_path).unwrap_or_else(|| {
199 let path = Path::new(file_path);
200 if path.is_dir() {
201 file_path.to_string()
202 } else {
203 path.parent().map_or_else(
204 || file_path.to_string(),
205 |p| p.to_string_lossy().into_owned(),
206 )
207 }
208 }),
209 };
210 }
211 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
212 && !env_root.is_empty()
213 {
214 return env_root;
215 }
216 let cfg = crate::core::config::Config::load();
217 if let Some(ref cfg_root) = cfg.project_root
218 && !cfg_root.is_empty()
219 {
220 return cfg_root.clone();
221 }
222 if let Some(ide_root) = resolve_ide_path(&cfg, file_path) {
223 return ide_root;
224 }
225 if let Some(root) = detect_project_root(file_path) {
226 return root;
227 }
228
229 let fallback = {
230 let p = Path::new(file_path);
231 if p.exists() {
232 if p.is_dir() {
233 file_path.to_string()
234 } else {
235 p.parent().map_or_else(
236 || file_path.to_string(),
237 |pp| pp.to_string_lossy().to_string(),
238 )
239 }
240 } else {
241 std::env::current_dir()
242 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
243 }
244 };
245
246 if is_broad_directory(&fallback) && !crate::core::runtime_flags::is_dashboard_server() {
247 use std::sync::Once;
248 static WARN_ONCE: Once = Once::new();
249 WARN_ONCE.call_once(|| {
250 tracing::warn!(
251 "[protocol: no project detected — current directory is {fallback} which is not a project root.\n \
252 To fix: run from inside a project (with .git, Cargo.toml, package.json, etc.)\n \
253 Or set: export LEAN_CTX_PROJECT_ROOT=/path/to/your/project]"
254 );
255 });
256 }
257
258 fallback
259}
260
261fn is_broad_directory(path: &str) -> bool {
262 if path == "/" || path == "\\" || path == "." {
263 return true;
264 }
265 if let Some(home) = dirs::home_dir() {
266 let home_str = home.to_string_lossy();
267 if path == home_str.as_ref() || path == format!("{home_str}/") {
268 return true;
269 }
270 }
271 false
272}
273
274fn resolve_ide_path(cfg: &crate::core::config::Config, file_path: &str) -> Option<String> {
277 if cfg.ide_paths.is_empty() {
278 return None;
279 }
280 let agent = std::env::var("LEAN_CTX_AGENT").ok()?;
281 let agent_lower = agent.to_lowercase();
282 let paths = cfg.ide_paths.get(&agent_lower)?;
283 let fp = Path::new(file_path);
284 for allowed in paths {
285 let ap = Path::new(allowed.as_str());
286 if fp.starts_with(ap) {
287 return Some(allowed.clone());
288 }
289 }
290 paths.first().cloned()
292}
293
294pub fn display_path(path: &str) -> String {
301 path.replace('\\', "/")
302}
303
304pub fn shorten_path(path: &str) -> String {
305 let normalized = display_path(path);
306 let p = Path::new(&normalized);
307 if let Some(name) = p.file_name() {
308 return name.to_string_lossy().to_string();
309 }
310 normalized
311}
312
313pub fn shorten_path_relative(path: &str, root: &str) -> String {
320 let norm_path = display_path(path);
321 let norm_root = display_path(root);
322 let norm_root = norm_root.strip_suffix('/').unwrap_or(&norm_root);
323 if let Some(rest) = norm_path.strip_prefix(norm_root)
324 && let Some(rel) = rest.strip_prefix('/')
325 && !rel.is_empty()
326 {
327 return rel.to_string();
328 }
329 shorten_path(&norm_path)
330}
331
332static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
337
338pub fn set_mcp_context(active: bool) {
340 MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
341}
342
343pub fn savings_footer_visible() -> bool {
347 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
348 return false;
349 }
350 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "0") {
351 return false;
352 }
353 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1") {
354 return true;
355 }
356 let mode = super::config::SavingsFooter::effective();
357 match mode {
358 super::config::SavingsFooter::Always => true,
359 super::config::SavingsFooter::Never => false,
360 super::config::SavingsFooter::Auto => {
361 !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
362 }
363 }
364}
365
366pub fn meta_visible() -> bool {
370 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
371 return false;
372 }
373 matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
374 || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
375}
376
377pub fn format_savings(original: usize, compressed: usize) -> String {
383 super::savings_footer::format_footer_basic(original, compressed)
384}
385
386pub fn format_savings_with_info(
390 original: usize,
391 compressed: usize,
392 mode: Option<&str>,
393 detail: Option<&str>,
394) -> String {
395 super::savings_footer::format_footer(&super::savings_footer::SavingsInfo {
396 original,
397 compressed,
398 mode,
399 detail,
400 })
401}
402
403pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
405 super::savings_footer::append_footer_basic(output, original, compressed)
406}
407
408pub fn append_savings_with_info(
410 output: &str,
411 original: usize,
412 compressed: usize,
413 mode: Option<&str>,
414 detail: Option<&str>,
415) -> String {
416 super::savings_footer::append_footer(
417 output,
418 &super::savings_footer::SavingsInfo {
419 original,
420 compressed,
421 mode,
422 detail,
423 },
424 )
425}
426
427pub fn strip_trailing_savings_footer(output: &str) -> &str {
437 let body = output.trim_end_matches('\n');
438 let (head, last_line) = match body.rfind('\n') {
439 Some(nl) => (&body[..nl], &body[nl + 1..]),
440 None => ("", body),
441 };
442 if is_savings_footer_line(last_line) {
443 head
444 } else {
445 output
446 }
447}
448
449fn is_savings_footer_line(line: &str) -> bool {
450 let l = line.trim();
451 (l.starts_with("\u{2500}\u{2500}\u{2500} ") && l.ends_with(" \u{2500}\u{2500}\u{2500}"))
452 || (l.starts_with("[lean-ctx: ") && l.ends_with(']'))
453}
454
455pub struct InstructionTemplate {
457 pub code: &'static str,
458 pub full: &'static str,
459}
460
461const TEMPLATES: &[InstructionTemplate] = &[
466 InstructionTemplate {
467 code: "ACT1",
468 full: "act now, 1-line result",
469 },
470 InstructionTemplate {
471 code: "BRIEF",
472 full: "1-2 line approach, then act",
473 },
474 InstructionTemplate {
475 code: "FULL",
476 full: "outline+edge cases first",
477 },
478 InstructionTemplate {
479 code: "DELTA",
480 full: "changed lines only",
481 },
482 InstructionTemplate {
483 code: "NOREPEAT",
484 full: "use Fn refs",
485 },
486 InstructionTemplate {
487 code: "STRUCT",
488 full: "+/-/~",
489 },
490 InstructionTemplate {
491 code: "1LINE",
492 full: "1 line/action",
493 },
494 InstructionTemplate {
495 code: "QUALITY",
496 full: "keep edge cases",
497 },
498 InstructionTemplate {
499 code: "FREF",
500 full: "Fn refs, no paths",
501 },
502 InstructionTemplate {
503 code: "DIFF",
504 full: "diff lines only",
505 },
506];
507
508pub fn instruction_decoder_block(tdd_active: bool) -> String {
513 if !tdd_active {
514 return String::new();
515 }
516 let pairs: Vec<String> = TEMPLATES
517 .iter()
518 .map(|t| format!("{}={}", t.code, t.full))
519 .collect();
520 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
521}
522
523pub fn encode_instructions(complexity: &str) -> String {
526 match complexity {
527 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
528 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
529 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
530 "complex" => {
531 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
532 }
533 "architectural" => {
534 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
535 }
536 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
537 }
538}
539
540pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
542 let snr = if compression_pct > 0.0 {
543 1.0 - (compression_pct / 100.0)
544 } else {
545 1.0
546 };
547 let base = encode_instructions(complexity);
548 format!("{base} | SNR: {snr:.2}")
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
558 fn a_pin_does_not_replace_the_request_project() {
559 let _data = crate::core::data_dir::isolated_data_dir();
560 let (request, pinned) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
561 let file = request.path().join("lib.rs");
562 std::fs::write(&file, "fn main() {}\n").unwrap();
563 let _pin = crate::test_env::VarGuard::set("LEAN_CTX_PROJECT_ROOT", pinned.path());
564
565 let root = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
566 std::cell::RefCell::new(Some(request.path().to_path_buf())),
567 || detect_project_root_or_cwd(&file.to_string_lossy()),
568 );
569
570 assert_eq!(root, request.path().to_string_lossy());
571 }
572
573 #[test]
574 fn strip_trailing_savings_footer_handles_both_styles() {
575 let boxed = "body line one\nbody line two\n\u{2500}\u{2500}\u{2500} 4,200 \u{2192} 840 tok (\u{2193}80%) \u{2500}\u{2500}\u{2500}";
577 assert_eq!(
578 strip_trailing_savings_footer(boxed),
579 "body line one\nbody line two"
580 );
581 let verbatim = "out\n[lean-ctx: 4200\u{2192}840 tok, verbatim truncated]";
583 assert_eq!(strip_trailing_savings_footer(verbatim), "out");
584 assert_eq!(
586 strip_trailing_savings_footer("plain body\n"),
587 "plain body\n"
588 );
589 assert_eq!(
591 strip_trailing_savings_footer("[lean-ctx: 10\u{2192}5 tok, verbatim truncated]"),
592 ""
593 );
594 assert_eq!(
596 strip_trailing_savings_footer("see [lean-ctx: docs] for details"),
597 "see [lean-ctx: docs] for details"
598 );
599 }
600
601 #[test]
602 fn display_path_normalizes_windows_separators() {
603 assert_eq!(
605 display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
606 "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
607 );
608 assert_eq!(display_path("src/main.rs"), "src/main.rs");
609 }
610
611 #[test]
612 fn shorten_path_basename_for_windows_abs_path() {
613 assert_eq!(
614 shorten_path(r"D:\Temp\win-build-raw.log"),
615 "win-build-raw.log"
616 );
617 assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
618 }
619
620 #[test]
621 fn shorten_path_relative_handles_windows_separators() {
622 assert_eq!(
624 shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
625 "src/app/main.rs"
626 );
627 assert_eq!(
629 shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
630 "src/main.rs"
631 );
632 assert_eq!(
635 shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
636 "build.log"
637 );
638 }
639
640 #[test]
641 fn shorten_path_relative_requires_component_boundary() {
642 assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
644 assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
645 }
646
647 #[test]
648 fn is_project_root_marker_detects_git() {
649 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
650 let _ = std::fs::create_dir_all(&tmp);
651 let git_dir = tmp.join(".git");
652 let _ = std::fs::create_dir_all(&git_dir);
653 assert!(is_project_root_marker(&tmp));
654 let _ = std::fs::remove_dir_all(&tmp);
655 }
656
657 #[test]
658 fn is_project_root_marker_detects_cargo_toml() {
659 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
660 let _ = std::fs::create_dir_all(&tmp);
661 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
662 assert!(is_project_root_marker(&tmp));
663 let _ = std::fs::remove_dir_all(&tmp);
664 }
665
666 #[test]
667 fn detect_project_root_finds_outermost() {
668 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
669 let inner = base.join("packages").join("app");
670 let _ = std::fs::create_dir_all(&inner);
671 let _ = std::fs::create_dir_all(base.join(".git"));
672 let _ = std::fs::create_dir_all(inner.join(".git"));
673
674 let test_file = inner.join("main.rs");
675 let _ = std::fs::write(&test_file, "fn main() {}");
676
677 let root = detect_project_root(test_file.to_str().unwrap());
678 assert!(root.is_some(), "should find a project root for nested .git");
679 let root_path = std::path::PathBuf::from(root.unwrap());
680 assert_eq!(
681 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
682 crate::core::pathutil::safe_canonicalize(&base).ok(),
683 "should return outermost .git, not inner"
684 );
685
686 let _ = std::fs::remove_dir_all(&base);
687 }
688
689 #[test]
690 fn stray_generic_marker_in_an_ancestor_is_not_the_project() {
691 let canon = |p: &std::path::Path| crate::core::pathutil::safe_canonicalize(p).ok();
692 let detect = |file: &std::path::Path| {
693 detect_project_root(file.to_str().unwrap()).map(std::path::PathBuf::from)
694 };
695 let base = tempfile::tempdir().unwrap();
696 std::fs::write(base.path().join("Makefile"), "all:\n").unwrap();
697
698 let repo = base.path().join("repo");
700 std::fs::create_dir_all(repo.join(".git")).unwrap();
701 std::fs::create_dir_all(repo.join("src")).unwrap();
702 std::fs::write(repo.join("src/main.rs"), "fn main() {}").unwrap();
703 let found = detect(&repo.join("src/main.rs")).unwrap();
704 assert_eq!(canon(&found), canon(&repo));
705
706 let plain = base.path().join("plain");
708 std::fs::create_dir_all(&plain).unwrap();
709 std::fs::write(plain.join("package.json"), "{}").unwrap();
710 std::fs::write(plain.join("index.js"), "").unwrap();
711 let found = detect(&plain.join("index.js")).unwrap();
712 assert_eq!(canon(&found), canon(&plain));
713
714 let ws = base.path().join("ws");
716 let member = ws.join("crates").join("a");
717 std::fs::create_dir_all(&member).unwrap();
718 std::fs::write(
719 ws.join("Cargo.toml"),
720 "[workspace]\nmembers = [\"crates/*\"]\n",
721 )
722 .unwrap();
723 std::fs::write(member.join("Cargo.toml"), "[package]\nname = \"a\"\n").unwrap();
724 std::fs::write(member.join("lib.rs"), "").unwrap();
725 let found = detect(&member.join("lib.rs")).unwrap();
726 assert_eq!(canon(&found), canon(&ws));
727 }
728
729 #[test]
730 fn decoder_block_contains_all_codes() {
731 let block = instruction_decoder_block(true);
732 for t in TEMPLATES {
733 assert!(
734 block.contains(t.code),
735 "decoder should contain code {}",
736 t.code
737 );
738 }
739 }
740
741 #[test]
742 fn decoder_block_empty_outside_tdd() {
743 {
744 let found = &instruction_decoder_block(false);
745 assert!(found.is_empty(), "{found:?}");
746 }
747 }
748
749 #[test]
750 fn decoder_codes_match_what_encode_can_emit() {
751 let all_modes: Vec<String> = [
754 "mechanical",
755 "simple",
756 "standard",
757 "complex",
758 "architectural",
759 "unknown",
760 ]
761 .iter()
762 .map(|c| encode_instructions(c))
763 .collect();
764 for t in TEMPLATES {
765 assert!(
766 all_modes.iter().any(|m| m.contains(t.code)),
767 "code {} is defined but never emitted",
768 t.code
769 );
770 }
771 }
772
773 #[test]
774 fn encoded_instructions_are_compact() {
775 use super::super::tokens::count_tokens;
776 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
777 let encoded = encode_instructions("mechanical");
778 assert!(
779 count_tokens(&encoded) <= count_tokens(full),
780 "encoded ({}) should be <= full ({})",
781 count_tokens(&encoded),
782 count_tokens(full)
783 );
784 }
785
786 #[test]
787 fn all_complexity_levels_encode() {
788 for level in &["mechanical", "standard", "architectural"] {
789 let encoded = encode_instructions(level);
790 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
791 }
792 }
793
794 #[test]
795 fn savings_footer_env_gated_tests() {
796 let _lock = crate::core::data_dir::test_env_lock();
797
798 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
800 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
801 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
802 crate::test_env::set_var("LEAN_CTX_COMPRESSION_ANNOTATION", "full");
803 crate::test_env::remove_var("LEAN_CTX_QUIET");
804
805 let s = super::format_savings(100, 50);
806 assert!(s.contains("\u{2192}"), "expected arrow: {s}");
807 assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
808 assert!(
809 s.starts_with("\u{2500}\u{2500}\u{2500}"),
810 "expected box-drawing: {s}"
811 );
812
813 let s = super::format_savings_with_info(4200, 840, Some("map"), None);
814 assert!(s.contains("mode: map"), "expected mode: {s}");
815 assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
816
817 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
819 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
820 let s = super::format_savings(100, 50);
821 assert!(s.is_empty(), "expected empty with never: {s}");
822
823 let result = super::append_savings("hello", 100, 50);
824 assert_eq!(result, "hello");
825
826 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
828 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
829 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
830 let s = super::format_savings(100, 50);
831 assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
832 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
833
834 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
836 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
837 assert!(super::savings_footer_visible());
838 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
839 assert!(!super::savings_footer_visible());
840
841 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
842 crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
843 crate::test_env::remove_var("LEAN_CTX_COMPRESSION_ANNOTATION");
844 }
845
846 #[test]
849 fn crp_resolution_precedence() {
850 use crate::core::config::CompressionLevel as L;
851 let r = CrpMode::resolve_from;
852 assert_eq!(r(None, None, L::Lite, None), CrpMode::Off);
854 assert_eq!(r(None, None, L::Lite, Some("tdd")), CrpMode::Tdd);
856 assert_eq!(r(None, None, L::Off, Some("compact")), CrpMode::Compact);
857 assert_eq!(r(None, None, L::Standard, Some("tdd")), CrpMode::Compact);
859 assert_eq!(r(None, None, L::Max, Some("off")), CrpMode::Tdd);
860 assert_eq!(r(None, Some(L::Lite), L::Max, Some("tdd")), CrpMode::Off);
862 assert_eq!(
864 r(Some("tdd"), Some(L::Off), L::Off, Some("off")),
865 CrpMode::Tdd
866 );
867 assert_eq!(r(Some("bogus"), None, L::Max, Some("tdd")), CrpMode::Off);
868 assert_eq!(r(Some(" "), None, L::Lite, Some("tdd")), CrpMode::Tdd);
869 assert_eq!(r(None, None, L::Lite, Some("weird")), CrpMode::Off);
871 }
872
873 #[test]
874 fn crp_mode_str_round_trips() {
875 for m in [CrpMode::Off, CrpMode::Compact, CrpMode::Tdd] {
876 assert_eq!(CrpMode::parse(m.as_str()), Some(m));
877 }
878 }
879}