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