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