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 from_env() -> Self {
15 match std::env::var("LEAN_CTX_CRP_MODE")
16 .unwrap_or_default()
17 .to_lowercase()
18 .as_str()
19 {
20 "off" => Self::Off,
21 "compact" => Self::Compact,
22 _ => Self::Tdd,
23 }
24 }
25
26 pub fn parse(s: &str) -> Option<Self> {
27 match s.trim().to_lowercase().as_str() {
28 "off" => Some(Self::Off),
29 "compact" => Some(Self::Compact),
30 "tdd" => Some(Self::Tdd),
31 _ => None,
32 }
33 }
34}
35
36#[derive(Clone, Debug)]
38pub struct ToolCallRecord {
39 pub tool: String,
40 pub original_tokens: usize,
41 pub saved_tokens: usize,
42 pub mode: Option<String>,
43 pub duration_ms: u64,
44 pub timestamp: String,
45}
46
47pub fn detect_project_root(file_path: &str) -> Option<String> {
52 let start = Path::new(file_path);
53 let mut dir = if start.is_dir() {
54 start
55 } else {
56 start.parent()?
57 };
58 let mut best: Option<String> = None;
59
60 loop {
61 if is_project_root_marker(dir) {
62 best = Some(dir.to_string_lossy().to_string());
63 }
64 match dir.parent() {
65 Some(parent) if parent != dir => dir = parent,
66 _ => break,
67 }
68 }
69 best
70}
71
72fn is_project_root_marker(dir: &Path) -> bool {
74 const MARKERS: &[&str] = &[
75 ".git",
76 "Cargo.toml",
77 "package.json",
78 "go.work",
79 "pnpm-workspace.yaml",
80 "lerna.json",
81 "nx.json",
82 "turbo.json",
83 ".projectile",
84 "pyproject.toml",
85 "setup.py",
86 "Makefile",
87 "CMakeLists.txt",
88 "BUILD.bazel",
89 ];
90 MARKERS.iter().any(|m| dir.join(m).exists())
91}
92
93pub fn detect_project_root_or_cwd(file_path: &str) -> String {
96 if let Some(root) = detect_project_root(file_path) {
97 return root;
98 }
99
100 let fallback = {
101 let p = Path::new(file_path);
102 if p.exists() {
103 if p.is_dir() {
104 file_path.to_string()
105 } else {
106 p.parent().map_or_else(
107 || file_path.to_string(),
108 |pp| pp.to_string_lossy().to_string(),
109 )
110 }
111 } else {
112 std::env::current_dir()
113 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
114 }
115 };
116
117 if is_broad_directory(&fallback) {
118 tracing::warn!(
119 "[protocol: no project markers found — falling back to broad directory {fallback}. \
120 Set LEAN_CTX_PROJECT_ROOT to override]"
121 );
122 }
123
124 fallback
125}
126
127fn is_broad_directory(path: &str) -> bool {
128 if path == "/" || path == "\\" || path == "." {
129 return true;
130 }
131 if let Some(home) = dirs::home_dir() {
132 let home_str = home.to_string_lossy();
133 if path == home_str.as_ref() || path == format!("{home_str}/") {
134 return true;
135 }
136 }
137 false
138}
139
140pub fn shorten_path(path: &str) -> String {
142 let p = Path::new(path);
143 if let Some(name) = p.file_name() {
144 return name.to_string_lossy().to_string();
145 }
146 path.to_string()
147}
148
149static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
154
155pub fn set_mcp_context(active: bool) {
157 MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
158}
159
160pub fn savings_footer_visible() -> bool {
164 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
165 return false;
166 }
167 let mode = super::config::SavingsFooter::effective();
168 match mode {
169 super::config::SavingsFooter::Always => true,
170 super::config::SavingsFooter::Never => false,
171 super::config::SavingsFooter::Auto => {
172 !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
173 }
174 }
175}
176
177pub fn meta_visible() -> bool {
181 if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
182 return false;
183 }
184 matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
185 || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
186}
187
188pub fn format_savings(original: usize, compressed: usize) -> String {
193 if !savings_footer_visible() {
194 return String::new();
195 }
196 let saved = original.saturating_sub(compressed);
197 if original == 0 {
198 return "0 tok saved".to_string();
199 }
200 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
201 format!("[{saved} tok saved ({pct}%)]")
202}
203
204pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
206 let footer = format_savings(original, compressed);
207 if footer.is_empty() {
208 output.to_string()
209 } else {
210 format!("{output}\n{footer}")
211 }
212}
213
214pub struct InstructionTemplate {
216 pub code: &'static str,
217 pub full: &'static str,
218}
219
220const TEMPLATES: &[InstructionTemplate] = &[
221 InstructionTemplate {
222 code: "ACT1",
223 full: "Act immediately, 1-line result",
224 },
225 InstructionTemplate {
226 code: "BRIEF",
227 full: "1-2 line approach, then act",
228 },
229 InstructionTemplate {
230 code: "FULL",
231 full: "Outline+edge cases, then act",
232 },
233 InstructionTemplate {
234 code: "DELTA",
235 full: "Changed lines only",
236 },
237 InstructionTemplate {
238 code: "NOREPEAT",
239 full: "No repeat, use Fn refs",
240 },
241 InstructionTemplate {
242 code: "STRUCT",
243 full: "+/-/~ notation",
244 },
245 InstructionTemplate {
246 code: "1LINE",
247 full: "1 line per action",
248 },
249 InstructionTemplate {
250 code: "NODOC",
251 full: "No narration comments",
252 },
253 InstructionTemplate {
254 code: "ACTFIRST",
255 full: "Tool calls first, no narration",
256 },
257 InstructionTemplate {
258 code: "QUALITY",
259 full: "Never skip edge cases",
260 },
261 InstructionTemplate {
262 code: "NOMOCK",
263 full: "No mock/placeholder data",
264 },
265 InstructionTemplate {
266 code: "FREF",
267 full: "Fn refs only, no full paths",
268 },
269 InstructionTemplate {
270 code: "DIFF",
271 full: "Diff lines only",
272 },
273 InstructionTemplate {
274 code: "ABBREV",
275 full: "fn,cfg,impl,deps,req,res,ctx,err",
276 },
277 InstructionTemplate {
278 code: "SYMBOLS",
279 full: "+=add -=rm ~=mod ->=ret",
280 },
281];
282
283pub fn instruction_decoder_block() -> String {
285 let pairs: Vec<String> = TEMPLATES
286 .iter()
287 .map(|t| format!("{}={}", t.code, t.full))
288 .collect();
289 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
290}
291
292pub fn encode_instructions(complexity: &str) -> String {
295 match complexity {
296 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
297 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
298 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
299 "complex" => {
300 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
301 }
302 "architectural" => {
303 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
304 }
305 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
306 }
307}
308
309pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
311 let snr = if compression_pct > 0.0 {
312 1.0 - (compression_pct / 100.0)
313 } else {
314 1.0
315 };
316 let base = encode_instructions(complexity);
317 format!("{base} | SNR: {snr:.2}")
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn is_project_root_marker_detects_git() {
326 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
327 let _ = std::fs::create_dir_all(&tmp);
328 let git_dir = tmp.join(".git");
329 let _ = std::fs::create_dir_all(&git_dir);
330 assert!(is_project_root_marker(&tmp));
331 let _ = std::fs::remove_dir_all(&tmp);
332 }
333
334 #[test]
335 fn is_project_root_marker_detects_cargo_toml() {
336 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
337 let _ = std::fs::create_dir_all(&tmp);
338 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
339 assert!(is_project_root_marker(&tmp));
340 let _ = std::fs::remove_dir_all(&tmp);
341 }
342
343 #[test]
344 fn detect_project_root_finds_outermost() {
345 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
346 let inner = base.join("packages").join("app");
347 let _ = std::fs::create_dir_all(&inner);
348 let _ = std::fs::create_dir_all(base.join(".git"));
349 let _ = std::fs::create_dir_all(inner.join(".git"));
350
351 let test_file = inner.join("main.rs");
352 let _ = std::fs::write(&test_file, "fn main() {}");
353
354 let root = detect_project_root(test_file.to_str().unwrap());
355 assert!(root.is_some(), "should find a project root for nested .git");
356 let root_path = std::path::PathBuf::from(root.unwrap());
357 assert_eq!(
358 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
359 crate::core::pathutil::safe_canonicalize(&base).ok(),
360 "should return outermost .git, not inner"
361 );
362
363 let _ = std::fs::remove_dir_all(&base);
364 }
365
366 #[test]
367 fn decoder_block_contains_all_codes() {
368 let block = instruction_decoder_block();
369 for t in TEMPLATES {
370 assert!(
371 block.contains(t.code),
372 "decoder should contain code {}",
373 t.code
374 );
375 }
376 }
377
378 #[test]
379 fn encoded_instructions_are_compact() {
380 use super::super::tokens::count_tokens;
381 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
382 let encoded = encode_instructions("mechanical");
383 assert!(
384 count_tokens(&encoded) <= count_tokens(full),
385 "encoded ({}) should be <= full ({})",
386 count_tokens(&encoded),
387 count_tokens(full)
388 );
389 }
390
391 #[test]
392 fn all_complexity_levels_encode() {
393 for level in &["mechanical", "standard", "architectural"] {
394 let encoded = encode_instructions(level);
395 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
396 }
397 }
398
399 #[test]
400 fn format_savings_returns_bracket_when_always() {
401 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
402 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
403 let s = super::format_savings(100, 50);
404 assert!(
405 s.contains("50 tok saved"),
406 "expected savings bracket, got: {s}"
407 );
408 assert!(s.contains("50%"), "expected percentage, got: {s}");
409 }
410
411 #[test]
412 fn format_savings_returns_empty_when_never() {
413 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
414 let s = super::format_savings(100, 50);
415 assert!(
416 s.is_empty(),
417 "expected empty string with never mode, got: {s}"
418 );
419 }
420
421 #[test]
422 fn format_savings_suppressed_in_mcp_auto_mode() {
423 super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
424 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
425 let s = super::format_savings(100, 50);
426 assert!(s.is_empty(), "expected empty in MCP+auto, got: {s}");
427 super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
428 }
429
430 #[test]
431 fn append_savings_no_trailing_newline_when_suppressed() {
432 std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
433 let result = super::append_savings("hello", 100, 50);
434 assert_eq!(result, "hello");
435 }
436}