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