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    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Off => "off",
26            Self::Compact => "compact",
27            Self::Tdd => "tdd",
28        }
29    }
30
31    /// The single CRP resolver (#1890): tool output, MCP instructions and
32    /// `lean-ctx instructions` all go through here, so they can never disagree.
33    /// `profile_crp` is the profile's own `compression.crp_mode`, if it sets one.
34    pub fn resolve(profile_crp: Option<&str>) -> Self {
35        use crate::core::config::{CompressionLevel, Config};
36        let env = std::env::var("LEAN_CTX_CRP_MODE").ok();
37        let forced = CompressionLevel::session_degrade_level().or_else(CompressionLevel::from_env);
38        let configured = CompressionLevel::effective(&Config::load());
39        Self::resolve_from(env.as_deref(), forced, configured, profile_crp)
40    }
41
42    /// Precedence: `LEAN_CTX_CRP_MODE` → a forced level (correction-loop
43    /// degrade, `LEAN_CTX_COMPRESSION`) → a configured `compression_level` that
44    /// implies CRP (standard/max/raw) → the profile's `crp_mode` → off.
45    /// `off`/`lite` only shape prose, so they leave the choice to the profile.
46    pub(crate) fn resolve_from(
47        env: Option<&str>,
48        forced: Option<crate::core::config::CompressionLevel>,
49        configured: crate::core::config::CompressionLevel,
50        profile_crp: Option<&str>,
51    ) -> Self {
52        let level_crp = |level: crate::core::config::CompressionLevel| {
53            Self::parse(level.to_components().2).unwrap_or(Self::Off)
54        };
55        if let Some(v) = env.filter(|v| !v.trim().is_empty()) {
56            return Self::parse(v).unwrap_or(Self::Off);
57        }
58        if let Some(level) = forced {
59            return level_crp(level);
60        }
61        let configured = level_crp(configured);
62        if configured != Self::Off {
63            return configured;
64        }
65        profile_crp.and_then(Self::parse).unwrap_or(Self::Off)
66    }
67
68    /// Effective CRP mode for the active profile — see [`CrpMode::resolve`].
69    pub fn effective() -> Self {
70        Self::resolve(
71            crate::core::profiles::active_profile()
72                .compression
73                .crp_mode
74                .as_deref(),
75        )
76    }
77
78    /// Returns true if the mode is TDD (maximum compression).
79    pub fn is_tdd(&self) -> bool {
80        *self == Self::Tdd
81    }
82}
83
84/// Recorded metrics for a single MCP tool invocation.
85#[derive(Clone, Debug)]
86pub struct ToolCallRecord {
87    pub tool: String,
88    pub original_tokens: usize,
89    pub saved_tokens: usize,
90    pub mode: Option<String>,
91    pub duration_ms: u64,
92    pub timestamp: String,
93}
94
95/// Finds the project root by walking up from `file_path`.
96///
97/// Only a repository or a workspace widens the root to an outer directory:
98/// the outermost ancestor with `.git` wins (a monorepo with nested `.git` dirs,
99/// e.g. `mono/backend/.git` + `mono/frontend/.git`, is one project), else the
100/// outermost workspace marker (`go.work`, `pnpm-workspace.yaml`, …), else the
101/// innermost generic marker (`Cargo.toml`, `package.json`, `Makefile`, …). A
102/// stray `Makefile` or `package.json` in an ancestor (`~/code/Makefile`) no
103/// longer makes that ancestor the project.
104pub fn detect_project_root(file_path: &str) -> Option<String> {
105    let start = Path::new(file_path);
106    let mut dir = if start.is_dir() {
107        start
108    } else {
109        start.parent()?
110    };
111    let (mut git, mut workspace, mut generic): (Option<&Path>, Option<&Path>, Option<&Path>) =
112        (None, None, None);
113
114    loop {
115        // `~` with a dotfiles `.git`, `/` or an agent directory carries a
116        // marker without being a project (AP04-G6).
117        if !crate::core::pathutil::is_broad_or_unsafe_root(dir) {
118            if dir.join(".git").exists() {
119                git = Some(dir);
120            } else if is_workspace_root(dir) {
121                workspace = Some(dir);
122            } else if generic.is_none() && is_project_root_marker(dir) {
123                generic = Some(dir);
124            }
125        }
126        match dir.parent() {
127            Some(parent) if parent != dir => dir = parent,
128            _ => break,
129        }
130    }
131    git.or(workspace)
132        .or(generic)
133        .map(|dir| dir.to_string_lossy().to_string())
134}
135
136/// A multi-package workspace root: it widens the root like `.git`. A
137/// `Cargo.toml` or `package.json` counts only when it declares a workspace.
138fn is_workspace_root(dir: &Path) -> bool {
139    const WORKSPACE_MARKERS: &[&str] = &[
140        "go.work",
141        "pnpm-workspace.yaml",
142        "lerna.json",
143        "nx.json",
144        "turbo.json",
145    ];
146    let declares = |file: &str, needle: &str| {
147        std::fs::read_to_string(dir.join(file)).is_ok_and(|text| text.contains(needle))
148    };
149    WORKSPACE_MARKERS.iter().any(|m| dir.join(m).exists())
150        || declares("Cargo.toml", "[workspace]")
151        || declares("package.json", "\"workspaces\"")
152}
153
154/// True inside a request that is bound to no project: project-keyed writes
155/// are refused there (`project_unresolved`, AP01 §3) instead of keying them
156/// on a file's location.
157pub fn request_project_unresolved() -> bool {
158    crate::core::policy::runtime::REQUEST_PROJECT
159        .try_with(|slot| slot.borrow().is_none())
160        .unwrap_or(false)
161}
162
163/// Checks if a directory looks like a project root (has `.git`, workspace config, etc.).
164fn is_project_root_marker(dir: &Path) -> bool {
165    const MARKERS: &[&str] = &[
166        ".git",
167        "Cargo.toml",
168        "package.json",
169        "go.work",
170        "pnpm-workspace.yaml",
171        "lerna.json",
172        "nx.json",
173        "turbo.json",
174        ".projectile",
175        "pyproject.toml",
176        "setup.py",
177        "Makefile",
178        "CMakeLists.txt",
179        "BUILD.bazel",
180    ];
181    MARKERS.iter().any(|m| dir.join(m).exists())
182}
183
184/// Returns the project root for `file_path`, falling back to cwd if none found.
185/// Outside a request, checks LEAN_CTX_PROJECT_ROOT env var and config.toml
186/// `project_root` first.
187/// Logs a warning when the fallback is a broad directory (home, root).
188pub fn detect_project_root_or_cwd(file_path: &str) -> String {
189    // Inside a request its bound project is the partition everything it
190    // touches belongs to; a pin, the global config or the process cwd never
191    // replace it. Without a bound project only the file's own location counts
192    // (AP01 §3, AP04-G4).
193    if let Ok(project) =
194        crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
195    {
196        return match project {
197            Some(root) => root.to_string_lossy().into_owned(),
198            None => detect_project_root(file_path).unwrap_or_else(|| {
199                let path = Path::new(file_path);
200                if path.is_dir() {
201                    file_path.to_string()
202                } else {
203                    path.parent().map_or_else(
204                        || file_path.to_string(),
205                        |p| p.to_string_lossy().into_owned(),
206                    )
207                }
208            }),
209        };
210    }
211    if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
212        && !env_root.is_empty()
213    {
214        return env_root;
215    }
216    let cfg = crate::core::config::Config::load();
217    if let Some(ref cfg_root) = cfg.project_root
218        && !cfg_root.is_empty()
219    {
220        return cfg_root.clone();
221    }
222    if let Some(ide_root) = resolve_ide_path(&cfg, file_path) {
223        return ide_root;
224    }
225    if let Some(root) = detect_project_root(file_path) {
226        return root;
227    }
228
229    let fallback = {
230        let p = Path::new(file_path);
231        if p.exists() {
232            if p.is_dir() {
233                file_path.to_string()
234            } else {
235                p.parent().map_or_else(
236                    || file_path.to_string(),
237                    |pp| pp.to_string_lossy().to_string(),
238                )
239            }
240        } else {
241            std::env::current_dir()
242                .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
243        }
244    };
245
246    if is_broad_directory(&fallback) && !crate::core::runtime_flags::is_dashboard_server() {
247        use std::sync::Once;
248        static WARN_ONCE: Once = Once::new();
249        WARN_ONCE.call_once(|| {
250            tracing::warn!(
251                "[protocol: no project detected — current directory is {fallback} which is not a project root.\n  \
252                 To fix: run from inside a project (with .git, Cargo.toml, package.json, etc.)\n  \
253                 Or set: export LEAN_CTX_PROJECT_ROOT=/path/to/your/project]"
254            );
255        });
256    }
257
258    fallback
259}
260
261fn is_broad_directory(path: &str) -> bool {
262    if path == "/" || path == "\\" || path == "." {
263        return true;
264    }
265    if let Some(home) = dirs::home_dir() {
266        let home_str = home.to_string_lossy();
267        if path == home_str.as_ref() || path == format!("{home_str}/") {
268            return true;
269        }
270    }
271    false
272}
273
274/// Resolves per-IDE allowed paths from config. If the active agent has
275/// `ide_paths` configured, returns the first path that contains `file_path`.
276fn resolve_ide_path(cfg: &crate::core::config::Config, file_path: &str) -> Option<String> {
277    if cfg.ide_paths.is_empty() {
278        return None;
279    }
280    let agent = std::env::var("LEAN_CTX_AGENT").ok()?;
281    let agent_lower = agent.to_lowercase();
282    let paths = cfg.ide_paths.get(&agent_lower)?;
283    let fp = Path::new(file_path);
284    for allowed in paths {
285        let ap = Path::new(allowed.as_str());
286        if fp.starts_with(ap) {
287            return Some(allowed.clone());
288        }
289    }
290    // file_path is outside all allowed paths — return first allowed path as root
291    paths.first().cloned()
292}
293
294/// Returns the file name component of a path for compact display.
295/// Normalize a path for display by converting Windows backslashes to forward
296/// slashes. Forward slashes are valid path separators on Windows, and unlike
297/// backslashes they are never misinterpreted as escape sequences by the JSON,
298/// markdown, or terminal layers of MCP clients — which corrupted Windows paths
299/// in tool output (e.g. `C:\Users\…` rendered as `CUsers…`). See issue #324.
300pub fn display_path(path: &str) -> String {
301    path.replace('\\', "/")
302}
303
304pub fn shorten_path(path: &str) -> String {
305    let normalized = display_path(path);
306    let p = Path::new(&normalized);
307    if let Some(name) = p.file_name() {
308        return name.to_string_lossy().to_string();
309    }
310    normalized
311}
312
313/// Returns a path relative to `root` for disambiguated display, always with
314/// forward slashes. Falls back to the basename if stripping fails.
315///
316/// Relativization is done on slash-normalized strings so it works regardless of
317/// the separator style the client sent (Windows backslashes, mixed separators).
318/// A component boundary is required so that root `a/b` never matches `a/bc`.
319pub fn shorten_path_relative(path: &str, root: &str) -> String {
320    let norm_path = display_path(path);
321    let norm_root = display_path(root);
322    let norm_root = norm_root.strip_suffix('/').unwrap_or(&norm_root);
323    if let Some(rest) = norm_path.strip_prefix(norm_root)
324        && let Some(rel) = rest.strip_prefix('/')
325        && !rel.is_empty()
326    {
327        return rel.to_string();
328    }
329    shorten_path(&norm_path)
330}
331
332/// Whether savings footers should be suppressed in tool output.
333///
334/// Default config is `never` to keep CLI output quiet; `auto` remains available for
335/// legacy compatibility and still follows transport context when explicitly enabled.
336static MCP_CONTEXT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
337
338/// Mark the current process as serving MCP tool calls (suppresses savings footers in `auto` mode).
339pub fn set_mcp_context(active: bool) {
340    MCP_CONTEXT.store(active, std::sync::atomic::Ordering::Relaxed);
341}
342
343/// Returns true if savings footers should be shown based on config + transport context.
344///
345/// Suppressed when `LEAN_CTX_QUIET=1`, `LEAN_CTX_SHOW_SAVINGS=0`, or compression is `Max` (ultra).
346pub fn savings_footer_visible() -> bool {
347    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
348        return false;
349    }
350    if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "0") {
351        return false;
352    }
353    if matches!(std::env::var("LEAN_CTX_SHOW_SAVINGS"), Ok(v) if v.trim() == "1") {
354        return true;
355    }
356    let mode = super::config::SavingsFooter::effective();
357    match mode {
358        super::config::SavingsFooter::Always => true,
359        super::config::SavingsFooter::Never => false,
360        super::config::SavingsFooter::Auto => {
361            !MCP_CONTEXT.load(std::sync::atomic::Ordering::Relaxed)
362        }
363    }
364}
365
366/// Whether non-essential meta lines (cache refs, budget warnings, repetition hints) should be shown.
367///
368/// Default is false to keep tool outputs clean for agents; opt-in via env var.
369pub fn meta_visible() -> bool {
370    if matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
371        return false;
372    }
373    matches!(std::env::var("LEAN_CTX_META"), Ok(v) if v.trim() == "1")
374        || matches!(std::env::var("LEAN_CTX_DIAGNOSTICS"), Ok(v) if v.trim() == "1")
375}
376
377/// Formats a token savings footer with box-drawing delimiters.
378///
379/// Output: `─── 4,200 → 840 tok (↓80%) ───`
380///
381/// Returns an empty string when savings footers are suppressed.
382pub fn format_savings(original: usize, compressed: usize) -> String {
383    super::savings_footer::format_footer_basic(original, compressed)
384}
385
386/// Formats a savings footer with mode and optional detail context.
387///
388/// Output: `─── 4,200 → 840 tok (↓80%) | mode: map ───`
389pub fn format_savings_with_info(
390    original: usize,
391    compressed: usize,
392    mode: Option<&str>,
393    detail: Option<&str>,
394) -> String {
395    super::savings_footer::format_footer(&super::savings_footer::SavingsInfo {
396        original,
397        compressed,
398        mode,
399        detail,
400    })
401}
402
403/// Appends a savings footer to `output` with a newline separator, but only if the footer is non-empty.
404pub fn append_savings(output: &str, original: usize, compressed: usize) -> String {
405    super::savings_footer::append_footer_basic(output, original, compressed)
406}
407
408/// Appends a savings footer with mode/detail context.
409pub fn append_savings_with_info(
410    output: &str,
411    original: usize,
412    compressed: usize,
413    mode: Option<&str>,
414    detail: Option<&str>,
415) -> String {
416    super::savings_footer::append_footer(
417        output,
418        &super::savings_footer::SavingsInfo {
419            original,
420            compressed,
421            mode,
422            detail,
423        },
424    )
425}
426
427/// Removes a single trailing savings footer line, if present.
428///
429/// The compression funnel appends at most one footer as the final line — either
430/// the box-drawing form (`─── 4,200 → 840 tok (↓80%) ───`) or the verbatim
431/// truncation form (`[lean-ctx: 4200→840 tok, verbatim truncated]`). The
432/// `/v1/compress` contract surfaces savings in a structured `stats` field, so
433/// message bodies must stay footer-free and byte-stable for prompt caching
434/// (#498). This strips that trailing line regardless of the ambient
435/// `savings_footer` setting; content without a footer is returned untouched.
436pub fn strip_trailing_savings_footer(output: &str) -> &str {
437    let body = output.trim_end_matches('\n');
438    let (head, last_line) = match body.rfind('\n') {
439        Some(nl) => (&body[..nl], &body[nl + 1..]),
440        None => ("", body),
441    };
442    if is_savings_footer_line(last_line) {
443        head
444    } else {
445        output
446    }
447}
448
449fn is_savings_footer_line(line: &str) -> bool {
450    let l = line.trim();
451    (l.starts_with("\u{2500}\u{2500}\u{2500} ") && l.ends_with(" \u{2500}\u{2500}\u{2500}"))
452        || (l.starts_with("[lean-ctx: ") && l.ends_with(']'))
453}
454
455/// A terse instruction code and its human-readable expansion.
456pub struct InstructionTemplate {
457    pub code: &'static str,
458    pub full: &'static str,
459}
460
461/// Exactly the codes `encode_instructions` can emit — the decoder block rides
462/// in every tdd-mode session, so codes that are never emitted (NODOC,
463/// ACTFIRST, NOMOCK) or already explained inline by the CRP suffix (ABBREV,
464/// SYMBOLS) must not be re-defined here (#579).
465const TEMPLATES: &[InstructionTemplate] = &[
466    InstructionTemplate {
467        code: "ACT1",
468        full: "act now, 1-line result",
469    },
470    InstructionTemplate {
471        code: "BRIEF",
472        full: "1-2 line approach, then act",
473    },
474    InstructionTemplate {
475        code: "FULL",
476        full: "outline+edge cases first",
477    },
478    InstructionTemplate {
479        code: "DELTA",
480        full: "changed lines only",
481    },
482    InstructionTemplate {
483        code: "NOREPEAT",
484        full: "use Fn refs",
485    },
486    InstructionTemplate {
487        code: "STRUCT",
488        full: "+/-/~",
489    },
490    InstructionTemplate {
491        code: "1LINE",
492        full: "1 line/action",
493    },
494    InstructionTemplate {
495        code: "QUALITY",
496        full: "keep edge cases",
497    },
498    InstructionTemplate {
499        code: "FREF",
500        full: "Fn refs, no paths",
501    },
502    InstructionTemplate {
503        code: "DIFF",
504        full: "diff lines only",
505    },
506];
507
508/// Generates the INSTRUCTION CODES block for agent system prompts.
509/// Only emits content when the instructions being built are in Tdd CRP mode
510/// (otherwise returns empty — the codes are only emitted in tdd outputs, so
511/// defining them would waste ~60 tokens per MCP instructions payload, #579).
512pub fn instruction_decoder_block(tdd_active: bool) -> String {
513    if !tdd_active {
514        return String::new();
515    }
516    let pairs: Vec<String> = TEMPLATES
517        .iter()
518        .map(|t| format!("{}={}", t.code, t.full))
519        .collect();
520    format!("INSTRUCTION CODES:\n  {}", pairs.join(" | "))
521}
522
523/// Encode an instruction suffix using short codes with budget hints.
524/// Response budget is dynamic based on task complexity to shape LLM output length.
525pub fn encode_instructions(complexity: &str) -> String {
526    match complexity {
527        "mechanical" => "MODE: ACT1 DELTA 1LINE | BUDGET: <=50 tokens, 1 line answer".to_string(),
528        "simple" => "MODE: BRIEF DELTA 1LINE | BUDGET: <=100 tokens, structured".to_string(),
529        "standard" => "MODE: BRIEF DELTA NOREPEAT STRUCT | BUDGET: <=200 tokens".to_string(),
530        "complex" => {
531            "MODE: FULL QUALITY NOREPEAT STRUCT FREF DIFF | BUDGET: <=500 tokens".to_string()
532        }
533        "architectural" => {
534            "MODE: FULL QUALITY NOREPEAT STRUCT FREF | BUDGET: unlimited".to_string()
535        }
536        _ => "MODE: BRIEF | BUDGET: <=200 tokens".to_string(),
537    }
538}
539
540/// Encode instructions with SNR metric for context quality awareness.
541pub fn encode_instructions_with_snr(complexity: &str, compression_pct: f64) -> String {
542    let snr = if compression_pct > 0.0 {
543        1.0 - (compression_pct / 100.0)
544    } else {
545        1.0
546    };
547    let base = encode_instructions(complexity);
548    format!("{base} | SNR: {snr:.2}")
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    /// AP04-G4: a pinned `LEAN_CTX_PROJECT_ROOT` (or global config root) does
556    /// not replace the project a request is bound to.
557    #[test]
558    fn a_pin_does_not_replace_the_request_project() {
559        let _data = crate::core::data_dir::isolated_data_dir();
560        let (request, pinned) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
561        let file = request.path().join("lib.rs");
562        std::fs::write(&file, "fn main() {}\n").unwrap();
563        let _pin = crate::test_env::VarGuard::set("LEAN_CTX_PROJECT_ROOT", pinned.path());
564
565        let root = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
566            std::cell::RefCell::new(Some(request.path().to_path_buf())),
567            || detect_project_root_or_cwd(&file.to_string_lossy()),
568        );
569
570        assert_eq!(root, request.path().to_string_lossy());
571    }
572
573    #[test]
574    fn strip_trailing_savings_footer_handles_both_styles() {
575        // Box-drawing footer.
576        let boxed = "body line one\nbody line two\n\u{2500}\u{2500}\u{2500} 4,200 \u{2192} 840 tok (\u{2193}80%) \u{2500}\u{2500}\u{2500}";
577        assert_eq!(
578            strip_trailing_savings_footer(boxed),
579            "body line one\nbody line two"
580        );
581        // Verbatim-truncation footer.
582        let verbatim = "out\n[lean-ctx: 4200\u{2192}840 tok, verbatim truncated]";
583        assert_eq!(strip_trailing_savings_footer(verbatim), "out");
584        // No footer → untouched (including trailing newline).
585        assert_eq!(
586            strip_trailing_savings_footer("plain body\n"),
587            "plain body\n"
588        );
589        // A footer-only string collapses to empty.
590        assert_eq!(
591            strip_trailing_savings_footer("[lean-ctx: 10\u{2192}5 tok, verbatim truncated]"),
592            ""
593        );
594        // A body line that merely mentions the marker mid-text is preserved.
595        assert_eq!(
596            strip_trailing_savings_footer("see [lean-ctx: docs] for details"),
597            "see [lean-ctx: docs] for details"
598        );
599    }
600
601    #[test]
602    fn display_path_normalizes_windows_separators() {
603        // Issue #324: backslashes were dropped/escaped by client render layers.
604        assert_eq!(
605            display_path(r"C:\Users\zir\AppData\Local\Temp\win-build-log.txt"),
606            "C:/Users/zir/AppData/Local/Temp/win-build-log.txt"
607        );
608        assert_eq!(display_path("src/main.rs"), "src/main.rs");
609    }
610
611    #[test]
612    fn shorten_path_basename_for_windows_abs_path() {
613        assert_eq!(
614            shorten_path(r"D:\Temp\win-build-raw.log"),
615            "win-build-raw.log"
616        );
617        assert_eq!(shorten_path("a/b/c.txt"), "c.txt");
618    }
619
620    #[test]
621    fn shorten_path_relative_handles_windows_separators() {
622        // Relative display keeps forward slashes regardless of input style.
623        assert_eq!(
624            shorten_path_relative(r"C:\proj\src\app\main.rs", r"C:\proj"),
625            "src/app/main.rs"
626        );
627        // Mixed separators between path and root still relativize.
628        assert_eq!(
629            shorten_path_relative(r"C:\proj\src\main.rs", "C:/proj/"),
630            "src/main.rs"
631        );
632        // A non-prefix abs path falls back to a clean basename, never a
633        // separator-stripped blob like "CUserszir…".
634        assert_eq!(
635            shorten_path_relative(r"C:\Users\zir\Temp\build.log", r"D:\proj"),
636            "build.log"
637        );
638    }
639
640    #[test]
641    fn shorten_path_relative_requires_component_boundary() {
642        // Root "a/b" must not match sibling "a/bc".
643        assert_eq!(shorten_path_relative("a/bc/d.rs", "a/b"), "d.rs");
644        assert_eq!(shorten_path_relative("a/b/d.rs", "a/b"), "d.rs");
645    }
646
647    #[test]
648    fn is_project_root_marker_detects_git() {
649        let tmp = std::env::temp_dir().join("lean-ctx-test-root-marker");
650        let _ = std::fs::create_dir_all(&tmp);
651        let git_dir = tmp.join(".git");
652        let _ = std::fs::create_dir_all(&git_dir);
653        assert!(is_project_root_marker(&tmp));
654        let _ = std::fs::remove_dir_all(&tmp);
655    }
656
657    #[test]
658    fn is_project_root_marker_detects_cargo_toml() {
659        let tmp = std::env::temp_dir().join("lean-ctx-test-cargo-marker");
660        let _ = std::fs::create_dir_all(&tmp);
661        let _ = std::fs::write(tmp.join("Cargo.toml"), "[package]");
662        assert!(is_project_root_marker(&tmp));
663        let _ = std::fs::remove_dir_all(&tmp);
664    }
665
666    #[test]
667    fn detect_project_root_finds_outermost() {
668        let base = std::env::temp_dir().join("lean-ctx-test-monorepo");
669        let inner = base.join("packages").join("app");
670        let _ = std::fs::create_dir_all(&inner);
671        let _ = std::fs::create_dir_all(base.join(".git"));
672        let _ = std::fs::create_dir_all(inner.join(".git"));
673
674        let test_file = inner.join("main.rs");
675        let _ = std::fs::write(&test_file, "fn main() {}");
676
677        let root = detect_project_root(test_file.to_str().unwrap());
678        assert!(root.is_some(), "should find a project root for nested .git");
679        let root_path = std::path::PathBuf::from(root.unwrap());
680        assert_eq!(
681            crate::core::pathutil::safe_canonicalize(&root_path).ok(),
682            crate::core::pathutil::safe_canonicalize(&base).ok(),
683            "should return outermost .git, not inner"
684        );
685
686        let _ = std::fs::remove_dir_all(&base);
687    }
688
689    #[test]
690    fn stray_generic_marker_in_an_ancestor_is_not_the_project() {
691        let canon = |p: &std::path::Path| crate::core::pathutil::safe_canonicalize(p).ok();
692        let detect = |file: &std::path::Path| {
693            detect_project_root(file.to_str().unwrap()).map(std::path::PathBuf::from)
694        };
695        let base = tempfile::tempdir().unwrap();
696        std::fs::write(base.path().join("Makefile"), "all:\n").unwrap();
697
698        // A repository below the stray Makefile is its own project.
699        let repo = base.path().join("repo");
700        std::fs::create_dir_all(repo.join(".git")).unwrap();
701        std::fs::create_dir_all(repo.join("src")).unwrap();
702        std::fs::write(repo.join("src/main.rs"), "fn main() {}").unwrap();
703        let found = detect(&repo.join("src/main.rs")).unwrap();
704        assert_eq!(canon(&found), canon(&repo));
705
706        // Without a repository the innermost generic marker wins ...
707        let plain = base.path().join("plain");
708        std::fs::create_dir_all(&plain).unwrap();
709        std::fs::write(plain.join("package.json"), "{}").unwrap();
710        std::fs::write(plain.join("index.js"), "").unwrap();
711        let found = detect(&plain.join("index.js")).unwrap();
712        assert_eq!(canon(&found), canon(&plain));
713
714        // ... unless an ancestor declares a workspace.
715        let ws = base.path().join("ws");
716        let member = ws.join("crates").join("a");
717        std::fs::create_dir_all(&member).unwrap();
718        std::fs::write(
719            ws.join("Cargo.toml"),
720            "[workspace]\nmembers = [\"crates/*\"]\n",
721        )
722        .unwrap();
723        std::fs::write(member.join("Cargo.toml"), "[package]\nname = \"a\"\n").unwrap();
724        std::fs::write(member.join("lib.rs"), "").unwrap();
725        let found = detect(&member.join("lib.rs")).unwrap();
726        assert_eq!(canon(&found), canon(&ws));
727    }
728
729    #[test]
730    fn decoder_block_contains_all_codes() {
731        let block = instruction_decoder_block(true);
732        for t in TEMPLATES {
733            assert!(
734                block.contains(t.code),
735                "decoder should contain code {}",
736                t.code
737            );
738        }
739    }
740
741    #[test]
742    fn decoder_block_empty_outside_tdd() {
743        {
744            let found = &instruction_decoder_block(false);
745            assert!(found.is_empty(), "{found:?}");
746        }
747    }
748
749    #[test]
750    fn decoder_codes_match_what_encode_can_emit() {
751        // Every defined code must appear in at least one encode_instructions
752        // output — dead definitions tax every tdd session (#579).
753        let all_modes: Vec<String> = [
754            "mechanical",
755            "simple",
756            "standard",
757            "complex",
758            "architectural",
759            "unknown",
760        ]
761        .iter()
762        .map(|c| encode_instructions(c))
763        .collect();
764        for t in TEMPLATES {
765            assert!(
766                all_modes.iter().any(|m| m.contains(t.code)),
767                "code {} is defined but never emitted",
768                t.code
769            );
770        }
771    }
772
773    #[test]
774    fn encoded_instructions_are_compact() {
775        use super::super::tokens::count_tokens;
776        let full = "TASK COMPLEXITY: mechanical\nMinimal reasoning needed. Act immediately, report result in one line. Show only changed lines, not full files.";
777        let encoded = encode_instructions("mechanical");
778        assert!(
779            count_tokens(&encoded) <= count_tokens(full),
780            "encoded ({}) should be <= full ({})",
781            count_tokens(&encoded),
782            count_tokens(full)
783        );
784    }
785
786    #[test]
787    fn all_complexity_levels_encode() {
788        for level in &["mechanical", "standard", "architectural"] {
789            let encoded = encode_instructions(level);
790            assert!(encoded.starts_with("MODE:"), "should start with MODE:");
791        }
792    }
793
794    #[test]
795    fn savings_footer_env_gated_tests() {
796        let _lock = crate::core::data_dir::test_env_lock();
797
798        // Use full annotation mode for exact percentage checks
799        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
800        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "always");
801        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
802        crate::test_env::set_var("LEAN_CTX_COMPRESSION_ANNOTATION", "full");
803        crate::test_env::remove_var("LEAN_CTX_QUIET");
804
805        let s = super::format_savings(100, 50);
806        assert!(s.contains("\u{2192}"), "expected arrow: {s}");
807        assert!(s.contains("\u{2193}50%"), "expected pct: {s}");
808        assert!(
809            s.starts_with("\u{2500}\u{2500}\u{2500}"),
810            "expected box-drawing: {s}"
811        );
812
813        let s = super::format_savings_with_info(4200, 840, Some("map"), None);
814        assert!(s.contains("mode: map"), "expected mode: {s}");
815        assert!(s.contains("\u{2193}80%"), "expected 80%: {s}");
816
817        // Test: never mode suppresses
818        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
819        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
820        let s = super::format_savings(100, 50);
821        assert!(s.is_empty(), "expected empty with never: {s}");
822
823        let result = super::append_savings("hello", 100, 50);
824        assert_eq!(result, "hello");
825
826        // Test: MCP auto mode suppresses
827        super::MCP_CONTEXT.store(true, std::sync::atomic::Ordering::Relaxed);
828        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "auto");
829        crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
830        let s = super::format_savings(100, 50);
831        assert!(s.is_empty(), "expected empty in MCP+auto: {s}");
832        super::MCP_CONTEXT.store(false, std::sync::atomic::Ordering::Relaxed);
833
834        // Test: SHOW_SAVINGS overrides config
835        crate::test_env::set_var("LEAN_CTX_SAVINGS_FOOTER", "never");
836        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "1");
837        assert!(super::savings_footer_visible());
838        crate::test_env::set_var("LEAN_CTX_SHOW_SAVINGS", "0");
839        assert!(!super::savings_footer_visible());
840
841        crate::test_env::remove_var("LEAN_CTX_SHOW_SAVINGS");
842        crate::test_env::remove_var("LEAN_CTX_SAVINGS_FOOTER");
843        crate::test_env::remove_var("LEAN_CTX_COMPRESSION_ANNOTATION");
844    }
845
846    /// #1890: one precedence chain — env → forced level → CRP-implying
847    /// configured level → profile → off.
848    #[test]
849    fn crp_resolution_precedence() {
850        use crate::core::config::CompressionLevel as L;
851        let r = CrpMode::resolve_from;
852        // Default config (Lite) + default profile (no crp_mode) → off.
853        assert_eq!(r(None, None, L::Lite, None), CrpMode::Off);
854        // A profile's crp_mode is honoured when nothing overrides it.
855        assert_eq!(r(None, None, L::Lite, Some("tdd")), CrpMode::Tdd);
856        assert_eq!(r(None, None, L::Off, Some("compact")), CrpMode::Compact);
857        // A configured level that implies CRP beats the profile.
858        assert_eq!(r(None, None, L::Standard, Some("tdd")), CrpMode::Compact);
859        assert_eq!(r(None, None, L::Max, Some("off")), CrpMode::Tdd);
860        // A forced level (env / correction-loop degrade) beats the profile.
861        assert_eq!(r(None, Some(L::Lite), L::Max, Some("tdd")), CrpMode::Off);
862        // LEAN_CTX_CRP_MODE wins over everything; invalid values fail safe.
863        assert_eq!(
864            r(Some("tdd"), Some(L::Off), L::Off, Some("off")),
865            CrpMode::Tdd
866        );
867        assert_eq!(r(Some("bogus"), None, L::Max, Some("tdd")), CrpMode::Off);
868        assert_eq!(r(Some("  "), None, L::Lite, Some("tdd")), CrpMode::Tdd);
869        // Unknown profile values fall back to off.
870        assert_eq!(r(None, None, L::Lite, Some("weird")), CrpMode::Off);
871    }
872
873    #[test]
874    fn crp_mode_str_round_trips() {
875        for m in [CrpMode::Off, CrpMode::Compact, CrpMode::Tdd] {
876            assert_eq!(CrpMode::parse(m.as_str()), Some(m));
877        }
878    }
879}