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 mut dir = Path::new(file_path).parent()?;
53    let mut best: Option<String> = None;
54
55    loop {
56        if is_project_root_marker(dir) {
57            best = Some(dir.to_string_lossy().to_string());
58        }
59        match dir.parent() {
60            Some(parent) if parent != dir => dir = parent,
61            _ => break,
62        }
63    }
64    best
65}
66
67/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
68fn is_project_root_marker(dir: &Path) -> bool {
69    const MARKERS: &[&str] = &[
70        ".git",
71        "Cargo.toml",
72        "package.json",
73        "go.work",
74        "pnpm-workspace.yaml",
75        "lerna.json",
76        "nx.json",
77        "turbo.json",
78        ".projectile",
79        "pyproject.toml",
80        "setup.py",
81        "Makefile",
82        "CMakeLists.txt",
83        "BUILD.bazel",
84    ];
85    MARKERS.iter().any(|m| dir.join(m).exists())
86}
87
88/// Returns the project root for `file_path`, falling back to cwd if none found.
89pub fn detect_project_root_or_cwd(file_path: &str) -> String {
90    detect_project_root(file_path).unwrap_or_else(|| {
91        let p = Path::new(file_path);
92        if p.exists() {
93            if p.is_dir() {
94                return file_path.to_string();
95            }
96            if let Some(parent) = p.parent() {
97                return parent.to_string_lossy().to_string();
98            }
99            return file_path.to_string();
100        }
101        std::env::current_dir()
102            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
103    })
104}
105
106/// Returns the file name component of a path for compact display.
107pub fn shorten_path(path: &str) -> String {
108    let p = Path::new(path);
109    if let Some(name) = p.file_name() {
110        return name.to_string_lossy().to_string();
111    }
112    path.to_string()
113}
114
115/// Formats a token savings summary like `[42 tok saved (30%)]`.
116pub fn format_savings(original: usize, compressed: usize) -> String {
117    let saved = original.saturating_sub(compressed);
118    if original == 0 {
119        return "0 tok saved".to_string();
120    }
121    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
122    format!("[{saved} tok saved ({pct}%)]")
123}
124
125/// A terse instruction code and its human-readable expansion.
126pub struct InstructionTemplate {
127    pub code: &'static str,
128    pub full: &'static str,
129}
130
131const TEMPLATES: &[InstructionTemplate] = &[
132    InstructionTemplate {
133        code: "ACT1",
134        full: "Act immediately, 1-line result",
135    },
136    InstructionTemplate {
137        code: "BRIEF",
138        full: "1-2 line approach, then act",
139    },
140    InstructionTemplate {
141        code: "FULL",
142        full: "Outline+edge cases, then act",
143    },
144    InstructionTemplate {
145        code: "DELTA",
146        full: "Changed lines only",
147    },
148    InstructionTemplate {
149        code: "NOREPEAT",
150        full: "No repeat, use Fn refs",
151    },
152    InstructionTemplate {
153        code: "STRUCT",
154        full: "+/-/~ notation",
155    },
156    InstructionTemplate {
157        code: "1LINE",
158        full: "1 line per action",
159    },
160    InstructionTemplate {
161        code: "NODOC",
162        full: "No narration comments",
163    },
164    InstructionTemplate {
165        code: "ACTFIRST",
166        full: "Tool calls first, no narration",
167    },
168    InstructionTemplate {
169        code: "QUALITY",
170        full: "Never skip edge cases",
171    },
172    InstructionTemplate {
173        code: "NOMOCK",
174        full: "No mock/placeholder data",
175    },
176    InstructionTemplate {
177        code: "FREF",
178        full: "Fn refs only, no full paths",
179    },
180    InstructionTemplate {
181        code: "DIFF",
182        full: "Diff lines only",
183    },
184    InstructionTemplate {
185        code: "ABBREV",
186        full: "fn,cfg,impl,deps,req,res,ctx,err",
187    },
188    InstructionTemplate {
189        code: "SYMBOLS",
190        full: "+=add -=rm ~=mod ->=ret",
191    },
192];
193
194/// Generates the INSTRUCTION CODES block for agent system prompts.
195pub fn instruction_decoder_block() -> String {
196    let pairs: Vec<String> = TEMPLATES
197        .iter()
198        .map(|t| format!("{}={}", t.code, t.full))
199        .collect();
200    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
201}
202
203/// Encode an instruction suffix using short codes with budget hints.
204/// Response budget is dynamic based on task complexity to shape LLM output length.
205pub fn encode_instructions(complexity: &str) -> String {
206    match complexity {
207        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
208        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
209        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
210        "complex" => {
211            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
212        }
213        "architectural" => {
214            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
215        }
216        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
217    }
218}
219
220/// Encode instructions with SNR metric for context quality awareness.
221pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
222    let snr = if compression_pct > 0.0 {
223        1.0 - (compression_pct / 100.0)
224    } else {
225        1.0
226    };
227    let base = encode_instructions(complexity);
228    format!("{base} | SNR: {snr:.2}")
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn is_project_root_marker_detects_git() {
237        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
238        let _ = std::fs::create_dir_all(&tmp);
239        let git_dir = tmp.join(".git");
240        let _ = std::fs::create_dir_all(&git_dir);
241        assert!(is_project_root_marker(&tmp));
242        let _ = std::fs::remove_dir_all(&tmp);
243    }
244
245    #[test]
246    fn is_project_root_marker_detects_cargo_toml() {
247        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
248        let _ = std::fs::create_dir_all(&tmp);
249        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
250        assert!(is_project_root_marker(&tmp));
251        let _ = std::fs::remove_dir_all(&tmp);
252    }
253
254    #[test]
255    fn detect_project_root_finds_outermost() {
256        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
257        let inner = base.join("packages").join("app");
258        let _ = std::fs::create_dir_all(&inner);
259        let _ = std::fs::create_dir_all(base.join(".git"));
260        let _ = std::fs::create_dir_all(inner.join(".git"));
261
262        let test_file = inner.join("main.rs");
263        let _ = std::fs::write(&test_file, "fn main() {}");
264
265        let root = detect_project_root(test_file.to_str().unwrap());
266        assert!(root.is_some(), "should find a project root for nested .git");
267        let root_path = std::path::PathBuf::from(root.unwrap());
268        assert_eq!(
269            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
270            crate::core::pathutil::safe_canonicalize(&base).ok(),
271            "should return outermost .git, not inner"
272        );
273
274        let _ = std::fs::remove_dir_all(&base);
275    }
276
277    #[test]
278    fn decoder_block_contains_all_codes() {
279        let block = instruction_decoder_block();
280        for t in TEMPLATES {
281            assert!(
282                block.contains(t.code),
283                "decoder should contain code {}",
284                t.code
285            );
286        }
287    }
288
289    #[test]
290    fn encoded_instructions_are_compact() {
291        use super::super::tokens::count_tokens;
292        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
293        let encoded = encode_instructions("mechanical");
294        assert!(
295            count_tokens(&encoded) <= count_tokens(full),
296            "encoded ({}) should be <= full ({})",
297            count_tokens(&encoded),
298            count_tokens(full)
299        );
300    }
301
302    #[test]
303    fn all_complexity_levels_encode() {
304        for level in &["mechanical", "standard", "architectural"] {
305            let encoded = encode_instructions(level);
306            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
307        }
308    }
309}