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 {
330 let pairs: Vec<String> = TEMPLATES
331 .iter()
332 .map(|t| format!("{}={}", t.code, t.full))
333 .collect();
334 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
335}
336
337pub fn encode_instructions(complexity: &str) -> String {
340 match complexity {
341 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
342 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
343 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
344 "complex" => {
345 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
346 }
347 "architectural" => {
348 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
349 }
350 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
351 }
352}
353
354pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
356 let snr = if compression_pct > 0.0 {
357 1.0 - (compression_pct / 100.0)
358 } else {
359 1.0
360 };
361 let base = encode_instructions(complexity);
362 format!("{base} | SNR: {snr:.2}")
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn is_project_root_marker_detects_git() {
371 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
372 let _ = std::fs::create_dir_all(&tmp);
373 let git_dir = tmp.join(".git");
374 let _ = std::fs::create_dir_all(&git_dir);
375 assert!(is_project_root_marker(&tmp));
376 let _ = std::fs::remove_dir_all(&tmp);
377 }
378
379 #[test]
380 fn is_project_root_marker_detects_cargo_toml() {
381 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
382 let _ = std::fs::create_dir_all(&tmp);
383 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
384 assert!(is_project_root_marker(&tmp));
385 let _ = std::fs::remove_dir_all(&tmp);
386 }
387
388 #[test]
389 fn detect_project_root_finds_outermost() {
390 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
391 let inner = base.join("packages").join("app");
392 let _ = std::fs::create_dir_all(&inner);
393 let _ = std::fs::create_dir_all(base.join(".git"));
394 let _ = std::fs::create_dir_all(inner.join(".git"));
395
396 let test_file = inner.join("main.rs");
397 let _ = std::fs::write(&test_file, "fn main() {}");
398
399 let root = detect_project_root(test_file.to_str().unwrap());
400 assert!(root.is_some(), "should find a project root for nested .git");
401 let root_path = std::path::PathBuf::from(root.unwrap());
402 assert_eq!(
403 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
404 crate::core::pathutil::safe_canonicalize(&base).ok(),
405 "should return outermost .git, not inner"
406 );
407
408 let _ = std::fs::remove_dir_all(&base);
409 }
410
411 #[test]
412 fn decoder_block_contains_all_codes() {
413 let block = instruction_decoder_block();
414 for t in TEMPLATES {
415 assert!(
416 block.contains(t.code),
417 "decoder should contain code {}",
418 t.code
419 );
420 }
421 }
422
423 #[test]
424 fn encoded_instructions_are_compact() {
425 use super::super::tokens::count_tokens;
426 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
427 let encoded = encode_instructions("mechanical");
428 assert!(
429 count_tokens(&encoded) <= count_tokens(full),
430 "encoded ({}) should be <= full ({})",
431 count_tokens(&encoded),
432 count_tokens(full)
433 );
434 }
435
436 #[test]
437 fn all_complexity_levels_encode() {
438 for level in &["mechanical", "standard", "architectural"] {
439 let encoded = encode_instructions(level);
440 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
441 }
442 }
443
444 #[test]
445 fn format_savings_returns_bracket_when_always() {
446 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
447 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
448 let s = super::format_savings(100, 50);
449 assert!(
450 s.contains("100\u{2192}50 tok"),
451 "expected unified format, got: {s}"
452 );
453 assert!(s.contains("-50%"), "expected percentage, got: {s}");
454 }
455
456 #[test]
457 fn format_savings_returns_empty_when_never() {
458 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
459 let s = super::format_savings(100, 50);
460 assert!(
461 s.is_empty(),
462 "expected empty string with never mode, got: {s}"
463 );
464 }
465
466 #[test]
467 fn format_savings_suppressed_in_mcp_auto_mode() {
468 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
469 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
470 let s = super::format_savings(100, 50);
471 assert!(s.is_empty(), "expected empty in MCP+auto, got: {s}");
472 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
473 }
474
475 #[test]
476 fn append_savings_no_trailing_newline_when_suppressed() {
477 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
478 let result = super::append_savings("hello", 100, 50);
479 assert_eq!(result, "hello");
480 }
481}