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
24#[derive(Clone, Debug)]
26pub struct ToolCallRecord {
27 pub tool: String,
28 pub original_tokens: usize,
29 pub saved_tokens: usize,
30 pub mode: Option<String>,
31 pub duration_ms: u64,
32 pub timestamp: String,
33}
34
35pub fn detect_project_root(file_path: &str) -> Option<String> {
40 let start = Path::new(file_path);
41 let mut dir = if start.is_dir() {
42 start
43 } else {
44 start.parent()?
45 };
46 let mut best: Option<String> = None;
47
48 loop {
49 if is_project_root_marker(dir) {
50 best = Some(dir.to_string_lossy().to_string());
51 }
52 match dir.parent() {
53 Some(parent) if parent != dir => dir = parent,
54 _ => break,
55 }
56 }
57 best
58}
59
60fn is_project_root_marker(dir: &Path) -> bool {
62 const MARKERS: &[&str] = &[
63 ".git",
64 "Cargo.toml",
65 "package.json",
66 "go.work",
67 "pnpm-workspace.yaml",
68 "lerna.json",
69 "nx.json",
70 "turbo.json",
71 ".projectile",
72 "pyproject.toml",
73 "setup.py",
74 "Makefile",
75 "CMakeLists.txt",
76 "BUILD.bazel",
77 ];
78 MARKERS.iter().any(|m| dir.join(m).exists())
79}
80
81pub fn detect_project_root_or_cwd(file_path: &str) -> String {
85 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
86 && !env_root.is_empty()
87 {
88 return env_root;
89 }
90 let cfg = crate::core::config::Config::load();
91 if let Some(ref cfg_root) = cfg.project_root
92 && !cfg_root.is_empty()
93 {
94 return cfg_root.clone();
95 }
96 if let Some(ide_root) = resolve_ide_path(&cfg, file_path) {
97 return ide_root;
98 }
99 if let Some(root) = detect_project_root(file_path) {
100 return root;
101 }
102
103 let fallback = {
104 let p = Path::new(file_path);
105 if p.exists() {
106 if p.is_dir() {
107 file_path.to_string()
108 } else {
109 p.parent().map_or_else(
110 || file_path.to_string(),
111 |pp| pp.to_string_lossy().to_string(),
112 )
113 }
114 } else {
115 std::env::current_dir()
116 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
117 }
118 };
119
120 if is_broad_directory(&fallback) {
121 use std::sync::Once;
122 static WARN_ONCE: Once = Once::new();
123 WARN_ONCE.call_once(|| {
124 tracing::warn!(
125 "[protocol: no project detected — current directory is {fallback} which is not a project root.\n \
126 To fix: run from inside a project (with .git, Cargo.toml, package.json, etc.)\n \
127 Or set: export LEAN_CTX_PROJECT_ROOT=/path/to/your/project]"
128 );
129 });
130 }
131
132 fallback
133}
134
135fn is_broad_directory(path: &str) -> bool {
136 if path == "/" || path == "\\" || path == "." {
137 return true;
138 }
139 if let Some(home) = dirs::home_dir() {
140 let home_str = home.to_string_lossy();
141 if path == home_str.as_ref() || path == format!("{home_str}/") {
142 return true;
143 }
144 }
145 false
146}
147
148fn resolve_ide_path(cfg: &crate::core::config::Config, file_path: &str) -> Option<String> {
151 if cfg.ide_paths.is_empty() {
152 return None;
153 }
154 let agent = std::env::var("LEAN_CTX_AGENT").ok()?;
155 let agent_lower = agent.to_lowercase();
156 let paths = cfg.ide_paths.get(&agent_lower)?;
157 let fp = Path::new(file_path);
158 for allowed in paths {
159 let ap = Path::new(allowed.as_str());
160 if fp.starts_with(ap) {
161 return Some(allowed.clone());
162 }
163 }
164 paths.first().cloned()
166}
167
168pub fn display_path(path: &str) -> String {
175 path.replace('\\', "/")
176}
177
178pub fn shorten_path(path: &str) -> String {
179 let normalized = display_path(path);
180 let p = Path::new(&normalized);
181 if let Some(name) = p.file_name() {
182 return name.to_string_lossy().to_string();
183 }
184 normalized
185}
186
187pub fn shorten_path_relative(path: &str, root: &str) -> String {
194 let norm_path = display_path(path);
195 let norm_root = display_path(root);
196 let norm_root = norm_root.strip_suffix('/').unwrap_or(&norm_root);
197 if let Some(rest) = norm_path.strip_prefix(norm_root)
198 && let Some(rel) = rest.strip_prefix('/')
199 && !rel.is_empty()
200 {
201 return rel.to_string();
202 }
203 shorten_path(&norm_path)
204}
205
206static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
211
212pub fn set_mcp_context(active: bool) {
214 MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
215}
216
217pub fn savings_footer_visible() -> bool {
221 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
222 return false;
223 }
224 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "0") {
225 return false;
226 }
227 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1") {
228 return true;
229 }
230 let mode = super::config::SavingsFooter::effective();
231 match mode {
232 super::config::SavingsFooter::Always => true,
233 super::config::SavingsFooter::Never => false,
234 super::config::SavingsFooter::Auto => {
235 !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
236 }
237 }
238}
239
240pub fn meta_visible() -> bool {
244 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
245 return false;
246 }
247 matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
248 || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
249}
250
251pub fn format_savings(original: usize, compressed: usize) -> String {
257 super::savings_footer::format_footer_basic(original, compressed)
258}
259
260pub fn format_savings_with_info(
264 original: usize,
265 compressed: usize,
266 mode: Option<&str>,
267 detail: Option<&str>,
268) -> String {
269 super::savings_footer::format_footer(&super::savings_footer::SavingsInfo {
270 original,
271 compressed,
272 mode,
273 detail,
274 })
275}
276
277pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
279 super::savings_footer::append_footer_basic(output, original, compressed)
280}
281
282pub fn append_savings_with_info(
284 output: &str,
285 original: usize,
286 compressed: usize,
287 mode: Option<&str>,
288 detail: Option<&str>,
289) -> String {
290 super::savings_footer::append_footer(
291 output,
292 &super::savings_footer::SavingsInfo {
293 original,
294 compressed,
295 mode,
296 detail,
297 },
298 )
299}
300
301pub fn strip_trailing_savings_footer(output: &str) -> &str {
311 let body = output.trim_end_matches('\n');
312 let (head, last_line) = match body.rfind('\n') {
313 Some(nl) => (&body[..nl], &body[nl + 1..]),
314 None => ("", body),
315 };
316 if is_savings_footer_line(last_line) {
317 head
318 } else {
319 output
320 }
321}
322
323fn is_savings_footer_line(line: &str) -> bool {
324 let l = line.trim();
325 (l.starts_with("\u{2500}\u{2500}\u{2500} ") && l.ends_with(" \u{2500}\u{2500}\u{2500}"))
326 || (l.starts_with("[lean-ctx: ") && l.ends_with(']'))
327}
328
329pub struct InstructionTemplate {
331 pub code: &'static str,
332 pub full: &'static str,
333}
334
335const TEMPLATES: &[InstructionTemplate] = &[
340 InstructionTemplate {
341 code: "ACT1",
342 full: "act now, 1-line result",
343 },
344 InstructionTemplate {
345 code: "BRIEF",
346 full: "1-2 line approach, then act",
347 },
348 InstructionTemplate {
349 code: "FULL",
350 full: "outline+edge cases first",
351 },
352 InstructionTemplate {
353 code: "DELTA",
354 full: "changed lines only",
355 },
356 InstructionTemplate {
357 code: "NOREPEAT",
358 full: "use Fn refs",
359 },
360 InstructionTemplate {
361 code: "STRUCT",
362 full: "+/-/~",
363 },
364 InstructionTemplate {
365 code: "1LINE",
366 full: "1 line/action",
367 },
368 InstructionTemplate {
369 code: "QUALITY",
370 full: "keep edge cases",
371 },
372 InstructionTemplate {
373 code: "FREF",
374 full: "Fn refs, no paths",
375 },
376 InstructionTemplate {
377 code: "DIFF",
378 full: "diff lines only",
379 },
380];
381
382pub fn instruction_decoder_block(tdd_active: bool) -> String {
387 if !tdd_active {
388 return String::new();
389 }
390 let pairs: Vec<String> = TEMPLATES
391 .iter()
392 .map(|t| format!("{}={}", t.code, t.full))
393 .collect();
394 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
395}
396
397pub fn encode_instructions(complexity: &str) -> String {
400 match complexity {
401 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
402 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
403 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
404 "complex" => {
405 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
406 }
407 "architectural" => {
408 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
409 }
410 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
411 }
412}
413
414pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
416 let snr = if compression_pct > 0.0 {
417 1.0 - (compression_pct / 100.0)
418 } else {
419 1.0
420 };
421 let base = encode_instructions(complexity);
422 format!("{base} | SNR: {snr:.2}")
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn strip_trailing_savings_footer_handles_both_styles() {
431 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}";
433 assert_eq!(
434 strip_trailing_savings_footer(boxed),
435 "body line one\nbody line two"
436 );
437 let verbatim = "out\n[lean-ctx: 4200\u{2192}840 tok, verbatim truncated]";
439 assert_eq!(strip_trailing_savings_footer(verbatim), "out");
440 assert_eq!(
442 strip_trailing_savings_footer("plain body\n"),
443 "plain body\n"
444 );
445 assert_eq!(
447 strip_trailing_savings_footer("[lean-ctx: 10\u{2192}5 tok, verbatim truncated]"),
448 ""
449 );
450 assert_eq!(
452 strip_trailing_savings_footer("see [lean-ctx: docs] for details"),
453 "see [lean-ctx: docs] for details"
454 );
455 }
456
457 #[test]
458 fn display_path_normalizes_windows_separators() {
459 assert_eq!(
461 display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
462 "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
463 );
464 assert_eq!(display_path("src/main.rs"), "src/main.rs");
465 }
466
467 #[test]
468 fn shorten_path_basename_for_windows_abs_path() {
469 assert_eq!(
470 shorten_path(r"D:\Temp\win-build-raw.log"),
471 "win-build-raw.log"
472 );
473 assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
474 }
475
476 #[test]
477 fn shorten_path_relative_handles_windows_separators() {
478 assert_eq!(
480 shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
481 "src/app/main.rs"
482 );
483 assert_eq!(
485 shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
486 "src/main.rs"
487 );
488 assert_eq!(
491 shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
492 "build.log"
493 );
494 }
495
496 #[test]
497 fn shorten_path_relative_requires_component_boundary() {
498 assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
500 assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
501 }
502
503 #[test]
504 fn is_project_root_marker_detects_git() {
505 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
506 let _ = std::fs::create_dir_all(&tmp);
507 let git_dir = tmp.join(".git");
508 let _ = std::fs::create_dir_all(&git_dir);
509 assert!(is_project_root_marker(&tmp));
510 let _ = std::fs::remove_dir_all(&tmp);
511 }
512
513 #[test]
514 fn is_project_root_marker_detects_cargo_toml() {
515 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
516 let _ = std::fs::create_dir_all(&tmp);
517 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
518 assert!(is_project_root_marker(&tmp));
519 let _ = std::fs::remove_dir_all(&tmp);
520 }
521
522 #[test]
523 fn detect_project_root_finds_outermost() {
524 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
525 let inner = base.join("packages").join("app");
526 let _ = std::fs::create_dir_all(&inner);
527 let _ = std::fs::create_dir_all(base.join(".git"));
528 let _ = std::fs::create_dir_all(inner.join(".git"));
529
530 let test_file = inner.join("main.rs");
531 let _ = std::fs::write(&test_file, "fn main() {}");
532
533 let root = detect_project_root(test_file.to_str().unwrap());
534 assert!(root.is_some(), "should find a project root for nested .git");
535 let root_path = std::path::PathBuf::from(root.unwrap());
536 assert_eq!(
537 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
538 crate::core::pathutil::safe_canonicalize(&base).ok(),
539 "should return outermost .git, not inner"
540 );
541
542 let _ = std::fs::remove_dir_all(&base);
543 }
544
545 #[test]
546 fn decoder_block_contains_all_codes() {
547 let block = instruction_decoder_block(true);
548 for t in TEMPLATES {
549 assert!(
550 block.contains(t.code),
551 "decoder should contain code {}",
552 t.code
553 );
554 }
555 }
556
557 #[test]
558 fn decoder_block_empty_outside_tdd() {
559 assert!(instruction_decoder_block(false).is_empty());
560 }
561
562 #[test]
563 fn decoder_codes_match_what_encode_can_emit() {
564 let all_modes: Vec<String> = [
567 "mechanical",
568 "simple",
569 "standard",
570 "complex",
571 "architectural",
572 "unknown",
573 ]
574 .iter()
575 .map(|c| encode_instructions(c))
576 .collect();
577 for t in TEMPLATES {
578 assert!(
579 all_modes.iter().any(|m| m.contains(t.code)),
580 "code {} is defined but never emitted",
581 t.code
582 );
583 }
584 }
585
586 #[test]
587 fn encoded_instructions_are_compact() {
588 use super::super::tokens::count_tokens;
589 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
590 let encoded = encode_instructions("mechanical");
591 assert!(
592 count_tokens(&encoded) <= count_tokens(full),
593 "encoded ({}) should be <= full ({})",
594 count_tokens(&encoded),
595 count_tokens(full)
596 );
597 }
598
599 #[test]
600 fn all_complexity_levels_encode() {
601 for level in &["mechanical", "standard", "architectural"] {
602 let encoded = encode_instructions(level);
603 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
604 }
605 }
606
607 #[test]
608 fn savings_footer_env_gated_tests() {
609 let _lock = crate::core::data_dir::test_env_lock();
610
611 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
613 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
614 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
615 crate::test_env::set_var("LEAN_CTX_COMPRESSION_ANNOTATION", "full");
616 crate::test_env::remove_var("LEAN_CTX_QUIET");
617
618 let s = super::format_savings(100, 50);
619 assert!(s.contains("\u{2192}"), "expected arrow: {s}");
620 assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
621 assert!(
622 s.starts_with("\u{2500}\u{2500}\u{2500}"),
623 "expected box-drawing: {s}"
624 );
625
626 let s = super::format_savings_with_info(4200, 840, Some("map"), None);
627 assert!(s.contains("mode: map"), "expected mode: {s}");
628 assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
629
630 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
632 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
633 let s = super::format_savings(100, 50);
634 assert!(s.is_empty(), "expected empty with never: {s}");
635
636 let result = super::append_savings("hello", 100, 50);
637 assert_eq!(result, "hello");
638
639 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
641 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
642 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
643 let s = super::format_savings(100, 50);
644 assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
645 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
646
647 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
649 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
650 assert!(super::savings_footer_visible());
651 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
652 assert!(!super::savings_footer_visible());
653
654 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
655 crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
656 crate::test_env::remove_var("LEAN_CTX_COMPRESSION_ANNOTATION");
657 }
658}