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.
169/// Normalize a path for display by converting Windows backslashes to forward
170/// slashes. Forward slashes are valid path separators on Windows, and unlike
171/// backslashes they are never misinterpreted as escape sequences by the JSON,
172/// markdown, or terminal layers of MCP clients — which corrupted Windows paths
173/// in tool output (e.g. `C:\Users\…` rendered as `CUsers…`). See issue #324.
174pub fn display_path(path: &str) -> String {
175    path.replace('\\', "/")
176}
177
178pub fn shorten_path(path: &str) -> String {
179    let normalized = display_path(path);
180    let p = Path::new(&normalized);
181    if let Some(name) = p.file_name() {
182        return name.to_string_lossy().to_string();
183    }
184    normalized
185}
186
187/// Returns a path relative to `root` for disambiguated display, always with
188/// forward slashes. Falls back to the basename if stripping fails.
189///
190/// Relativization is done on slash-normalized strings so it works regardless of
191/// the separator style the client sent (Windows backslashes, mixed separators).
192/// A component boundary is required so that root `a/b` never matches `a/bc`.
193pub fn shorten_path_relative(path: &str, root: &str) -> String {
194    let norm_path = display_path(path);
195    let norm_root = display_path(root);
196    let norm_root = norm_root.strip_suffix('/').unwrap_or(&norm_root);
197    if let Some(rest) = norm_path.strip_prefix(norm_root) {
198        if let Some(rel) = rest.strip_prefix('/') {
199            if !rel.is_empty() {
200                return rel.to_string();
201            }
202        }
203    }
204    shorten_path(&norm_path)
205}
206
207/// Whether savings footers should be suppressed in tool output.
208///
209/// Default config is `never` to keep CLI output quiet; `auto` remains available for
210/// legacy compatibility and still follows transport context when explicitly enabled.
211static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
212
213/// Mark the current process as serving MCP tool calls (suppresses savings footers in `auto` mode).
214pub fn set_mcp_context(active: bool) {
215    MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
216}
217
218/// Returns true if savings footers should be shown based on config + transport context.
219///
220/// Suppressed when `LEAN_CTX_QUIET=1`, `LEAN_CTX_SHOW_SAVINGS=0`, or compression is `Max` (ultra).
221pub fn savings_footer_visible() -> bool {
222    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
223        return false;
224    }
225    if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "0") {
226        return false;
227    }
228    if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1") {
229        return true;
230    }
231    let mode = super::config::SavingsFooter::effective();
232    match mode {
233        super::config::SavingsFooter::Always => true,
234        super::config::SavingsFooter::Never => false,
235        super::config::SavingsFooter::Auto => {
236            !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
237        }
238    }
239}
240
241/// Whether non-essential meta lines (cache refs, budget warnings, repetition hints) should be shown.
242///
243/// Default is false to keep tool outputs clean for agents; opt-in via env var.
244pub fn meta_visible() -> bool {
245    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
246        return false;
247    }
248    matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
249        || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
250}
251
252/// Formats a token savings footer with box-drawing delimiters.
253///
254/// Output: `─── 4,200 → 840 tok (↓80%) ───`
255///
256/// Returns an empty string when savings footers are suppressed.
257pub fn format_savings(original: usize, compressed: usize) -> String {
258    super::savings_footer::format_footer_basic(original, compressed)
259}
260
261/// Formats a savings footer with mode and optional detail context.
262///
263/// Output: `─── 4,200 → 840 tok (↓80%) | mode: map ───`
264pub fn format_savings_with_info(
265    original: usize,
266    compressed: usize,
267    mode: Option<&str>,
268    detail: Option<&str>,
269) -> String {
270    super::savings_footer::format_footer(&super::savings_footer::SavingsInfo {
271        original,
272        compressed,
273        mode,
274        detail,
275    })
276}
277
278/// Appends a savings footer to `output` with a newline separator, but only if the footer is non-empty.
279pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
280    super::savings_footer::append_footer_basic(output, original, compressed)
281}
282
283/// Appends a savings footer with mode/detail context.
284pub fn append_savings_with_info(
285    output: &str,
286    original: usize,
287    compressed: usize,
288    mode: Option<&str>,
289    detail: Option<&str>,
290) -> String {
291    super::savings_footer::append_footer(
292        output,
293        &super::savings_footer::SavingsInfo {
294            original,
295            compressed,
296            mode,
297            detail,
298        },
299    )
300}
301
302/// A terse instruction code and its human-readable expansion.
303pub struct InstructionTemplate {
304    pub code: &'static str,
305    pub full: &'static str,
306}
307
308/// Exactly the codes `encode_instructions` can emit — the decoder block rides
309/// in every tdd-mode session, so codes that are never emitted (NODOC,
310/// ACTFIRST, NOMOCK) or already explained inline by the CRP suffix (ABBREV,
311/// SYMBOLS) must not be re-defined here (#579).
312const TEMPLATES: &[InstructionTemplate] = &[
313    InstructionTemplate {
314        code: "ACT1",
315        full: "act now, 1-line result",
316    },
317    InstructionTemplate {
318        code: "BRIEF",
319        full: "1-2 line approach, then act",
320    },
321    InstructionTemplate {
322        code: "FULL",
323        full: "outline+edge cases first",
324    },
325    InstructionTemplate {
326        code: "DELTA",
327        full: "changed lines only",
328    },
329    InstructionTemplate {
330        code: "NOREPEAT",
331        full: "use Fn refs",
332    },
333    InstructionTemplate {
334        code: "STRUCT",
335        full: "+/-/~",
336    },
337    InstructionTemplate {
338        code: "1LINE",
339        full: "1 line/action",
340    },
341    InstructionTemplate {
342        code: "QUALITY",
343        full: "keep edge cases",
344    },
345    InstructionTemplate {
346        code: "FREF",
347        full: "Fn refs, no paths",
348    },
349    InstructionTemplate {
350        code: "DIFF",
351        full: "diff lines only",
352    },
353];
354
355/// Generates the INSTRUCTION CODES block for agent system prompts.
356/// Only emits content when the instructions being built are in Tdd CRP mode
357/// (otherwise returns empty — the codes are only emitted in tdd outputs, so
358/// defining them would waste ~60 tokens per MCP instructions payload, #579).
359pub fn instruction_decoder_block(tdd_active: bool) -> String {
360    if !tdd_active {
361        return String::new();
362    }
363    let pairs: Vec<String> = TEMPLATES
364        .iter()
365        .map(|t| format!("{}={}", t.code, t.full))
366        .collect();
367    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
368}
369
370/// Encode an instruction suffix using short codes with budget hints.
371/// Response budget is dynamic based on task complexity to shape LLM output length.
372pub fn encode_instructions(complexity: &str) -> String {
373    match complexity {
374        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
375        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
376        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
377        "complex" => {
378            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
379        }
380        "architectural" => {
381            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
382        }
383        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
384    }
385}
386
387/// Encode instructions with SNR metric for context quality awareness.
388pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
389    let snr = if compression_pct > 0.0 {
390        1.0 - (compression_pct / 100.0)
391    } else {
392        1.0
393    };
394    let base = encode_instructions(complexity);
395    format!("{base} | SNR: {snr:.2}")
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn display_path_normalizes_windows_separators() {
404        // Issue #324: backslashes were dropped/escaped by client render layers.
405        assert_eq!(
406            display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
407            "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
408        );
409        assert_eq!(display_path("src/main.rs"), "src/main.rs");
410    }
411
412    #[test]
413    fn shorten_path_basename_for_windows_abs_path() {
414        assert_eq!(
415            shorten_path(r"D:\Temp\win-build-raw.log"),
416            "win-build-raw.log"
417        );
418        assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
419    }
420
421    #[test]
422    fn shorten_path_relative_handles_windows_separators() {
423        // Relative display keeps forward slashes regardless of input style.
424        assert_eq!(
425            shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
426            "src/app/main.rs"
427        );
428        // Mixed separators between path and root still relativize.
429        assert_eq!(
430            shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
431            "src/main.rs"
432        );
433        // A non-prefix abs path falls back to a clean basename, never a
434        // separator-stripped blob like "CUserszir…".
435        assert_eq!(
436            shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
437            "build.log"
438        );
439    }
440
441    #[test]
442    fn shorten_path_relative_requires_component_boundary() {
443        // Root "a/b" must not match sibling "a/bc".
444        assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
445        assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
446    }
447
448    #[test]
449    fn is_project_root_marker_detects_git() {
450        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
451        let _ = std::fs::create_dir_all(&tmp);
452        let git_dir = tmp.join(".git");
453        let _ = std::fs::create_dir_all(&git_dir);
454        assert!(is_project_root_marker(&tmp));
455        let _ = std::fs::remove_dir_all(&tmp);
456    }
457
458    #[test]
459    fn is_project_root_marker_detects_cargo_toml() {
460        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
461        let _ = std::fs::create_dir_all(&tmp);
462        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
463        assert!(is_project_root_marker(&tmp));
464        let _ = std::fs::remove_dir_all(&tmp);
465    }
466
467    #[test]
468    fn detect_project_root_finds_outermost() {
469        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
470        let inner = base.join("packages").join("app");
471        let _ = std::fs::create_dir_all(&inner);
472        let _ = std::fs::create_dir_all(base.join(".git"));
473        let _ = std::fs::create_dir_all(inner.join(".git"));
474
475        let test_file = inner.join("main.rs");
476        let _ = std::fs::write(&test_file, "fn main() {}");
477
478        let root = detect_project_root(test_file.to_str().unwrap());
479        assert!(root.is_some(), "should find a project root for nested .git");
480        let root_path = std::path::PathBuf::from(root.unwrap());
481        assert_eq!(
482            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
483            crate::core::pathutil::safe_canonicalize(&base).ok(),
484            "should return outermost .git, not inner"
485        );
486
487        let _ = std::fs::remove_dir_all(&base);
488    }
489
490    #[test]
491    fn decoder_block_contains_all_codes() {
492        let block = instruction_decoder_block(true);
493        for t in TEMPLATES {
494            assert!(
495                block.contains(t.code),
496                "decoder should contain code {}",
497                t.code
498            );
499        }
500    }
501
502    #[test]
503    fn decoder_block_empty_outside_tdd() {
504        assert!(instruction_decoder_block(false).is_empty());
505    }
506
507    #[test]
508    fn decoder_codes_match_what_encode_can_emit() {
509        // Every defined code must appear in at least one encode_instructions
510        // output — dead definitions tax every tdd session (#579).
511        let all_modes: Vec<String> = [
512            "mechanical",
513            "simple",
514            "standard",
515            "complex",
516            "architectural",
517            "unknown",
518        ]
519        .iter()
520        .map(|c| encode_instructions(c))
521        .collect();
522        for t in TEMPLATES {
523            assert!(
524                all_modes.iter().any(|m| m.contains(t.code)),
525                "code {} is defined but never emitted",
526                t.code
527            );
528        }
529    }
530
531    #[test]
532    fn encoded_instructions_are_compact() {
533        use super::super::tokens::count_tokens;
534        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
535        let encoded = encode_instructions("mechanical");
536        assert!(
537            count_tokens(&encoded) <= count_tokens(full),
538            "encoded ({}) should be <= full ({})",
539            count_tokens(&encoded),
540            count_tokens(full)
541        );
542    }
543
544    #[test]
545    fn all_complexity_levels_encode() {
546        for level in &["mechanical", "standard", "architectural"] {
547            let encoded = encode_instructions(level);
548            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
549        }
550    }
551
552    #[test]
553    fn savings_footer_env_gated_tests() {
554        let _lock = crate::core::data_dir::test_env_lock();
555
556        // Test: always mode shows box-drawing format
557        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
558        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
559        std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
560        std::env::remove_var("LEAN_CTX_QUIET");
561
562        let s = super::format_savings(100, 50);
563        assert!(s.contains("\u{2192}"), "expected arrow: {s}");
564        assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
565        assert!(
566            s.starts_with("\u{2500}\u{2500}\u{2500}"),
567            "expected box-drawing: {s}"
568        );
569
570        // Test: mode info included
571        let s = super::format_savings_with_info(4200, 840, Some("map"), None);
572        assert!(s.contains("mode: map"), "expected mode: {s}");
573        assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
574
575        // Test: never mode suppresses
576        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
577        std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
578        let s = super::format_savings(100, 50);
579        assert!(s.is_empty(), "expected empty with never: {s}");
580
581        let result = super::append_savings("hello", 100, 50);
582        assert_eq!(result, "hello");
583
584        // Test: MCP auto mode suppresses
585        super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
586        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
587        std::env::remove_var("LEAN_CTX_SHOW_SAVINGS");
588        let s = super::format_savings(100, 50);
589        assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
590        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
591
592        // Test: SHOW_SAVINGS overrides config
593        std::env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
594        std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
595        assert!(super::savings_footer_visible());
596        std::env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
597        assert!(!super::savings_footer_visible());
598
599        // Restore ALL touched env — leaking LEAN_CTX_SAVINGS_FOOTER made
600        // footers visible in unrelated tests (GL #556 flakiness).
601        std::env::remove_var("LEAN_CTX_SHOW_SAVINGS");
602        std::env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
603    }
604}