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 parse(s: &str) -> Option<Self> {
15        match s.trim().to_lowercase().as_str() {
16            "off" => Some(Self::Off),
17            "compact" => Some(Self::Compact),
18            "tdd" => Some(Self::Tdd),
19            _ => None,
20        }
21    }
22}
23
24/// Recorded metrics for a single MCP tool invocation.
25#[derive(Clone, Debug)]
26pub struct ToolCallRecord {
27    pub tool: String,
28    pub original_tokens: usize,
29    pub saved_tokens: usize,
30    pub mode: Option<String>,
31    pub duration_ms: u64,
32    pub timestamp: String,
33}
34
35/// Finds the outermost project root by walking up from `file_path`.
36/// For monorepos with nested `.git` dirs (e.g. `mono/backend/.git` + `mono/frontend/.git`),
37/// returns the outermost ancestor containing `.git`, a workspace marker, or a known
38/// monorepo config file — so the whole monorepo is treated as one project.
39pub fn detect_project_root(file_path: &str) -> Option<String> {
40    let start = Path::new(file_path);
41    let mut dir = if start.is_dir() {
42        start
43    } else {
44        start.parent()?
45    };
46    let mut best: Option<String> = None;
47
48    loop {
49        if is_project_root_marker(dir) {
50            best = Some(dir.to_string_lossy().to_string());
51        }
52        match dir.parent() {
53            Some(parent) if parent != dir => dir = parent,
54            _ => break,
55        }
56    }
57    best
58}
59
60/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
61fn is_project_root_marker(dir: &Path) -> bool {
62    const MARKERS: &[&str] = &[
63        ".git",
64        "Cargo.toml",
65        "package.json",
66        "go.work",
67        "pnpm-workspace.yaml",
68        "lerna.json",
69        "nx.json",
70        "turbo.json",
71        ".projectile",
72        "pyproject.toml",
73        "setup.py",
74        "Makefile",
75        "CMakeLists.txt",
76        "BUILD.bazel",
77    ];
78    MARKERS.iter().any(|m| dir.join(m).exists())
79}
80
81/// Returns the project root for `file_path`, falling back to cwd if none found.
82/// Checks LEAN_CTX_PROJECT_ROOT env var and config.toml `project_root` first.
83/// Logs a warning when the fallback is a broad directory (home, root).
84pub fn detect_project_root_or_cwd(file_path: &str) -> String {
85    if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT") {
86        if !env_root.is_empty() {
87            return env_root;
88        }
89    }
90    let cfg = crate::core::config::Config::load();
91    if let Some(ref cfg_root) = cfg.project_root {
92        if !cfg_root.is_empty() {
93            return cfg_root.clone();
94        }
95    }
96    if let Some(ide_root) = resolve_ide_path(&cfg, file_path) {
97        return ide_root;
98    }
99    if let Some(root) = detect_project_root(file_path) {
100        return root;
101    }
102
103    let fallback = {
104        let p = Path::new(file_path);
105        if p.exists() {
106            if p.is_dir() {
107                file_path.to_string()
108            } else {
109                p.parent().map_or_else(
110                    || file_path.to_string(),
111                    |pp| pp.to_string_lossy().to_string(),
112                )
113            }
114        } else {
115            std::env::current_dir()
116                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
117        }
118    };
119
120    if is_broad_directory(&fallback) {
121        use std::sync::Once;
122        static WARN_ONCE: Once = Once::new();
123        WARN_ONCE.call_once(|| {
124            tracing::warn!(
125                "[protocol: no project detected — current directory is {fallback} which is not a project root.\n  \
126                 To fix: run from inside a project (with .git, Cargo.toml, package.json, etc.)\n  \
127                 Or set: export LEAN_CTX_PROJECT_ROOT=/path/to/your/project]"
128            );
129        });
130    }
131
132    fallback
133}
134
135fn is_broad_directory(path: &str) -> bool {
136    if path == "/" || path == "\\" || path == "." {
137        return true;
138    }
139    if let Some(home) = dirs::home_dir() {
140        let home_str = home.to_string_lossy();
141        if path == home_str.as_ref() || path == format!("{home_str}/") {
142            return true;
143        }
144    }
145    false
146}
147
148/// Resolves per-IDE allowed paths from config. If the active agent has
149/// `ide_paths` configured, returns the first path that contains `file_path`.
150fn resolve_ide_path(cfg: &crate::core::config::Config, file_path: &str) -> Option<String> {
151    if cfg.ide_paths.is_empty() {
152        return None;
153    }
154    let agent = std::env::var("LEAN_CTX_AGENT").ok()?;
155    let agent_lower = agent.to_lowercase();
156    let paths = cfg.ide_paths.get(&agent_lower)?;
157    let fp = Path::new(file_path);
158    for allowed in paths {
159        let ap = Path::new(allowed.as_str());
160        if fp.starts_with(ap) {
161            return Some(allowed.clone());
162        }
163    }
164    // file_path is outside all allowed paths — return first allowed path as root
165    paths.first().cloned()
166}
167
168/// Returns the file name component of a path for compact display.
169pub fn shorten_path(path: &str) -> String {
170    let p = Path::new(path);
171    if let Some(name) = p.file_name() {
172        return name.to_string_lossy().to_string();
173    }
174    path.to_string()
175}
176
177/// Returns a path relative to `root` for disambiguated display.
178/// Falls back to basename if stripping fails.
179pub fn shorten_path_relative(path: &str, root: &str) -> String {
180    let p = Path::new(path);
181    let r = Path::new(root);
182    if let Ok(rel) = p.strip_prefix(r) {
183        let s = rel.to_string_lossy();
184        if !s.is_empty() {
185            return s.to_string();
186        }
187    }
188    shorten_path(path)
189}
190
191/// Whether savings footers should be suppressed in tool output.
192///
193/// Default config is `never` to keep CLI output quiet; `auto` remains available for
194/// legacy compatibility and still follows transport context when explicitly enabled.
195static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
196
197/// Mark the current process as serving MCP tool calls (suppresses savings footers in `auto` mode).
198pub fn set_mcp_context(active: bool) {
199    MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
200}
201
202/// Returns true if savings footers should be shown based on config + transport context.
203///
204/// Suppressed when `LEAN_CTX_QUIET=1` (production use, e.g. Codex with minimal verbosity).
205pub fn savings_footer_visible() -> bool {
206    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
207        return false;
208    }
209    let mode = super::config::SavingsFooter::effective();
210    match mode {
211        super::config::SavingsFooter::Always => true,
212        super::config::SavingsFooter::Never => false,
213        super::config::SavingsFooter::Auto => {
214            !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
215        }
216    }
217}
218
219/// Whether non-essential meta lines (cache refs, budget warnings, repetition hints) should be shown.
220///
221/// Default is false to keep tool outputs clean for agents; opt-in via env var.
222pub fn meta_visible() -> bool {
223    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
224        return false;
225    }
226    matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
227        || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
228}
229
230/// Formats a unified token savings footer like `[lean-ctx: 100→50 tok, -50%]`.
231///
232/// Returns an empty string when savings footers are suppressed (MCP context in `auto` mode,
233/// or `savings_footer = "never"`).
234pub fn format_savings(original: usize, compressed: usize) -> String {
235    if !savings_footer_visible() {
236        return String::new();
237    }
238    if original == 0 {
239        return String::new();
240    }
241    let saved = original.saturating_sub(compressed);
242    if saved == 0 {
243        return String::new();
244    }
245    let pct = (saved as f64 / original as f64 * 100.0).round() as usize;
246    format!("[lean-ctx: {original}\u{2192}{compressed} tok, -{pct}%]")
247}
248
249/// Appends a savings footer to `output` with a newline separator, but only if the footer is non-empty.
250pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
251    let footer = format_savings(original, compressed);
252    if footer.is_empty() {
253        output.to_string()
254    } else {
255        format!("{output}\n{footer}")
256    }
257}
258
259/// A terse instruction code and its human-readable expansion.
260pub struct InstructionTemplate {
261    pub code: &'static str,
262    pub full: &'static str,
263}
264
265const TEMPLATES: &[InstructionTemplate] = &[
266    InstructionTemplate {
267        code: "ACT1",
268        full: "Act immediately, 1-line result",
269    },
270    InstructionTemplate {
271        code: "BRIEF",
272        full: "1-2 line approach, then act",
273    },
274    InstructionTemplate {
275        code: "FULL",
276        full: "Outline+edge cases, then act",
277    },
278    InstructionTemplate {
279        code: "DELTA",
280        full: "Changed lines only",
281    },
282    InstructionTemplate {
283        code: "NOREPEAT",
284        full: "No repeat, use Fn refs",
285    },
286    InstructionTemplate {
287        code: "STRUCT",
288        full: "+/-/~ notation",
289    },
290    InstructionTemplate {
291        code: "1LINE",
292        full: "1 line per action",
293    },
294    InstructionTemplate {
295        code: "NODOC",
296        full: "No narration comments",
297    },
298    InstructionTemplate {
299        code: "ACTFIRST",
300        full: "Tool calls first, no narration",
301    },
302    InstructionTemplate {
303        code: "QUALITY",
304        full: "Never skip edge cases",
305    },
306    InstructionTemplate {
307        code: "NOMOCK",
308        full: "No mock/placeholder data",
309    },
310    InstructionTemplate {
311        code: "FREF",
312        full: "Fn refs only, no full paths",
313    },
314    InstructionTemplate {
315        code: "DIFF",
316        full: "Diff lines only",
317    },
318    InstructionTemplate {
319        code: "ABBREV",
320        full: "fn,cfg,impl,deps,req,res,ctx,err",
321    },
322    InstructionTemplate {
323        code: "SYMBOLS",
324        full: "+=add -=rm ~=mod ->=ret",
325    },
326];
327
328/// Generates the INSTRUCTION CODES block for agent system prompts.
329/// Only emits content when CRP mode is Tdd (otherwise returns empty string
330/// to avoid wasting ~80-100 tokens per MCP instructions payload).
331pub fn instruction_decoder_block() -> String {
332    let mode = crate::core::profiles::active_profile()
333        .compression
334        .crp_mode_effective()
335        .to_string();
336    if mode != "tdd" {
337        return String::new();
338    }
339    let pairs: Vec<String> = TEMPLATES
340        .iter()
341        .map(|t| format!("{}={}", t.code, t.full))
342        .collect();
343    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
344}
345
346/// Encode an instruction suffix using short codes with budget hints.
347/// Response budget is dynamic based on task complexity to shape LLM output length.
348pub fn encode_instructions(complexity: &str) -> String {
349    match complexity {
350        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
351        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
352        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
353        "complex" => {
354            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
355        }
356        "architectural" => {
357            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
358        }
359        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
360    }
361}
362
363/// Encode instructions with SNR metric for context quality awareness.
364pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
365    let snr = if compression_pct > 0.0 {
366        1.0 - (compression_pct / 100.0)
367    } else {
368        1.0
369    };
370    let base = encode_instructions(complexity);
371    format!("{base} | SNR: {snr:.2}")
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn is_project_root_marker_detects_git() {
380        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
381        let _ = std::fs::create_dir_all(&tmp);
382        let git_dir = tmp.join(".git");
383        let _ = std::fs::create_dir_all(&git_dir);
384        assert!(is_project_root_marker(&tmp));
385        let _ = std::fs::remove_dir_all(&tmp);
386    }
387
388    #[test]
389    fn is_project_root_marker_detects_cargo_toml() {
390        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
391        let _ = std::fs::create_dir_all(&tmp);
392        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
393        assert!(is_project_root_marker(&tmp));
394        let _ = std::fs::remove_dir_all(&tmp);
395    }
396
397    #[test]
398    fn detect_project_root_finds_outermost() {
399        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
400        let inner = base.join("packages").join("app");
401        let _ = std::fs::create_dir_all(&inner);
402        let _ = std::fs::create_dir_all(base.join(".git"));
403        let _ = std::fs::create_dir_all(inner.join(".git"));
404
405        let test_file = inner.join("main.rs");
406        let _ = std::fs::write(&test_file, "fn main() {}");
407
408        let root = detect_project_root(test_file.to_str().unwrap());
409        assert!(root.is_some(), "should find a project root for nested .git");
410        let root_path = std::path::PathBuf::from(root.unwrap());
411        assert_eq!(
412            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
413            crate::core::pathutil::safe_canonicalize(&base).ok(),
414            "should return outermost .git, not inner"
415        );
416
417        let _ = std::fs::remove_dir_all(&base);
418    }
419
420    #[test]
421    fn decoder_block_contains_all_codes() {
422        let block = instruction_decoder_block();
423        for t in TEMPLATES {
424            assert!(
425                block.contains(t.code),
426                "decoder should contain code {}",
427                t.code
428            );
429        }
430    }
431
432    #[test]
433    fn encoded_instructions_are_compact() {
434        use super::super::tokens::count_tokens;
435        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
436        let encoded = encode_instructions("mechanical");
437        assert!(
438            count_tokens(&encoded) <= count_tokens(full),
439            "encoded ({}) should be <= full ({})",
440            count_tokens(&encoded),
441            count_tokens(full)
442        );
443    }
444
445    #[test]
446    fn all_complexity_levels_encode() {
447        for level in &["mechanical", "standard", "architectural"] {
448            let encoded = encode_instructions(level);
449            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
450        }
451    }
452
453    #[test]
454    fn format_savings_returns_bracket_when_always() {
455        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
456        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
457        let s = super::format_savings(100, 50);
458        assert!(
459            s.contains("100\u{2192}50 tok"),
460            "expected unified format, got: {s}"
461        );
462        assert!(s.contains("-50%"), "expected percentage, got: {s}");
463    }
464
465    #[test]
466    fn format_savings_returns_empty_when_never() {
467        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
468        let s = super::format_savings(100, 50);
469        assert!(
470            s.is_empty(),
471            "expected empty string with never mode, got: {s}"
472        );
473    }
474
475    #[test]
476    fn format_savings_suppressed_in_mcp_auto_mode() {
477        super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
478        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
479        let s = super::format_savings(100, 50);
480        assert!(s.is_empty(), "expected empty in MCP+auto, got: {s}");
481        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
482    }
483
484    #[test]
485    fn append_savings_no_trailing_newline_when_suppressed() {
486        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
487        let result = super::append_savings("hello", 100, 50);
488        assert_eq!(result, "hello");
489    }
490}