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
149pub fn format_savings(original: usize, compressed: usize) -> String {
151 let saved = original.saturating_sub(compressed);
152 if original == 0 {
153 return "0 tok saved".to_string();
154 }
155 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
156 format!("[{saved} tok saved ({pct}%)]")
157}
158
159pub struct InstructionTemplate {
161 pub code: &'static str,
162 pub full: &'static str,
163}
164
165const TEMPLATES: &[InstructionTemplate] = &[
166 InstructionTemplate {
167 code: "ACT1",
168 full: "Act immediately, 1-line result",
169 },
170 InstructionTemplate {
171 code: "BRIEF",
172 full: "1-2 line approach, then act",
173 },
174 InstructionTemplate {
175 code: "FULL",
176 full: "Outline+edge cases, then act",
177 },
178 InstructionTemplate {
179 code: "DELTA",
180 full: "Changed lines only",
181 },
182 InstructionTemplate {
183 code: "NOREPEAT",
184 full: "No repeat, use Fn refs",
185 },
186 InstructionTemplate {
187 code: "STRUCT",
188 full: "+/-/~ notation",
189 },
190 InstructionTemplate {
191 code: "1LINE",
192 full: "1 line per action",
193 },
194 InstructionTemplate {
195 code: "NODOC",
196 full: "No narration comments",
197 },
198 InstructionTemplate {
199 code: "ACTFIRST",
200 full: "Tool calls first, no narration",
201 },
202 InstructionTemplate {
203 code: "QUALITY",
204 full: "Never skip edge cases",
205 },
206 InstructionTemplate {
207 code: "NOMOCK",
208 full: "No mock/placeholder data",
209 },
210 InstructionTemplate {
211 code: "FREF",
212 full: "Fn refs only, no full paths",
213 },
214 InstructionTemplate {
215 code: "DIFF",
216 full: "Diff lines only",
217 },
218 InstructionTemplate {
219 code: "ABBREV",
220 full: "fn,cfg,impl,deps,req,res,ctx,err",
221 },
222 InstructionTemplate {
223 code: "SYMBOLS",
224 full: "+=add -=rm ~=mod ->=ret",
225 },
226];
227
228pub fn instruction_decoder_block() -> String {
230 let pairs: Vec<String> = TEMPLATES
231 .iter()
232 .map(|t| format!("{}={}", t.code, t.full))
233 .collect();
234 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
235}
236
237pub fn encode_instructions(complexity: &str) -> String {
240 match complexity {
241 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
242 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
243 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
244 "complex" => {
245 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
246 }
247 "architectural" => {
248 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
249 }
250 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
251 }
252}
253
254pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
256 let snr = if compression_pct > 0.0 {
257 1.0 - (compression_pct / 100.0)
258 } else {
259 1.0
260 };
261 let base = encode_instructions(complexity);
262 format!("{base} | SNR: {snr:.2}")
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn is_project_root_marker_detects_git() {
271 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
272 let _ = std::fs::create_dir_all(&tmp);
273 let git_dir = tmp.join(".git");
274 let _ = std::fs::create_dir_all(&git_dir);
275 assert!(is_project_root_marker(&tmp));
276 let _ = std::fs::remove_dir_all(&tmp);
277 }
278
279 #[test]
280 fn is_project_root_marker_detects_cargo_toml() {
281 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
282 let _ = std::fs::create_dir_all(&tmp);
283 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
284 assert!(is_project_root_marker(&tmp));
285 let _ = std::fs::remove_dir_all(&tmp);
286 }
287
288 #[test]
289 fn detect_project_root_finds_outermost() {
290 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
291 let inner = base.join("packages").join("app");
292 let _ = std::fs::create_dir_all(&inner);
293 let _ = std::fs::create_dir_all(base.join(".git"));
294 let _ = std::fs::create_dir_all(inner.join(".git"));
295
296 let test_file = inner.join("main.rs");
297 let _ = std::fs::write(&test_file, "fn main() {}");
298
299 let root = detect_project_root(test_file.to_str().unwrap());
300 assert!(root.is_some(), "should find a project root for nested .git");
301 let root_path = std::path::PathBuf::from(root.unwrap());
302 assert_eq!(
303 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
304 crate::core::pathutil::safe_canonicalize(&base).ok(),
305 "should return outermost .git, not inner"
306 );
307
308 let _ = std::fs::remove_dir_all(&base);
309 }
310
311 #[test]
312 fn decoder_block_contains_all_codes() {
313 let block = instruction_decoder_block();
314 for t in TEMPLATES {
315 assert!(
316 block.contains(t.code),
317 "decoder should contain code {}",
318 t.code
319 );
320 }
321 }
322
323 #[test]
324 fn encoded_instructions_are_compact() {
325 use super::super::tokens::count_tokens;
326 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
327 let encoded = encode_instructions("mechanical");
328 assert!(
329 count_tokens(&encoded) <= count_tokens(full),
330 "encoded ({}) should be <= full ({})",
331 count_tokens(&encoded),
332 count_tokens(full)
333 );
334 }
335
336 #[test]
337 fn all_complexity_levels_encode() {
338 for level in &["mechanical", "standard", "architectural"] {
339 let encoded = encode_instructions(level);
340 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
341 }
342 }
343}