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 mut dir = Path::new(file_path).parent()?;
53 let mut best: Option<String> = None;
54
55 loop {
56 if is_project_root_marker(dir) {
57 best = Some(dir.to_string_lossy().to_string());
58 }
59 match dir.parent() {
60 Some(parent) if parent != dir => dir = parent,
61 _ => break,
62 }
63 }
64 best
65}
66
67fn is_project_root_marker(dir: &Path) -> bool {
69 const MARKERS: &[&str] = &[
70 ".git",
71 "Cargo.toml",
72 "package.json",
73 "go.work",
74 "pnpm-workspace.yaml",
75 "lerna.json",
76 "nx.json",
77 "turbo.json",
78 ".projectile",
79 "pyproject.toml",
80 "setup.py",
81 "Makefile",
82 "CMakeLists.txt",
83 "BUILD.bazel",
84 ];
85 MARKERS.iter().any(|m| dir.join(m).exists())
86}
87
88pub fn detect_project_root_or_cwd(file_path: &str) -> String {
90 detect_project_root(file_path).unwrap_or_else(|| {
91 let p = Path::new(file_path);
92 if p.exists() {
93 if p.is_dir() {
94 return file_path.to_string();
95 }
96 if let Some(parent) = p.parent() {
97 return parent.to_string_lossy().to_string();
98 }
99 return file_path.to_string();
100 }
101 std::env::current_dir()
102 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
103 })
104}
105
106pub fn shorten_path(path: &str) -> String {
108 let p = Path::new(path);
109 if let Some(name) = p.file_name() {
110 return name.to_string_lossy().to_string();
111 }
112 path.to_string()
113}
114
115pub fn format_savings(original: usize, compressed: usize) -> String {
117 let saved = original.saturating_sub(compressed);
118 if original == 0 {
119 return "0 tok saved".to_string();
120 }
121 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
122 format!("[{saved} tok saved ({pct}%)]")
123}
124
125pub struct InstructionTemplate {
127 pub code: &'static str,
128 pub full: &'static str,
129}
130
131const TEMPLATES: &[InstructionTemplate] = &[
132 InstructionTemplate {
133 code: "ACT1",
134 full: "Act immediately, 1-line result",
135 },
136 InstructionTemplate {
137 code: "BRIEF",
138 full: "1-2 line approach, then act",
139 },
140 InstructionTemplate {
141 code: "FULL",
142 full: "Outline+edge cases, then act",
143 },
144 InstructionTemplate {
145 code: "DELTA",
146 full: "Changed lines only",
147 },
148 InstructionTemplate {
149 code: "NOREPEAT",
150 full: "No repeat, use Fn refs",
151 },
152 InstructionTemplate {
153 code: "STRUCT",
154 full: "+/-/~ notation",
155 },
156 InstructionTemplate {
157 code: "1LINE",
158 full: "1 line per action",
159 },
160 InstructionTemplate {
161 code: "NODOC",
162 full: "No narration comments",
163 },
164 InstructionTemplate {
165 code: "ACTFIRST",
166 full: "Tool calls first, no narration",
167 },
168 InstructionTemplate {
169 code: "QUALITY",
170 full: "Never skip edge cases",
171 },
172 InstructionTemplate {
173 code: "NOMOCK",
174 full: "No mock/placeholder data",
175 },
176 InstructionTemplate {
177 code: "FREF",
178 full: "Fn refs only, no full paths",
179 },
180 InstructionTemplate {
181 code: "DIFF",
182 full: "Diff lines only",
183 },
184 InstructionTemplate {
185 code: "ABBREV",
186 full: "fn,cfg,impl,deps,req,res,ctx,err",
187 },
188 InstructionTemplate {
189 code: "SYMBOLS",
190 full: "+=add -=rm ~=mod ->=ret",
191 },
192];
193
194pub fn instruction_decoder_block() -> String {
196 let pairs: Vec<String> = TEMPLATES
197 .iter()
198 .map(|t| format!("{}={}", t.code, t.full))
199 .collect();
200 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
201}
202
203pub fn encode_instructions(complexity: &str) -> String {
206 match complexity {
207 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
208 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
209 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
210 "complex" => {
211 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
212 }
213 "architectural" => {
214 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
215 }
216 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
217 }
218}
219
220pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
222 let snr = if compression_pct > 0.0 {
223 1.0 - (compression_pct / 100.0)
224 } else {
225 1.0
226 };
227 let base = encode_instructions(complexity);
228 format!("{base} | SNR: {snr:.2}")
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn is_project_root_marker_detects_git() {
237 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
238 let _ = std::fs::create_dir_all(&tmp);
239 let git_dir = tmp.join(".git");
240 let _ = std::fs::create_dir_all(&git_dir);
241 assert!(is_project_root_marker(&tmp));
242 let _ = std::fs::remove_dir_all(&tmp);
243 }
244
245 #[test]
246 fn is_project_root_marker_detects_cargo_toml() {
247 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
248 let _ = std::fs::create_dir_all(&tmp);
249 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
250 assert!(is_project_root_marker(&tmp));
251 let _ = std::fs::remove_dir_all(&tmp);
252 }
253
254 #[test]
255 fn detect_project_root_finds_outermost() {
256 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
257 let inner = base.join("packages").join("app");
258 let _ = std::fs::create_dir_all(&inner);
259 let _ = std::fs::create_dir_all(base.join(".git"));
260 let _ = std::fs::create_dir_all(inner.join(".git"));
261
262 let test_file = inner.join("main.rs");
263 let _ = std::fs::write(&test_file, "fn main() {}");
264
265 let root = detect_project_root(test_file.to_str().unwrap());
266 assert!(root.is_some(), "should find a project root for nested .git");
267 let root_path = std::path::PathBuf::from(root.unwrap());
268 assert_eq!(
269 crate::core::pathutil::safe_canonicalize(&root_path).ok(),
270 crate::core::pathutil::safe_canonicalize(&base).ok(),
271 "should return outermost .git, not inner"
272 );
273
274 let _ = std::fs::remove_dir_all(&base);
275 }
276
277 #[test]
278 fn decoder_block_contains_all_codes() {
279 let block = instruction_decoder_block();
280 for t in TEMPLATES {
281 assert!(
282 block.contains(t.code),
283 "decoder should contain code {}",
284 t.code
285 );
286 }
287 }
288
289 #[test]
290 fn encoded_instructions_are_compact() {
291 use super::super::tokens::count_tokens;
292 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
293 let encoded = encode_instructions("mechanical");
294 assert!(
295 count_tokens(&encoded) <= count_tokens(full),
296 "encoded ({}) should be <= full ({})",
297 count_tokens(&encoded),
298 count_tokens(full)
299 );
300 }
301
302 #[test]
303 fn all_complexity_levels_encode() {
304 for level in &["mechanical", "standard", "architectural"] {
305 let encoded = encode_instructions(level);
306 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
307 }
308 }
309}