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 shorten_path(path: &str) -> String {
170 let p = Path::new(path);
171 if let Some(name) = p.file_name() {
172 return name.to_string_lossy().to_string();
173 }
174 path.to_string()
175}
176
177pub fn shorten_path_relative(path: &str, root: &str) -> String {
180 let p = Path::new(path);
181 let r = Path::new(root);
182 if let Ok(rel) = p.strip_prefix(r) {
183 let s = rel.to_string_lossy();
184 if !s.is_empty() {
185 return s.to_string();
186 }
187 }
188 shorten_path(path)
189}
190
191static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
196
197pub fn set_mcp_context(active: bool) {
199 MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
200}
201
202pub fn savings_footer_visible() -> bool {
206 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
207 return false;
208 }
209 let mode = super::config::SavingsFooter::effective();
210 match mode {
211 super::config::SavingsFooter::Always => true,
212 super::config::SavingsFooter::Never => false,
213 super::config::SavingsFooter::Auto => {
214 !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
215 }
216 }
217}
218
219pub fn meta_visible() -> bool {
223 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
224 return false;
225 }
226 matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
227 || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
228}
229
230pub fn format_savings(original: usize, compressed: usize) -> String {
235 if !savings_footer_visible() {
236 return String::new();
237 }
238 if original == 0 {
239 return String::new();
240 }
241 let saved = original.saturating_sub(compressed);
242 if saved == 0 {
243 return String::new();
244 }
245 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
246 format!("[lean-ctx: {original}\u{2192}{compressed} tok, -{pct}%]")
247}
248
249pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
251 let footer = format_savings(original, compressed);
252 if footer.is_empty() {
253 output.to_string()
254 } else {
255 format!("{output}\n{footer}")
256 }
257}
258
259pub struct InstructionTemplate {
261 pub code: &'static str,
262 pub full: &'static str,
263}
264
265const TEMPLATES: &[InstructionTemplate] = &[
266 InstructionTemplate {
267 code: "ACT1",
268 full: "Act immediately, 1-line result",
269 },
270 InstructionTemplate {
271 code: "BRIEF",
272 full: "1-2 line approach, then act",
273 },
274 InstructionTemplate {
275 code: "FULL",
276 full: "Outline+edge cases, then act",
277 },
278 InstructionTemplate {
279 code: "DELTA",
280 full: "Changed lines only",
281 },
282 InstructionTemplate {
283 code: "NOREPEAT",
284 full: "No repeat, use Fn refs",
285 },
286 InstructionTemplate {
287 code: "STRUCT",
288 full: "+/-/~ notation",
289 },
290 InstructionTemplate {
291 code: "1LINE",
292 full: "1 line per action",
293 },
294 InstructionTemplate {
295 code: "NODOC",
296 full: "No narration comments",
297 },
298 InstructionTemplate {
299 code: "ACTFIRST",
300 full: "Tool calls first, no narration",
301 },
302 InstructionTemplate {
303 code: "QUALITY",
304 full: "Never skip edge cases",
305 },
306 InstructionTemplate {
307 code: "NOMOCK",
308 full: "No mock/placeholder data",
309 },
310 InstructionTemplate {
311 code: "FREF",
312 full: "Fn refs only, no full paths",
313 },
314 InstructionTemplate {
315 code: "DIFF",
316 full: "Diff lines only",
317 },
318 InstructionTemplate {
319 code: "ABBREV",
320 full: "fn,cfg,impl,deps,req,res,ctx,err",
321 },
322 InstructionTemplate {
323 code: "SYMBOLS",
324 full: "+=add -=rm ~=mod ->=ret",
325 },
326];
327
328pub fn instruction_decoder_block() -> String {
332 let mode = crate::core::profiles::active_profile()
333 .compression
334 .crp_mode_effective()
335 .to_string();
336 if mode != "tdd" {
337 return String::new();
338 }
339 let pairs: Vec<String> = TEMPLATES
340 .iter()
341 .map(|t| format!("{}={}", t.code, t.full))
342 .collect();
343 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
344}
345
346pub fn encode_instructions(complexity: &str) -> String {
349 match complexity {
350 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
351 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
352 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
353 "complex" => {
354 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
355 }
356 "architectural" => {
357 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
358 }
359 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
360 }
361}
362
363pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
365 let snr = if compression_pct > 0.0 {
366 1.0 - (compression_pct / 100.0)
367 } else {
368 1.0
369 };
370 let base = encode_instructions(complexity);
371 format!("{base} | SNR: {snr:.2}")
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn is_project_root_marker_detects_git() {
380 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
381 let _ = std::fs::create_dir_all(&tmp);
382 let git_dir = tmp.join(".git");
383 let _ = std::fs::create_dir_all(&git_dir);
384 assert!(is_project_root_marker(&tmp));
385 let _ = std::fs::remove_dir_all(&tmp);
386 }
387
388 #[test]
389 fn is_project_root_marker_detects_cargo_toml() {
390 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
391 let _ = std::fs::create_dir_all(&tmp);
392 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
393 assert!(is_project_root_marker(&tmp));
394 let _ = std::fs::remove_dir_all(&tmp);
395 }
396
397 #[test]
398 fn detect_project_root_finds_outermost() {
399 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
400 let inner = base.join("packages").join("app");
401 let _ = std::fs::create_dir_all(&inner);
402 let _ = std::fs::create_dir_all(base.join(".git"));
403 let _ = std::fs::create_dir_all(inner.join(".git"));
404
405 let test_file = inner.join("main.rs");
406 let _ = std::fs::write(&test_file, "fn main() {}");
407
408 let root = detect_project_root(test_file.to_str().unwrap());
409 assert!(root.is_some(), "should find a project root for nested .git");
410 let root_path = std::path::PathBuf::from(root.unwrap());
411 assert_eq!(
412 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
413 crate::core::pathutil::safe_canonicalize(&base).ok(),
414 "should return outermost .git, not inner"
415 );
416
417 let _ = std::fs::remove_dir_all(&base);
418 }
419
420 #[test]
421 fn decoder_block_contains_all_codes() {
422 let block = instruction_decoder_block();
423 for t in TEMPLATES {
424 assert!(
425 block.contains(t.code),
426 "decoder should contain code {}",
427 t.code
428 );
429 }
430 }
431
432 #[test]
433 fn encoded_instructions_are_compact() {
434 use super::super::tokens::count_tokens;
435 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
436 let encoded = encode_instructions("mechanical");
437 assert!(
438 count_tokens(&encoded) <= count_tokens(full),
439 "encoded ({}) should be <= full ({})",
440 count_tokens(&encoded),
441 count_tokens(full)
442 );
443 }
444
445 #[test]
446 fn all_complexity_levels_encode() {
447 for level in &["mechanical", "standard", "architectural"] {
448 let encoded = encode_instructions(level);
449 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
450 }
451 }
452
453 #[test]
454 fn format_savings_returns_bracket_when_always() {
455 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
456 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
457 let s = super::format_savings(100, 50);
458 assert!(
459 s.contains("100\u{2192}50 tok"),
460 "expected unified format, got: {s}"
461 );
462 assert!(s.contains("-50%"), "expected percentage, got: {s}");
463 }
464
465 #[test]
466 fn format_savings_returns_empty_when_never() {
467 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
468 let s = super::format_savings(100, 50);
469 assert!(
470 s.is_empty(),
471 "expected empty string with never mode, got: {s}"
472 );
473 }
474
475 #[test]
476 fn format_savings_suppressed_in_mcp_auto_mode() {
477 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
478 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
479 let s = super::format_savings(100, 50);
480 assert!(s.is_empty(), "expected empty in MCP+auto, got: {s}");
481 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
482 }
483
484 #[test]
485 fn append_savings_no_trailing_newline_when_suppressed() {
486 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
487 let result = super::append_savings("hello", 100, 50);
488 assert_eq!(result, "hello");
489 }
490}