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