Skip to main content

lean_ctx/core/
protocol.rs

1use std::path::Path;
2
3// ── Shared types moved here from tools/ to break reverse-dependency ──
4
5/// Context Reduction Protocol mode controlling output verbosity.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum CrpMode {
8    Off,
9    Compact,
10    Tdd,
11}
12
13impl CrpMode {
14    pub fn from_env() -> Self {
15        match std::env::var("LEAN_CTX_CRP_MODE")
16            .unwrap_or_default()
17            .to_lowercase()
18            .as_str()
19        {
20            "off" => Self::Off,
21            "compact" => Self::Compact,
22            _ => Self::Tdd,
23        }
24    }
25
26    pub fn parse(s: &str) -> Option<Self> {
27        match s.trim().to_lowercase().as_str() {
28            "off" => Some(Self::Off),
29            "compact" => Some(Self::Compact),
30            "tdd" => Some(Self::Tdd),
31            _ => None,
32        }
33    }
34}
35
36/// Recorded metrics for a single MCP tool invocation.
37#[derive(Clone, Debug)]
38pub struct ToolCallRecord {
39    pub tool: String,
40    pub original_tokens: usize,
41    pub saved_tokens: usize,
42    pub mode: Option<String>,
43    pub duration_ms: u64,
44    pub timestamp: String,
45}
46
47/// Finds the outermost project root by walking up from `file_path`.
48/// For monorepos with nested `.git` dirs (e.g. `mono/backend/.git` + `mono/frontend/.git`),
49/// returns the outermost ancestor containing `.git`, a workspace marker, or a known
50/// monorepo config file — so the whole monorepo is treated as one project.
51pub fn detect_project_root(file_path: &str) -> Option<String> {
52    let start = Path::new(file_path);
53    let mut dir = if start.is_dir() {
54        start
55    } else {
56        start.parent()?
57    };
58    let mut best: Option<String> = None;
59
60    loop {
61        if is_project_root_marker(dir) {
62            best = Some(dir.to_string_lossy().to_string());
63        }
64        match dir.parent() {
65            Some(parent) if parent != dir => dir = parent,
66            _ => break,
67        }
68    }
69    best
70}
71
72/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
73fn is_project_root_marker(dir: &Path) -> bool {
74    const MARKERS: &[&str] = &[
75        ".git",
76        "Cargo.toml",
77        "package.json",
78        "go.work",
79        "pnpm-workspace.yaml",
80        "lerna.json",
81        "nx.json",
82        "turbo.json",
83        ".projectile",
84        "pyproject.toml",
85        "setup.py",
86        "Makefile",
87        "CMakeLists.txt",
88        "BUILD.bazel",
89    ];
90    MARKERS.iter().any(|m| dir.join(m).exists())
91}
92
93/// Returns the project root for `file_path`, falling back to cwd if none found.
94/// Logs a warning when the fallback is a broad directory (home, root).
95pub fn detect_project_root_or_cwd(file_path: &str) -> String {
96    if let Some(root) = detect_project_root(file_path) {
97        return root;
98    }
99
100    let fallback = {
101        let p = Path::new(file_path);
102        if p.exists() {
103            if p.is_dir() {
104                file_path.to_string()
105            } else {
106                p.parent().map_or_else(
107                    || file_path.to_string(),
108                    |pp| pp.to_string_lossy().to_string(),
109                )
110            }
111        } else {
112            std::env::current_dir()
113                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
114        }
115    };
116
117    if is_broad_directory(&fallback) {
118        tracing::warn!(
119            "[protocol: no project markers found — falling back to broad directory {fallback}. \
120             Set LEAN_CTX_PROJECT_ROOT to override]"
121        );
122    }
123
124    fallback
125}
126
127fn is_broad_directory(path: &str) -> bool {
128    if path == "/" || path == "\\" || path == "." {
129        return true;
130    }
131    if let Some(home) = dirs::home_dir() {
132        let home_str = home.to_string_lossy();
133        if path == home_str.as_ref() || path == format!("{home_str}/") {
134            return true;
135        }
136    }
137    false
138}
139
140/// Returns the file name component of a path for compact display.
141pub fn shorten_path(path: &str) -> String {
142    let p = Path::new(path);
143    if let Some(name) = p.file_name() {
144        return name.to_string_lossy().to_string();
145    }
146    path.to_string()
147}
148
149/// Formats a token savings summary like `[42 tok saved (30%)]`.
150pub fn format_savings(original: usize, compressed: usize) -> String {
151    let saved = original.saturating_sub(compressed);
152    if original == 0 {
153        return "0 tok saved".to_string();
154    }
155    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
156    format!("[{saved} tok saved ({pct}%)]")
157}
158
159/// A terse instruction code and its human-readable expansion.
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
228/// Generates the INSTRUCTION CODES block for agent system prompts.
229pub fn instruction_decoder_block() -> String {
230    let pairs: Vec<String> = TEMPLATES
231        .iter()
232        .map(|t| format!("{}={}", t.code, t.full))
233        .collect();
234    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
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            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
304            crate::core::pathutil::safe_canonicalize(&base).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}