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 if !env_root.is_empty() {
87 return env_root;
88 }
89 }
90 let cfg = crate::core::config::Config::load();
91 if let Some(ref cfg_root) = cfg.project_root {
92 if !cfg_root.is_empty() {
93 return cfg_root.clone();
94 }
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 if let Some(rel) = rest.strip_prefix('/') {
199 if !rel.is_empty() {
200 return rel.to_string();
201 }
202 }
203 }
204 shorten_path(&norm_path)
205}
206
207static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
212
213pub fn set_mcp_context(active: bool) {
215 MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
216}
217
218pub fn savings_footer_visible() -> bool {
222 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
223 return false;
224 }
225 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "0") {
226 return false;
227 }
228 if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1") {
229 return true;
230 }
231 let mode = super::config::SavingsFooter::effective();
232 match mode {
233 super::config::SavingsFooter::Always => true,
234 super::config::SavingsFooter::Never => false,
235 super::config::SavingsFooter::Auto => {
236 !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
237 }
238 }
239}
240
241pub fn meta_visible() -> bool {
245 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
246 return false;
247 }
248 matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
249 || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
250}
251
252pub fn format_savings(original: usize, compressed: usize) -> String {
258 super::savings_footer::format_footer_basic(original, compressed)
259}
260
261pub fn format_savings_with_info(
265 original: usize,
266 compressed: usize,
267 mode: Option<&str>,
268 detail: Option<&str>,
269) -> String {
270 super::savings_footer::format_footer(&super::savings_footer::SavingsInfo {
271 original,
272 compressed,
273 mode,
274 detail,
275 })
276}
277
278pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
280 super::savings_footer::append_footer_basic(output, original, compressed)
281}
282
283pub fn append_savings_with_info(
285 output: &str,
286 original: usize,
287 compressed: usize,
288 mode: Option<&str>,
289 detail: Option<&str>,
290) -> String {
291 super::savings_footer::append_footer(
292 output,
293 &super::savings_footer::SavingsInfo {
294 original,
295 compressed,
296 mode,
297 detail,
298 },
299 )
300}
301
302pub struct InstructionTemplate {
304 pub code: &'static str,
305 pub full: &'static str,
306}
307
308const TEMPLATES: &[InstructionTemplate] = &[
309 InstructionTemplate {
310 code: "ACT1",
311 full: "Act immediately, 1-line result",
312 },
313 InstructionTemplate {
314 code: "BRIEF",
315 full: "1-2 line approach, then act",
316 },
317 InstructionTemplate {
318 code: "FULL",
319 full: "Outline+edge cases, then act",
320 },
321 InstructionTemplate {
322 code: "DELTA",
323 full: "Changed lines only",
324 },
325 InstructionTemplate {
326 code: "NOREPEAT",
327 full: "No repeat, use Fn refs",
328 },
329 InstructionTemplate {
330 code: "STRUCT",
331 full: "+/-/~ notation",
332 },
333 InstructionTemplate {
334 code: "1LINE",
335 full: "1 line per action",
336 },
337 InstructionTemplate {
338 code: "NODOC",
339 full: "No narration comments",
340 },
341 InstructionTemplate {
342 code: "ACTFIRST",
343 full: "Tool calls first, no narration",
344 },
345 InstructionTemplate {
346 code: "QUALITY",
347 full: "Never skip edge cases",
348 },
349 InstructionTemplate {
350 code: "NOMOCK",
351 full: "No mock/placeholder data",
352 },
353 InstructionTemplate {
354 code: "FREF",
355 full: "Fn refs only, no full paths",
356 },
357 InstructionTemplate {
358 code: "DIFF",
359 full: "Diff lines only",
360 },
361 InstructionTemplate {
362 code: "ABBREV",
363 full: "fn,cfg,impl,deps,req,res,ctx,err",
364 },
365 InstructionTemplate {
366 code: "SYMBOLS",
367 full: "+=add -=rm ~=mod ->=ret",
368 },
369];
370
371pub fn instruction_decoder_block() -> String {
375 let mode = crate::core::profiles::active_profile()
376 .compression
377 .crp_mode_effective()
378 .to_string();
379 if mode != "tdd" {
380 return String::new();
381 }
382 let pairs: Vec<String> = TEMPLATES
383 .iter()
384 .map(|t| format!("{}={}", t.code, t.full))
385 .collect();
386 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
387}
388
389pub fn encode_instructions(complexity: &str) -> String {
392 match complexity {
393 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
394 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
395 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
396 "complex" => {
397 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
398 }
399 "architectural" => {
400 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
401 }
402 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
403 }
404}
405
406pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
408 let snr = if compression_pct > 0.0 {
409 1.0 - (compression_pct / 100.0)
410 } else {
411 1.0
412 };
413 let base = encode_instructions(complexity);
414 format!("{base} | SNR: {snr:.2}")
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn display_path_normalizes_windows_separators() {
423 assert_eq!(
425 display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
426 "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
427 );
428 assert_eq!(display_path("src/main.rs"), "src/main.rs");
429 }
430
431 #[test]
432 fn shorten_path_basename_for_windows_abs_path() {
433 assert_eq!(
434 shorten_path(r"D:\Temp\win-build-raw.log"),
435 "win-build-raw.log"
436 );
437 assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
438 }
439
440 #[test]
441 fn shorten_path_relative_handles_windows_separators() {
442 assert_eq!(
444 shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
445 "src/app/main.rs"
446 );
447 assert_eq!(
449 shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
450 "src/main.rs"
451 );
452 assert_eq!(
455 shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
456 "build.log"
457 );
458 }
459
460 #[test]
461 fn shorten_path_relative_requires_component_boundary() {
462 assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
464 assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
465 }
466
467 #[test]
468 fn is_project_root_marker_detects_git() {
469 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
470 let _ = std::fs::create_dir_all(&tmp);
471 let git_dir = tmp.join(".git");
472 let _ = std::fs::create_dir_all(&git_dir);
473 assert!(is_project_root_marker(&tmp));
474 let _ = std::fs::remove_dir_all(&tmp);
475 }
476
477 #[test]
478 fn is_project_root_marker_detects_cargo_toml() {
479 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
480 let _ = std::fs::create_dir_all(&tmp);
481 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
482 assert!(is_project_root_marker(&tmp));
483 let _ = std::fs::remove_dir_all(&tmp);
484 }
485
486 #[test]
487 fn detect_project_root_finds_outermost() {
488 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
489 let inner = base.join("packages").join("app");
490 let _ = std::fs::create_dir_all(&inner);
491 let _ = std::fs::create_dir_all(base.join(".git"));
492 let _ = std::fs::create_dir_all(inner.join(".git"));
493
494 let test_file = inner.join("main.rs");
495 let _ = std::fs::write(&test_file, "fn main() {}");
496
497 let root = detect_project_root(test_file.to_str().unwrap());
498 assert!(root.is_some(), "should find a project root for nested .git");
499 let root_path = std::path::PathBuf::from(root.unwrap());
500 assert_eq!(
501 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
502 crate::core::pathutil::safe_canonicalize(&base).ok(),
503 "should return outermost .git, not inner"
504 );
505
506 let _ = std::fs::remove_dir_all(&base);
507 }
508
509 #[test]
510 fn decoder_block_contains_all_codes() {
511 let block = instruction_decoder_block();
512 for t in TEMPLATES {
513 assert!(
514 block.contains(t.code),
515 "decoder should contain code {}",
516 t.code
517 );
518 }
519 }
520
521 #[test]
522 fn encoded_instructions_are_compact() {
523 use super::super::tokens::count_tokens;
524 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
525 let encoded = encode_instructions("mechanical");
526 assert!(
527 count_tokens(&encoded) <= count_tokens(full),
528 "encoded ({}) should be <= full ({})",
529 count_tokens(&encoded),
530 count_tokens(full)
531 );
532 }
533
534 #[test]
535 fn all_complexity_levels_encode() {
536 for level in &["mechanical", "standard", "architectural"] {
537 let encoded = encode_instructions(level);
538 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
539 }
540 }
541
542 #[test]
543 fn savings_footer_env_gated_tests() {
544 let _lock = crate::core::data_dir::test_env_lock();
545
546 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
548 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
549 std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
550 std::env::remove_var("LEAN_CTX_QUIET");
551
552 let s = super::format_savings(100, 50);
553 assert!(s.contains("\u{2192}"), "expected arrow: {s}");
554 assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
555 assert!(
556 s.starts_with("\u{2500}\u{2500}\u{2500}"),
557 "expected box-drawing: {s}"
558 );
559
560 let s = super::format_savings_with_info(4200, 840, Some("map"), None);
562 assert!(s.contains("mode: map"), "expected mode: {s}");
563 assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
564
565 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
567 std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
568 let s = super::format_savings(100, 50);
569 assert!(s.is_empty(), "expected empty with never: {s}");
570
571 let result = super::append_savings("hello", 100, 50);
572 assert_eq!(result, "hello");
573
574 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
576 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
577 std::env::remove_var("LEAN_CTX_SHOW_SAVINGS");
578 let s = super::format_savings(100, 50);
579 assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
580 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
581
582 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
584 std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
585 assert!(super::savings_footer_visible());
586 std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
587 assert!(!super::savings_footer_visible());
588
589 std::env::remove_var("LEAN_CTX_SHOW_SAVINGS");
590 }
591}