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, 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
228/// Build the decoder block that explains all instruction codes (sent once per session).
229pub 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
237/// Encode an instruction suffix using short codes with budget hints.
238/// Response budget is dynamic based on task complexity to shape LLM output length.
239pub 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
254/// Encode instructions with SNR metric for context quality awareness.
255pub 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}