1use std::path::Path;
2
3pub fn detect_project_root(file_path: &str) -> Option<String> {
6 let p = Path::new(file_path);
7 let mut dir = if p.is_dir() { p } else { p.parent()? };
8 let mut best_non_git: Option<String> = None;
9
10 loop {
11 if dir.join(".git").exists() {
12 return Some(dir.to_string_lossy().to_string());
13 }
14 if is_project_root_marker(dir) {
15 best_non_git = Some(dir.to_string_lossy().to_string());
16 }
17 match dir.parent() {
18 Some(parent) if parent != dir => dir = parent,
19 _ => break,
20 }
21 }
22 best_non_git
23}
24
25fn is_project_root_marker(dir: &Path) -> bool {
27 const MARKERS: &[&str] = &[
28 "Cargo.toml",
29 "package.json",
30 "go.work",
31 "pnpm-workspace.yaml",
32 "lerna.json",
33 "nx.json",
34 "turbo.json",
35 ".projectile",
36 "pyproject.toml",
37 "setup.py",
38 "Makefile",
39 "CMakeLists.txt",
40 "BUILD.bazel",
41 ];
42 MARKERS.iter().any(|m| dir.join(m).exists())
43}
44
45pub fn detect_project_root_or_cwd(file_path: &str) -> String {
46 detect_project_root(file_path).unwrap_or_else(|| {
47 let p = Path::new(file_path);
48 if p.exists() {
49 if p.is_dir() {
50 return file_path.to_string();
51 }
52 if let Some(parent) = p.parent() {
53 return parent.to_string_lossy().to_string();
54 }
55 return file_path.to_string();
56 }
57 std::env::current_dir()
58 .map(|p| p.to_string_lossy().to_string())
59 .unwrap_or_else(|_| ".".to_string())
60 })
61}
62
63pub fn shorten_path(path: &str) -> String {
64 let p = Path::new(path);
65 if let Some(name) = p.file_name() {
66 return name.to_string_lossy().to_string();
67 }
68 path.to_string()
69}
70
71pub fn format_savings(original: usize, compressed: usize) -> String {
72 let saved = original.saturating_sub(compressed);
73 if original == 0 {
74 return "0 tok saved".to_string();
75 }
76 let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
77 format!("[{saved} tok saved ({pct}%)]")
78}
79
80pub fn compress_output(text: &str, density: &super::config::OutputDensity) -> String {
85 use super::config::OutputDensity;
86 match density {
87 OutputDensity::Normal => text.to_string(),
88 OutputDensity::Terse => compress_terse(text),
89 OutputDensity::Ultra => compress_ultra(text),
90 }
91}
92
93fn compress_terse(text: &str) -> String {
94 text.lines()
95 .filter(|line| {
96 let trimmed = line.trim();
97 if trimmed.is_empty() {
98 return false;
99 }
100 if is_comment_only(trimmed) {
101 return false;
102 }
103 if is_banner_line(trimmed) {
104 return false;
105 }
106 true
107 })
108 .collect::<Vec<_>>()
109 .join("\n")
110}
111
112fn compress_ultra(text: &str) -> String {
113 let terse = compress_terse(text);
114 let mut result = terse;
115 for (long, short) in ABBREVIATIONS {
116 result = result.replace(long, short);
117 }
118 result
119}
120
121const ABBREVIATIONS: &[(&str, &str)] = &[
122 ("function", "fn"),
123 ("configuration", "cfg"),
124 ("implementation", "impl"),
125 ("dependencies", "deps"),
126 ("dependency", "dep"),
127 ("request", "req"),
128 ("response", "res"),
129 ("context", "ctx"),
130 ("error", "err"),
131 ("return", "ret"),
132 ("argument", "arg"),
133 ("value", "val"),
134 ("module", "mod"),
135 ("package", "pkg"),
136 ("directory", "dir"),
137 ("parameter", "param"),
138 ("variable", "var"),
139];
140
141fn is_comment_only(line: &str) -> bool {
142 line.starts_with("//")
143 || line.starts_with('#')
144 || line.starts_with("--")
145 || (line.starts_with("/*") && line.ends_with("*/"))
146}
147
148fn is_banner_line(line: &str) -> bool {
149 if line.len() < 4 {
150 return false;
151 }
152 let chars: Vec<char> = line.chars().collect();
153 let first = chars[0];
154 if matches!(first, '=' | '-' | '*' | '─' | '━' | '▀' | '▄') {
155 let same_count = chars.iter().filter(|c| **c == first).count();
156 return same_count as f64 / chars.len() as f64 > 0.7;
157 }
158 false
159}
160
161pub struct InstructionTemplate {
162 pub code: &'static str,
163 pub full: &'static str,
164}
165
166const TEMPLATES: &[InstructionTemplate] = &[
167 InstructionTemplate {
168 code: "ACT1",
169 full: "Act immediately, report result in one line",
170 },
171 InstructionTemplate {
172 code: "BRIEF",
173 full: "Summarize approach in 1-2 lines, then act",
174 },
175 InstructionTemplate {
176 code: "FULL",
177 full: "Outline approach, consider edge cases, then act",
178 },
179 InstructionTemplate {
180 code: "DELTA",
181 full: "Only show changed lines, not full files",
182 },
183 InstructionTemplate {
184 code: "NOREPEAT",
185 full: "Never repeat known context. Reference cached files by Fn ID",
186 },
187 InstructionTemplate {
188 code: "STRUCT",
189 full: "Use notation, not sentences. Changes: +line/-line/~line",
190 },
191 InstructionTemplate {
192 code: "1LINE",
193 full: "One line per action. Summarize, don't explain",
194 },
195 InstructionTemplate {
196 code: "NODOC",
197 full: "Don't add comments that narrate what code does",
198 },
199 InstructionTemplate {
200 code: "ACTFIRST",
201 full: "Execute tool calls immediately. Never narrate before acting",
202 },
203 InstructionTemplate {
204 code: "QUALITY",
205 full: "Never skip edge case analysis or error handling to save tokens",
206 },
207 InstructionTemplate {
208 code: "NOMOCK",
209 full: "Never use mock data, fake values, or placeholder code",
210 },
211 InstructionTemplate {
212 code: "FREF",
213 full: "Reference files by Fn refs only, never full paths",
214 },
215 InstructionTemplate {
216 code: "DIFF",
217 full: "For code changes: show only diff lines, not full files",
218 },
219 InstructionTemplate {
220 code: "ABBREV",
221 full: "Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err",
222 },
223 InstructionTemplate {
224 code: "SYMBOLS",
225 full: "Use TDD notation: +=add -=remove ~=modify ->=returns ok/fail for status",
226 },
227];
228
229pub fn instruction_decoder_block() -> String {
231 let mut lines = vec!["INSTRUCTION CODES:".to_string()];
232 for t in TEMPLATES {
233 lines.push(format!(" {} = {}", t.code, t.full));
234 }
235 lines.join("\n")
236}
237
238pub fn encode_instructions(complexity: &str) -> String {
241 match complexity {
242 "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
243 "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
244 "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
245 "complex" => {
246 "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
247 }
248 "architectural" => {
249 "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
250 }
251 _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
252 }
253}
254
255pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
257 let snr = if compression_pct > 0.0 {
258 1.0 - (compression_pct / 100.0)
259 } else {
260 1.0
261 };
262 let base = encode_instructions(complexity);
263 format!("{base} | SNR: {snr:.2}")
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn compress_output_normal_unchanged() {
272 use crate::core::config::OutputDensity;
273 let input = "line1\n\nline3\n// comment\n====\nline6";
274 let result = compress_output(input, &OutputDensity::Normal);
275 assert_eq!(result, input);
276 }
277
278 #[test]
279 fn compress_output_terse_strips_blanks_and_comments() {
280 use crate::core::config::OutputDensity;
281 let input = "line1\n\n// comment\nline4\n----\nline6";
282 let result = compress_output(input, &OutputDensity::Terse);
283 assert!(!result.contains("\n\n"), "should remove blank lines");
284 assert!(!result.contains("// comment"), "should remove comments");
285 assert!(!result.contains("----"), "should remove banners");
286 assert!(result.contains("line1"));
287 assert!(result.contains("line4"));
288 assert!(result.contains("line6"));
289 }
290
291 #[test]
292 fn compress_output_ultra_abbreviates() {
293 use crate::core::config::OutputDensity;
294 let input = "function configuration implementation dependencies";
295 let result = compress_output(input, &OutputDensity::Ultra);
296 assert!(result.contains("fn"));
297 assert!(result.contains("cfg"));
298 assert!(result.contains("impl"));
299 assert!(result.contains("deps"));
300 assert!(!result.contains("function"));
301 }
302
303 #[test]
304 fn terse_shorter_than_normal() {
305 use crate::core::config::OutputDensity;
306 let input = "line1\n\n// header comment\nline3\n======\nline5\n\nline7";
307 let normal = compress_output(input, &OutputDensity::Normal);
308 let terse = compress_output(input, &OutputDensity::Terse);
309 assert!(terse.len() < normal.len());
310 }
311
312 #[test]
313 fn detect_project_root_finds_git_root() {
314 let tmp = std::env::temp_dir().join("lean-ctx-test-git-root");
315 let _ = std::fs::create_dir_all(&tmp);
316 let git_dir = tmp.join(".git");
317 let _ = std::fs::create_dir_all(&git_dir);
318 let root = detect_project_root(tmp.to_str().unwrap());
319 assert_eq!(root.as_deref(), Some(tmp.to_string_lossy().as_ref()));
320 let _ = std::fs::remove_dir_all(&tmp);
321 }
322
323 #[test]
324 fn is_project_root_marker_detects_cargo_toml() {
325 let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
326 let _ = std::fs::create_dir_all(&tmp);
327 let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
328 assert!(is_project_root_marker(&tmp));
329 let _ = std::fs::remove_dir_all(&tmp);
330 }
331
332 #[test]
333 fn detect_project_root_prefers_closest_git_root() {
334 let base = std::env::temp_dir().join("lean-ctx-test-nested-git");
335 let inner = base.join("packages").join("app");
336 let _ = std::fs::create_dir_all(&inner);
337 let _ = std::fs::create_dir_all(base.join(".git"));
338 let _ = std::fs::create_dir_all(inner.join(".git"));
339
340 let test_file = inner.join("main.rs");
341 let _ = std::fs::write(&test_file, "fn main() {}");
342
343 let root = detect_project_root(test_file.to_str().unwrap());
344 assert_eq!(root.as_deref(), Some(inner.to_string_lossy().as_ref()));
345
346 let _ = std::fs::remove_dir_all(&base);
347 }
348
349 #[test]
350 fn decoder_block_contains_all_codes() {
351 let block = instruction_decoder_block();
352 for t in TEMPLATES {
353 assert!(
354 block.contains(t.code),
355 "decoder should contain code {}",
356 t.code
357 );
358 }
359 }
360
361 #[test]
362 fn encoded_instructions_are_compact() {
363 use super::super::tokens::count_tokens;
364 let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
365 let encoded = encode_instructions("mechanical");
366 assert!(
367 count_tokens(&encoded) <= count_tokens(full),
368 "encoded ({}) should be <= full ({})",
369 count_tokens(&encoded),
370 count_tokens(full)
371 );
372 }
373
374 #[test]
375 fn all_complexity_levels_encode() {
376 for level in &["mechanical", "standard", "architectural"] {
377 let encoded = encode_instructions(level);
378 assert!(encoded.starts_with("MODE:"), "should start with MODE:");
379 }
380 }
381}