Skip to main content

lean_ctx/core/
protocol.rs

1use std::path::Path;
2
3/// Finds the outermost project root by walking up from `file_path`.
4/// For monorepos with nested `.git` dirs (e.g. `mono/backend/.git` + `mono/frontend/.git`),
5/// returns the outermost ancestor containing `.git`, a workspace marker, or a known
6/// monorepo config file — so the whole monorepo is treated as one project.
7pub 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
23/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
24fn 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
79/// Compresses tool output text based on density level.
80/// - Normal: no changes
81/// - Terse: strip blank lines, strip comment-only lines, remove banners
82/// - Ultra: additionally abbreviate common words
83pub 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
236/// Encode an instruction suffix using short codes with budget hints.
237/// Response budget is dynamic based on task complexity to shape LLM output length.
238pub 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
253/// Encode instructions with SNR metric for context quality awareness.
254pub 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            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
303            crate::core::pathutil::safe_canonicalize(&base).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}