1use std::path::Path;
2
3pub fn detect_project_root(file_path: &str) -> Option<String> {
8 let mut dir = Path::new(file_path).parent()?;
9 let mut best: Option<String> = None;
10
11 loop {
12 if is_project_root_marker(dir) {
13 best = Some(dir.to_string_lossy().to_string());
14 }
15 match dir.parent() {
16 Some(parent) if parent != dir => dir = parent,
17 _ => break,
18 }
19 }
20 best
21}
22
23fn is_project_root_marker(dir: &Path) -> bool {
25 const MARKERS: &[&str] = &[
26 ".git",
27 "Cargo.toml",
28 "package.json",
29 "go.work",
30 "pnpm-workspace.yaml",
31 "lerna.json",
32 "nx.json",
33 "turbo.json",
34 ".projectile",
35 "pyproject.toml",
36 "setup.py",
37 "Makefile",
38 "CMakeLists.txt",
39 "BUILD.bazel",
40 ];
41 MARKERS.iter().any(|m| dir.join(m).exists())
42}
43
44pub fn detect_project_root_or_cwd(file_path: &str) -> String {
45 detect_project_root(file_path).unwrap_or_else(|| {
46 let p = Path::new(file_path);
47 if p.exists() {
48 if p.is_dir() {
49 return file_path.to_string();
50 }
51 if let Some(parent) = p.parent() {
52 return parent.to_string_lossy().to_string();
53 }
54 return file_path.to_string();
55 }
56 std::env::current_dir()
57 .map(|p| p.to_string_lossy().to_string())
58 .unwrap_or_else(|_| ".".to_string())
59 })
60}
61
62pub fn shorten_path(path: &str) -> String {
63 let p = Path::new(path);
64 if let Some(name) = p.file_name() {
65 return name.to_string_lossy().to_string();
66 }
67 path.to_string()
68}
69
70pub fn format_savings(original: usize, compressed: usize) -> String {
71 let saved = original.saturating_sub(compressed);
72 if original == 0 {
73 return "0 tok saved".to_string();
74 }
75 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
76 format!("[{saved} tok saved ({pct}%)]")
77}
78
79pub fn compress_output(text: &str, density: &super::config::OutputDensity) -> String {
84 use super::config::OutputDensity;
85 match density {
86 OutputDensity::Normal => text.to_string(),
87 OutputDensity::Terse => compress_terse(text),
88 OutputDensity::Ultra => compress_ultra(text),
89 }
90}
91
92fn compress_terse(text: &str) -> String {
93 text.lines()
94 .filter(|line| {
95 let trimmed = line.trim();
96 if trimmed.is_empty() {
97 return false;
98 }
99 if is_comment_only(trimmed) {
100 return false;
101 }
102 if is_banner_line(trimmed) {
103 return false;
104 }
105 true
106 })
107 .collect::<Vec<_>>()
108 .join("\n")
109}
110
111fn compress_ultra(text: &str) -> String {
112 let terse = compress_terse(text);
113 let mut result = terse;
114 for (long, short) in ABBREVIATIONS {
115 result = result.replace(long, short);
116 }
117 result
118}
119
120const ABBREVIATIONS: &[(&str, &str)] = &[
121 ("function", "fn"),
122 ("configuration", "cfg"),
123 ("implementation", "impl"),
124 ("dependencies", "deps"),
125 ("dependency", "dep"),
126 ("request", "req"),
127 ("response", "res"),
128 ("context", "ctx"),
129 ("error", "err"),
130 ("return", "ret"),
131 ("argument", "arg"),
132 ("value", "val"),
133 ("module", "mod"),
134 ("package", "pkg"),
135 ("directory", "dir"),
136 ("parameter", "param"),
137 ("variable", "var"),
138];
139
140fn is_comment_only(line: &str) -> bool {
141 line.starts_with("//")
142 || line.starts_with('#')
143 || line.starts_with("--")
144 || (line.starts_with("/*") && line.ends_with("*/"))
145}
146
147fn is_banner_line(line: &str) -> bool {
148 if line.len() < 4 {
149 return false;
150 }
151 let chars: Vec<char> = line.chars().collect();
152 let first = chars[0];
153 if matches!(first, '=' | '-' | '*' | '─' | '━' | '▀' | '▄') {
154 let same_count = chars.iter().filter(|c| **c == first).count();
155 return same_count as f64 / chars.len() as f64 > 0.7;
156 }
157 false
158}
159
160pub 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 {
229 let pairs: Vec<String> = TEMPLATES
230 .iter()
231 .map(|t| format!("{}={}", t.code, t.full))
232 .collect();
233 format!("INSTRUCTION CODES:\n {}", pairs.join(" | "))
234}
235
236pub fn encode_instructions(complexity: &str) -> String {
239 match complexity {
240 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
241 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
242 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
243 "complex" => {
244 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
245 }
246 "architectural" => {
247 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
248 }
249 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
250 }
251}
252
253pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
255 let snr = if compression_pct > 0.0 {
256 1.0 - (compression_pct / 100.0)
257 } else {
258 1.0
259 };
260 let base = encode_instructions(complexity);
261 format!("{base} | SNR: {snr:.2}")
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn is_project_root_marker_detects_git() {
270 let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
271 let _ = std::fs::create_dir_all(&tmp);
272 let git_dir = tmp.join(".git");
273 let _ = std::fs::create_dir_all(&git_dir);
274 assert!(is_project_root_marker(&tmp));
275 let _ = std::fs::remove_dir_all(&tmp);
276 }
277
278 #[test]
279 fn is_project_root_marker_detects_cargo_toml() {
280 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
281 let _ = std::fs::create_dir_all(&tmp);
282 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
283 assert!(is_project_root_marker(&tmp));
284 let _ = std::fs::remove_dir_all(&tmp);
285 }
286
287 #[test]
288 fn detect_project_root_finds_outermost() {
289 let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
290 let inner = base.join("packages").join("app");
291 let _ = std::fs::create_dir_all(&inner);
292 let _ = std::fs::create_dir_all(base.join(".git"));
293 let _ = std::fs::create_dir_all(inner.join(".git"));
294
295 let test_file = inner.join("main.rs");
296 let _ = std::fs::write(&test_file, "fn main() {}");
297
298 let root = detect_project_root(test_file.to_str().unwrap());
299 assert!(root.is_some(), "should find a project root for nested .git");
300 let root_path = std::path::PathBuf::from(root.unwrap());
301 assert_eq!(
302 root_path.canonicalize().ok(),
303 base.canonicalize().ok(),
304 "should return outermost .git, not inner"
305 );
306
307 let _ = std::fs::remove_dir_all(&base);
308 }
309
310 #[test]
311 fn decoder_block_contains_all_codes() {
312 let block = instruction_decoder_block();
313 for t in TEMPLATES {
314 assert!(
315 block.contains(t.code),
316 "decoder should contain code {}",
317 t.code
318 );
319 }
320 }
321
322 #[test]
323 fn encoded_instructions_are_compact() {
324 use super::super::tokens::count_tokens;
325 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
326 let encoded = encode_instructions("mechanical");
327 assert!(
328 count_tokens(&encoded) <= count_tokens(full),
329 "encoded ({}) should be <= full ({})",
330 count_tokens(&encoded),
331 count_tokens(full)
332 );
333 }
334
335 #[test]
336 fn all_complexity_levels_encode() {
337 for level in &["mechanical", "standard", "architectural"] {
338 let encoded = encode_instructions(level);
339 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
340 }
341 }
342}