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 struct InstructionTemplate {
303 pub code: &'static str,
304 pub full: &'static str,
305}
306
307const TEMPLATES: &[InstructionTemplate] = &[
312 InstructionTemplate {
313 code: "ACT1",
314 full: "act now, 1-line result",
315 },
316 InstructionTemplate {
317 code: "BRIEF",
318 full: "1-2 line approach, then act",
319 },
320 InstructionTemplate {
321 code: "FULL",
322 full: "outline+edge cases first",
323 },
324 InstructionTemplate {
325 code: "DELTA",
326 full: "changed lines only",
327 },
328 InstructionTemplate {
329 code: "NOREPEAT",
330 full: "use Fn refs",
331 },
332 InstructionTemplate {
333 code: "STRUCT",
334 full: "+/-/~",
335 },
336 InstructionTemplate {
337 code: "1LINE",
338 full: "1 line/action",
339 },
340 InstructionTemplate {
341 code: "QUALITY",
342 full: "keep edge cases",
343 },
344 InstructionTemplate {
345 code: "FREF",
346 full: "Fn refs, no paths",
347 },
348 InstructionTemplate {
349 code: "DIFF",
350 full: "diff lines only",
351 },
352];
353
354pub fn instruction_decoder_block(tdd_active: bool) -> String {
359 if !tdd_active {
360 return String::new();
361 }
362 let pairs: Vec<String> = TEMPLATES
363 .iter()
364 .map(|t| format!("{}={}", t.code, t.full))
365 .collect();
366 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
367}
368
369pub fn encode_instructions(complexity: &str) -> String {
372 match complexity {
373 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
374 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
375 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
376 "complex" => {
377 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
378 }
379 "architectural" => {
380 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
381 }
382 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
383 }
384}
385
386pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
388 let snr = if compression_pct > 0.0 {
389 1.0 - (compression_pct / 100.0)
390 } else {
391 1.0
392 };
393 let base = encode_instructions(complexity);
394 format!("{base} | SNR: {snr:.2}")
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn display_path_normalizes_windows_separators() {
403 assert_eq!(
405 display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
406 "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
407 );
408 assert_eq!(display_path("src/main.rs"), "src/main.rs");
409 }
410
411 #[test]
412 fn shorten_path_basename_for_windows_abs_path() {
413 assert_eq!(
414 shorten_path(r"D:\Temp\win-build-raw.log"),
415 "win-build-raw.log"
416 );
417 assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
418 }
419
420 #[test]
421 fn shorten_path_relative_handles_windows_separators() {
422 assert_eq!(
424 shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
425 "src/app/main.rs"
426 );
427 assert_eq!(
429 shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
430 "src/main.rs"
431 );
432 assert_eq!(
435 shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
436 "build.log"
437 );
438 }
439
440 #[test]
441 fn shorten_path_relative_requires_component_boundary() {
442 assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
444 assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
445 }
446
447 #[test]
448 fn is_project_root_marker_detects_git() {
449 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
450 let _ = std::fs::create_dir_all(&tmp);
451 let git_dir = tmp.join(".git");
452 let _ = std::fs::create_dir_all(&git_dir);
453 assert!(is_project_root_marker(&tmp));
454 let _ = std::fs::remove_dir_all(&tmp);
455 }
456
457 #[test]
458 fn is_project_root_marker_detects_cargo_toml() {
459 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
460 let _ = std::fs::create_dir_all(&tmp);
461 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
462 assert!(is_project_root_marker(&tmp));
463 let _ = std::fs::remove_dir_all(&tmp);
464 }
465
466 #[test]
467 fn detect_project_root_finds_outermost() {
468 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
469 let inner = base.join("packages").join("app");
470 let _ = std::fs::create_dir_all(&inner);
471 let _ = std::fs::create_dir_all(base.join(".git"));
472 let _ = std::fs::create_dir_all(inner.join(".git"));
473
474 let test_file = inner.join("main.rs");
475 let _ = std::fs::write(&test_file, "fn main() {}");
476
477 let root = detect_project_root(test_file.to_str().unwrap());
478 assert!(root.is_some(), "should find a project root for nested .git");
479 let root_path = std::path::PathBuf::from(root.unwrap());
480 assert_eq!(
481 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
482 crate::core::pathutil::safe_canonicalize(&base).ok(),
483 "should return outermost .git, not inner"
484 );
485
486 let _ = std::fs::remove_dir_all(&base);
487 }
488
489 #[test]
490 fn decoder_block_contains_all_codes() {
491 let block = instruction_decoder_block(true);
492 for t in TEMPLATES {
493 assert!(
494 block.contains(t.code),
495 "decoder should contain code {}",
496 t.code
497 );
498 }
499 }
500
501 #[test]
502 fn decoder_block_empty_outside_tdd() {
503 assert!(instruction_decoder_block(false).is_empty());
504 }
505
506 #[test]
507 fn decoder_codes_match_what_encode_can_emit() {
508 let all_modes: Vec<String> = [
511 "mechanical",
512 "simple",
513 "standard",
514 "complex",
515 "architectural",
516 "unknown",
517 ]
518 .iter()
519 .map(|c| encode_instructions(c))
520 .collect();
521 for t in TEMPLATES {
522 assert!(
523 all_modes.iter().any(|m| m.contains(t.code)),
524 "code {} is defined but never emitted",
525 t.code
526 );
527 }
528 }
529
530 #[test]
531 fn encoded_instructions_are_compact() {
532 use super::super::tokens::count_tokens;
533 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
534 let encoded = encode_instructions("mechanical");
535 assert!(
536 count_tokens(&encoded) <= count_tokens(full),
537 "encoded ({}) should be <= full ({})",
538 count_tokens(&encoded),
539 count_tokens(full)
540 );
541 }
542
543 #[test]
544 fn all_complexity_levels_encode() {
545 for level in &["mechanical", "standard", "architectural"] {
546 let encoded = encode_instructions(level);
547 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
548 }
549 }
550
551 #[test]
552 fn savings_footer_env_gated_tests() {
553 let _lock = crate::core::data_dir::test_env_lock();
554
555 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
557 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
558 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
559 crate::test_env::remove_var("LEAN_CTX_QUIET");
560
561 let s = super::format_savings(100, 50);
562 assert!(s.contains("\u{2192}"), "expected arrow: {s}");
563 assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
564 assert!(
565 s.starts_with("\u{2500}\u{2500}\u{2500}"),
566 "expected box-drawing: {s}"
567 );
568
569 let s = super::format_savings_with_info(4200, 840, Some("map"), None);
571 assert!(s.contains("mode: map"), "expected mode: {s}");
572 assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
573
574 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
576 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
577 let s = super::format_savings(100, 50);
578 assert!(s.is_empty(), "expected empty with never: {s}");
579
580 let result = super::append_savings("hello", 100, 50);
581 assert_eq!(result, "hello");
582
583 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
585 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
586 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
587 let s = super::format_savings(100, 50);
588 assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
589 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
590
591 crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
593 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
594 assert!(super::savings_footer_visible());
595 crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
596 assert!(!super::savings_footer_visible());
597
598 crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
601 crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
602 }
603}