Skip to main content

lean_ctx/core/
protocol.rs

1use std::path::Path;
2
3/// Finds a project root by walking up from `file_path`.
4/// Prefers the closest Git root (`.git`) to avoid accidentally selecting unrelated ancestor repos.
5pub 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
25/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
26fn 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
80/// Compresses tool output text based on density level.
81/// - Normal: no changes
82/// - Terse: strip blank lines, strip comment-only lines, remove banners
83/// - Ultra: additionally abbreviate common words
84pub 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
229/// Build the decoder block that explains all instruction codes (sent once per session).
230pub 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
238/// Encode an instruction suffix using short codes with budget hints.
239/// Response budget is dynamic based on task complexity to shape LLM output length.
240pub 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
255/// Encode instructions with SNR metric for context quality awareness.
256pub 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}