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, report result in one line",
169 },
170 InstructionTemplate {
171 code: "BRIEF",
172 full: "Summarize approach in 1-2 lines, then act",
173 },
174 InstructionTemplate {
175 code: "FULL",
176 full: "Outline approach, consider edge cases, then act",
177 },
178 InstructionTemplate {
179 code: "DELTA",
180 full: "Only show changed lines, not full files",
181 },
182 InstructionTemplate {
183 code: "NOREPEAT",
184 full: "Never repeat known context. Reference cached files by Fn ID",
185 },
186 InstructionTemplate {
187 code: "STRUCT",
188 full: "Use notation, not sentences. Changes: +line/-line/~line",
189 },
190 InstructionTemplate {
191 code: "1LINE",
192 full: "One line per action. Summarize, don't explain",
193 },
194 InstructionTemplate {
195 code: "NODOC",
196 full: "Don't add comments that narrate what code does",
197 },
198 InstructionTemplate {
199 code: "ACTFIRST",
200 full: "Execute tool calls immediately. Never narrate before acting",
201 },
202 InstructionTemplate {
203 code: "QUALITY",
204 full: "Never skip edge case analysis or error handling to save tokens",
205 },
206 InstructionTemplate {
207 code: "NOMOCK",
208 full: "Never use mock data, fake values, or placeholder code",
209 },
210 InstructionTemplate {
211 code: "FREF",
212 full: "Reference files by Fn refs only, never full paths",
213 },
214 InstructionTemplate {
215 code: "DIFF",
216 full: "For code changes: show only diff lines, not full files",
217 },
218 InstructionTemplate {
219 code: "ABBREV",
220 full: "Use abbreviations: fn, cfg, impl, deps, req, res, ctx, err",
221 },
222 InstructionTemplate {
223 code: "SYMBOLS",
224 full: "Use TDD notation: +=add -=remove ~=modify ->=returns ok/fail for status",
225 },
226];
227
228pub fn instruction_decoder_block() -> String {
230 let mut lines = vec!["INSTRUCTION CODES:".to_string()];
231 for t in TEMPLATES {
232 lines.push(format!(" {} = {}", t.code, t.full));
233 }
234 lines.join("\n")
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 root_path.canonicalize().ok(),
304 base.canonicalize().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}