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/// Whether savings footers should be suppressed in tool output.
150///
151/// In `auto` mode (default): suppressed when running as MCP server (agent context),
152/// shown when running as CLI (human context).
153static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
154
155/// Mark the current process as serving MCP tool calls (suppresses savings footers in `auto` mode).
156pub fn set_mcp_context(active: bool) {
157    MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
158}
159
160/// Returns true if savings footers should be shown based on config + transport context.
161pub fn savings_footer_visible() -> bool {
162    let mode = super::config::SavingsFooter::effective();
163    match mode {
164        super::config::SavingsFooter::Always => true,
165        super::config::SavingsFooter::Never => false,
166        super::config::SavingsFooter::Auto => {
167            !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
168        }
169    }
170}
171
172/// Formats a token savings summary like `[42 tok saved (30%)]`.
173///
174/// Returns an empty string when savings footers are suppressed (MCP context in `auto` mode,
175/// or `savings_footer = "never"`).
176pub fn format_savings(original: usize, compressed: usize) -> String {
177    if !savings_footer_visible() {
178        return String::new();
179    }
180    let saved = original.saturating_sub(compressed);
181    if original == 0 {
182        return "0 tok saved".to_string();
183    }
184    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
185    format!("[{saved} tok saved ({pct}%)]")
186}
187
188/// Appends a savings footer to `output` with a newline separator, but only if the footer is non-empty.
189pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
190    let footer = format_savings(original, compressed);
191    if footer.is_empty() {
192        output.to_string()
193    } else {
194        format!("{output}\n{footer}")
195    }
196}
197
198/// A terse instruction code and its human-readable expansion.
199pub struct InstructionTemplate {
200    pub code: &'static str,
201    pub full: &'static str,
202}
203
204const TEMPLATES: &[InstructionTemplate] = &[
205    InstructionTemplate {
206        code: "ACT1",
207        full: "Act immediately, 1-line result",
208    },
209    InstructionTemplate {
210        code: "BRIEF",
211        full: "1-2 line approach, then act",
212    },
213    InstructionTemplate {
214        code: "FULL",
215        full: "Outline+edge cases, then act",
216    },
217    InstructionTemplate {
218        code: "DELTA",
219        full: "Changed lines only",
220    },
221    InstructionTemplate {
222        code: "NOREPEAT",
223        full: "No repeat, use Fn refs",
224    },
225    InstructionTemplate {
226        code: "STRUCT",
227        full: "+/-/~ notation",
228    },
229    InstructionTemplate {
230        code: "1LINE",
231        full: "1 line per action",
232    },
233    InstructionTemplate {
234        code: "NODOC",
235        full: "No narration comments",
236    },
237    InstructionTemplate {
238        code: "ACTFIRST",
239        full: "Tool calls first, no narration",
240    },
241    InstructionTemplate {
242        code: "QUALITY",
243        full: "Never skip edge cases",
244    },
245    InstructionTemplate {
246        code: "NOMOCK",
247        full: "No mock/placeholder data",
248    },
249    InstructionTemplate {
250        code: "FREF",
251        full: "Fn refs only, no full paths",
252    },
253    InstructionTemplate {
254        code: "DIFF",
255        full: "Diff lines only",
256    },
257    InstructionTemplate {
258        code: "ABBREV",
259        full: "fn,cfg,impl,deps,req,res,ctx,err",
260    },
261    InstructionTemplate {
262        code: "SYMBOLS",
263        full: "+=add -=rm ~=mod ->=ret",
264    },
265];
266
267/// Generates the INSTRUCTION CODES block for agent system prompts.
268pub fn instruction_decoder_block() -> String {
269    let pairs: Vec<String> = TEMPLATES
270        .iter()
271        .map(|t| format!("{}={}", t.code, t.full))
272        .collect();
273    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
274}
275
276/// Encode an instruction suffix using short codes with budget hints.
277/// Response budget is dynamic based on task complexity to shape LLM output length.
278pub fn encode_instructions(complexity: &str) -> String {
279    match complexity {
280        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
281        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
282        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
283        "complex" => {
284            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
285        }
286        "architectural" => {
287            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
288        }
289        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
290    }
291}
292
293/// Encode instructions with SNR metric for context quality awareness.
294pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
295    let snr = if compression_pct > 0.0 {
296        1.0 - (compression_pct / 100.0)
297    } else {
298        1.0
299    };
300    let base = encode_instructions(complexity);
301    format!("{base} | SNR: {snr:.2}")
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn is_project_root_marker_detects_git() {
310        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
311        let _ = std::fs::create_dir_all(&tmp);
312        let git_dir = tmp.join(".git");
313        let _ = std::fs::create_dir_all(&git_dir);
314        assert!(is_project_root_marker(&tmp));
315        let _ = std::fs::remove_dir_all(&tmp);
316    }
317
318    #[test]
319    fn is_project_root_marker_detects_cargo_toml() {
320        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
321        let _ = std::fs::create_dir_all(&tmp);
322        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
323        assert!(is_project_root_marker(&tmp));
324        let _ = std::fs::remove_dir_all(&tmp);
325    }
326
327    #[test]
328    fn detect_project_root_finds_outermost() {
329        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
330        let inner = base.join("packages").join("app");
331        let _ = std::fs::create_dir_all(&inner);
332        let _ = std::fs::create_dir_all(base.join(".git"));
333        let _ = std::fs::create_dir_all(inner.join(".git"));
334
335        let test_file = inner.join("main.rs");
336        let _ = std::fs::write(&test_file, "fn main() {}");
337
338        let root = detect_project_root(test_file.to_str().unwrap());
339        assert!(root.is_some(), "should find a project root for nested .git");
340        let root_path = std::path::PathBuf::from(root.unwrap());
341        assert_eq!(
342            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
343            crate::core::pathutil::safe_canonicalize(&base).ok(),
344            "should return outermost .git, not inner"
345        );
346
347        let _ = std::fs::remove_dir_all(&base);
348    }
349
350    #[test]
351    fn decoder_block_contains_all_codes() {
352        let block = instruction_decoder_block();
353        for t in TEMPLATES {
354            assert!(
355                block.contains(t.code),
356                "decoder should contain code {}",
357                t.code
358            );
359        }
360    }
361
362    #[test]
363    fn encoded_instructions_are_compact() {
364        use super::super::tokens::count_tokens;
365        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
366        let encoded = encode_instructions("mechanical");
367        assert!(
368            count_tokens(&encoded) <= count_tokens(full),
369            "encoded ({}) should be <= full ({})",
370            count_tokens(&encoded),
371            count_tokens(full)
372        );
373    }
374
375    #[test]
376    fn all_complexity_levels_encode() {
377        for level in &["mechanical", "standard", "architectural"] {
378            let encoded = encode_instructions(level);
379            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
380        }
381    }
382
383    #[test]
384    fn format_savings_returns_bracket_when_always() {
385        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
386        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
387        let s = super::format_savings(100, 50);
388        assert!(
389            s.contains("50 tok saved"),
390            "expected savings bracket, got: {s}"
391        );
392        assert!(s.contains("50%"), "expected percentage, got: {s}");
393    }
394
395    #[test]
396    fn format_savings_returns_empty_when_never() {
397        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
398        let s = super::format_savings(100, 50);
399        assert!(
400            s.is_empty(),
401            "expected empty string with never mode, got: {s}"
402        );
403    }
404
405    #[test]
406    fn format_savings_suppressed_in_mcp_auto_mode() {
407        super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
408        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
409        let s = super::format_savings(100, 50);
410        assert!(s.is_empty(), "expected empty in MCP+auto, got: {s}");
411        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
412    }
413
414    #[test]
415    fn append_savings_no_trailing_newline_when_suppressed() {
416        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
417        let result = super::append_savings("hello", 100, 50);
418        assert_eq!(result, "hello");
419    }
420}