Skip to main content

lean_ctx/cli/
read_cmd.rs

1use std::path::Path;
2
3use crate::core::compressor;
4use crate::core::deps as dep_extract;
5use crate::core::entropy;
6use crate::core::io_boundary;
7use crate::core::patterns::deps_cmd;
8use crate::core::protocol;
9use crate::core::roles;
10use crate::core::signatures;
11
12fn resolve_cli_path(raw: &str) -> String {
13    if let Ok(abs) = std::path::Path::new(raw).canonicalize() {
14        return abs.to_string_lossy().to_string();
15    }
16    if Path::new(raw).is_relative()
17        && let Ok(cwd) = std::env::current_dir()
18    {
19        return cwd.join(raw).to_string_lossy().into_owned();
20    }
21    raw.to_string()
22}
23use crate::core::tokens::count_tokens;
24
25use super::common::print_savings;
26
27/// #361 anti-inflation guarantee for the additive one-shot CLI path (the pi
28/// default an independent benchmark measured). Mirrors the MCP `cap_to_raw`
29/// invariant: a read must never cost more tokens than the raw file, so when the
30/// framing (`short [NL]` header, deps/API summary, savings footer) would push
31/// the payload past the bare content we ship the content verbatim. Empty files
32/// keep their framing so the reader still gets a signal.
33fn cap_cli_to_raw(framed: String, raw_content: &str, raw_tokens: usize) -> String {
34    if raw_tokens > 0 && count_tokens(&framed) > raw_tokens {
35        raw_content.to_string()
36    } else {
37        framed
38    }
39}
40
41/// Whether the read must bypass all caches and return verbatim content. True for
42/// explicit `--fresh`/`--no-cache` and for hook children (`hook_child`), whose
43/// `-m full` output is piped into a temp file the host reads back as the file's
44/// content and must never be a `cached … [NL]` stub (#1037).
45fn should_force_fresh(args: &[String], hook_child: bool) -> bool {
46    hook_child || args.iter().any(|a| a == "--fresh" || a == "--no-cache")
47}
48
49/// Resolve the read mode from CLI args. `--mode`/`-m` wins; otherwise the
50/// first positional after the path that parses as a known mode counts — a bare
51/// `lean-ctx read f.ps1 map` used to silently serve the `auto` default instead
52/// of the requested view (limitations audit 2026-07-03). Unknown positionals
53/// and flags are left alone so existing invocations keep their meaning.
54fn resolve_cli_read_mode(args: &[String]) -> &str {
55    if let Some(m) = args
56        .iter()
57        .position(|a| a == "--mode" || a == "-m")
58        .and_then(|i| args.get(i + 1))
59    {
60        return m.as_str();
61    }
62    args.iter()
63        .skip(1)
64        .find(|a| !a.starts_with('-') && a.parse::<crate::tools::ctx_read::ReadMode>().is_ok())
65        .map_or("auto", std::string::String::as_str)
66}
67
68pub fn cmd_read(args: &[String]) {
69    if args.is_empty() {
70        eprintln!(
71            "Usage: lean-ctx read <file> [--mode auto|full|map|signatures|aggressive|entropy] [--fresh]"
72        );
73        std::process::exit(1);
74    }
75
76    let raw_path = &args[0];
77    let path = if Path::new(raw_path).is_relative() {
78        std::env::current_dir().ok().map_or_else(
79            || raw_path.clone(),
80            |cwd| cwd.join(raw_path).to_string_lossy().into_owned(),
81        )
82    } else {
83        raw_path.clone()
84    };
85    let path = path.as_str();
86    let mode = resolve_cli_read_mode(args);
87    // #1037: redirect/rewrite hook children pipe `-m full` output into a temp file the
88    // host reads back AS the file's content, so a `cached <file> [NL]` cache-hit stub
89    // corrupts the read (and round-trips the stub into the real file on the next edit).
90    // The hook env (set by `mark_hook_environment`, inherited by the subprocess) forces
91    // verbatim content on BOTH the daemon (`fresh:true`) and standalone (skip cli_cache)
92    // paths. Direct CLI/MCP reads keep caching.
93    let force_fresh = should_force_fresh(args, crate::core::runtime_flags::hook_child_enabled());
94    // Whether *we* choose the mode (auto): only then do we cap framing to raw.
95    // An explicit mode is a deliberate view we return verbatim (#361).
96    let requested_auto = mode == "auto";
97
98    let short = protocol::shorten_path(path);
99
100    // Apply the same secret-path policy in CLI mode as in MCP tools.
101    // Default is warn; enforce depends on active role/policy.
102    if let Ok(abs) = std::fs::canonicalize(path) {
103        match io_boundary::check_secret_path_for_tool("cli_read", &abs) {
104            Ok(Some(w)) => eprintln!("{w}"),
105            Ok(None) => {}
106            Err(e) => {
107                eprintln!("{e}");
108                std::process::exit(1);
109            }
110        }
111    } else {
112        // Best-effort: still check the raw path string.
113        let raw = std::path::Path::new(path);
114        match io_boundary::check_secret_path_for_tool("cli_read", raw) {
115            Ok(Some(w)) => eprintln!("{w}"),
116            Ok(None) => {}
117            Err(e) => {
118                eprintln!("{e}");
119                std::process::exit(1);
120            }
121        }
122    }
123
124    #[cfg(unix)]
125    {
126        #[cfg(unix)]
127        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
128            "ctx_read",
129            Some(serde_json::json!({
130                "path": path,
131                "mode": mode,
132                "fresh": force_fresh,
133            })),
134        ) {
135            let filtered = super::common::filter_daemon_output(&out);
136            if !filtered.trim().is_empty() {
137                println!("{filtered}");
138                return;
139            }
140        }
141    }
142    super::common::daemon_fallback_hint();
143
144    // Read latency for the Context IR lineage (#566) — the standalone path only;
145    // the daemon branch above records its own IR and returns before this.
146    let read_start = std::time::Instant::now();
147
148    if !force_fresh && mode == "full" {
149        use crate::core::cli_cache::{self, CacheResult};
150        match cli_cache::check_and_read(path) {
151            CacheResult::Hit { entry, file_ref } => {
152                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
153                println!("{msg}");
154                let sent = count_tokens(&msg);
155                super::common::cli_track_read_cached(
156                    path,
157                    "full",
158                    entry.original_tokens,
159                    sent,
160                    &msg,
161                    read_start.elapsed(),
162                );
163                return;
164            }
165            CacheResult::Miss { content } if content.is_empty() => {
166                eprintln!("Error: could not read {path}");
167                std::process::exit(1);
168            }
169            CacheResult::Miss { content } => {
170                let line_count = content.lines().count();
171                let raw_tokens = count_tokens(&content);
172                let framed = format!("{short} [{line_count}L]\n{content}");
173                let output = cap_cli_to_raw(framed, &content, raw_tokens);
174                println!("{output}");
175                let sent = count_tokens(&output);
176                super::common::cli_track_read(
177                    path,
178                    "full",
179                    raw_tokens,
180                    sent,
181                    &output,
182                    read_start.elapsed(),
183                );
184                return;
185            }
186        }
187    }
188
189    let content = match crate::tools::ctx_read::read_file_lossy(path) {
190        Ok(c) => c,
191        Err(e) => {
192            eprintln!("Error: {e}");
193            std::process::exit(1);
194        }
195    };
196
197    let ext = Path::new(path)
198        .extension()
199        .and_then(|e| e.to_str())
200        .unwrap_or("");
201    let line_count = content.lines().count();
202    let original_tokens = count_tokens(&content);
203
204    let mode = if mode == "auto" {
205        // Unified resolver — the single source of truth shared with the MCP
206        // path. The old CLI-local predictor lacked the small-file / config /
207        // instruction guards, so auto could pick a compressing mode that
208        // inflated a tiny file. Routing through `resolve` fixes that at the
209        // source (#361).
210        crate::core::auto_mode_resolver::resolve(
211            &crate::core::auto_mode_resolver::AutoModeContext {
212                path,
213                token_count: original_tokens,
214                line_count: None,
215                task: None,
216                cache: None,
217            },
218        )
219        .mode
220    } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
221        "full".to_string()
222    } else {
223        mode.to_string()
224    };
225    let mode = if (mode == "map" || mode == "signatures") && original_tokens <= 400 {
226        "full".to_string()
227    } else {
228        mode
229    };
230    let mode = mode.as_str();
231
232    match mode {
233        "map" => {
234            let structured = match ext {
235                "md" | "mdx" | "rst" => {
236                    crate::core::structured_read::extract_markdown_outline(&content)
237                }
238                "json" => crate::core::structured_read::extract_json_structure(&content),
239                "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
240                "toml" => crate::core::structured_read::extract_toml_structure(&content),
241                _ if path.to_lowercase().ends_with(".lock")
242                    || path.to_lowercase().ends_with("go.sum") =>
243                {
244                    crate::core::structured_read::extract_lock_summary(&content, path)
245                }
246                _ => String::new(),
247            };
248
249            let mut output_buf = if structured.is_empty() {
250                let sigs = signatures::extract_signatures(&content, ext);
251                let dep_info = dep_extract::extract_deps(&content, ext);
252                let mut buf = format!("{short} [{line_count}L]");
253                if !dep_info.imports.is_empty() {
254                    buf.push_str(&format!("\n  deps: {}", dep_info.imports.join(", ")));
255                }
256                let key_sigs: Vec<&signatures::Signature> = sigs
257                    .iter()
258                    .filter(|s| s.is_exported || s.indent == 0)
259                    .collect();
260                // Drop exports the API section already lists (same symbol in a
261                // fuller form) so map drops the duplicate names — mirrors the
262                // MCP map renderer in ctx_read::render (#361).
263                let extra_exports =
264                    signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
265                if !extra_exports.is_empty() {
266                    buf.push_str(&format!("\n  exports: {}", extra_exports.join(", ")));
267                }
268                if !key_sigs.is_empty() {
269                    buf.push_str("\n  API:");
270                    for sig in &key_sigs {
271                        buf.push_str(&format!("\n    {}", sig.to_compact_located()));
272                    }
273                }
274                // Same honesty rule as the MCP renderer: an information-free
275                // map must say so (limitations audit, #4).
276                if key_sigs.is_empty() && dep_info.imports.is_empty() && extra_exports.is_empty() {
277                    buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
278                }
279                buf
280            } else {
281                format!("{short} [{line_count}L]\n{structured}")
282            };
283
284            let sent = count_tokens(&output_buf);
285            output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
286            if requested_auto {
287                output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
288            }
289            let sent = count_tokens(&output_buf);
290            println!("{output_buf}");
291            super::common::cli_track_read(
292                path,
293                "map",
294                original_tokens,
295                sent,
296                &output_buf,
297                read_start.elapsed(),
298            );
299        }
300        "signatures" => {
301            let sigs = signatures::extract_signatures(&content, ext);
302            let mut output_buf = format!("{short} [{line_count}L]");
303            for sig in &sigs {
304                output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
305            }
306            // Same honesty rule as the MCP renderer (limitations audit, #4).
307            if sigs.is_empty() {
308                output_buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
309            }
310            if requested_auto {
311                output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
312            }
313            println!("{output_buf}");
314            let sent = count_tokens(&output_buf);
315            print_savings(original_tokens, sent);
316            super::common::cli_track_read(
317                path,
318                "signatures",
319                original_tokens,
320                sent,
321                &output_buf,
322                read_start.elapsed(),
323            );
324        }
325        "aggressive" => {
326            let compressed = compressor::aggressive_compress(&content, Some(ext));
327            println!("{short} [{line_count}L]");
328            println!("{compressed}");
329            let sent = count_tokens(&compressed);
330            print_savings(original_tokens, sent);
331            super::common::cli_track_read(
332                path,
333                "aggressive",
334                original_tokens,
335                sent,
336                &compressed,
337                read_start.elapsed(),
338            );
339        }
340        "entropy" => {
341            let result = entropy::entropy_compress(&content);
342            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
343            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
344            for tech in &result.techniques {
345                println!("{tech}");
346            }
347            println!("{}", result.output);
348            let sent = count_tokens(&result.output);
349            print_savings(original_tokens, sent);
350            super::common::cli_track_read(
351                path,
352                "entropy",
353                original_tokens,
354                sent,
355                &result.output,
356                read_start.elapsed(),
357            );
358        }
359        m if m.starts_with("lines:") => {
360            // The CLI used to drop the window and print the whole file — a
361            // `lines:` read must return the requested selection, with the same
362            // comma-multi-select hint as the MCP renderer (limitations #7).
363            let range_str = &m[6..];
364            let extracted = crate::tools::ctx_read::extract_line_range(&content, range_str);
365            let multi_hint = if range_str.contains(',') {
366                crate::tools::ctx_read::LINES_COMMA_HINT
367            } else {
368                ""
369            };
370            let output =
371                format!("{short} [{line_count}L] lines:{range_str}\n{extracted}{multi_hint}");
372            println!("{output}");
373            let sent = count_tokens(&output);
374            print_savings(original_tokens, sent);
375            super::common::cli_track_read(
376                path,
377                "lines",
378                original_tokens,
379                sent,
380                &output,
381                read_start.elapsed(),
382            );
383        }
384        _ => {
385            // `full` and any unrecognized mode land here. These are
386            // verbatim reads — the prose terse pipeline would mangle source
387            // (dictionary substitutions, line-drop dedup) and break a `full`
388            // read's "complete content" contract, so it must never run here
389            // (#404). Intentionally-lossy modes (map/signatures/aggressive/
390            // entropy/lines) have their own arms above.
391            let mut output = format!("{short} [{line_count}L]\n{content}");
392            if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
393                let config = crate::core::config::Config::load();
394                let level = crate::core::config::CompressionLevel::effective(&config);
395                if level.is_active() {
396                    let terse_result =
397                        crate::core::terse::pipeline::compress(&output, &level, None);
398                    if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
399                        output = terse_result.output;
400                    }
401                }
402            }
403            // Full/verbatim reads never beat raw via framing — if terse didn't
404            // compress below the bare file, ship the file itself (#361).
405            let output = cap_cli_to_raw(output, &content, original_tokens);
406            println!("{output}");
407            let sent = count_tokens(&output);
408            super::common::cli_track_read(
409                path,
410                "full",
411                original_tokens,
412                sent,
413                &output,
414                read_start.elapsed(),
415            );
416        }
417    }
418}
419
420pub fn cmd_diff(args: &[String]) {
421    if args.len() < 2 {
422        eprintln!("Usage: lean-ctx diff <file1> <file2>");
423        std::process::exit(1);
424    }
425
426    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
427        Ok(c) => c,
428        Err(e) => {
429            eprintln!("Error reading {}: {e}", args[0]);
430            std::process::exit(1);
431        }
432    };
433
434    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
435        Ok(c) => c,
436        Err(e) => {
437            eprintln!("Error reading {}: {e}", args[1]);
438            std::process::exit(1);
439        }
440    };
441
442    let diff = compressor::diff_content(&content1, &content2);
443    let original = count_tokens(&content1) + count_tokens(&content2);
444    let sent = count_tokens(&diff);
445
446    println!(
447        "diff {} {}",
448        protocol::shorten_path(&args[0]),
449        protocol::shorten_path(&args[1])
450    );
451    println!("{diff}");
452    print_savings(original, sent);
453    crate::core::stats::record("cli_diff", original, sent);
454}
455
456pub fn cmd_grep(args: &[String]) {
457    if args.is_empty() {
458        eprintln!("Usage: lean-ctx grep <pattern> [path]");
459        std::process::exit(1);
460    }
461
462    let pattern = &args[0];
463    let raw_path = args.get(1).map_or(".", std::string::String::as_str);
464    let abs_path = resolve_cli_path(raw_path);
465    let path = abs_path.as_str();
466
467    #[cfg(unix)]
468    {
469        #[cfg(unix)]
470        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
471            "ctx_search",
472            Some(serde_json::json!({
473                "pattern": pattern,
474                "path": path,
475            })),
476        ) {
477            let out = super::common::filter_daemon_output(&out);
478            println!("{out}");
479            if out.trim_start().starts_with("0 matches") {
480                std::process::exit(1);
481            }
482            return;
483        }
484    }
485    super::common::daemon_fallback_hint();
486
487    // Search latency for the Context IR lineage (#566), standalone path only.
488    let search_start = std::time::Instant::now();
489
490    let outcome = crate::tools::ctx_search::handle(
491        pattern,
492        path,
493        None,
494        20,
495        crate::tools::CrpMode::effective(),
496        true,
497        roles::active_role().io.allow_secret_paths,
498        false,
499    );
500    let out = outcome.text;
501    println!("{out}");
502    super::common::cli_track_search(
503        outcome.modeled_baseline,
504        outcome.observed_tokens,
505        count_tokens(&out),
506        pattern,
507        path,
508        &out,
509        search_start.elapsed(),
510    );
511    if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
512        std::process::exit(1);
513    }
514}
515
516/// `lean-ctx glob <pattern> [path]` — find files by glob pattern, shares the
517/// exact `ctx_glob` core so the CLI, the MCP tool, and the shadow-mode redirect
518/// (#556) all return identical results. Prefers the daemon (warms its cache),
519/// falling back to an in-process call.
520pub fn cmd_glob(args: &[String]) {
521    if args.is_empty() {
522        eprintln!("Usage: lean-ctx glob <pattern> [path]");
523        std::process::exit(1);
524    }
525
526    let pattern = &args[0];
527    let raw_path = args.get(1).map_or(".", std::string::String::as_str);
528    let abs_path = resolve_cli_path(raw_path);
529    let path = abs_path.as_str();
530
531    #[cfg(unix)]
532    if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
533        "ctx_glob",
534        Some(serde_json::json!({
535            "pattern": pattern,
536            "path": path,
537        })),
538    ) {
539        let out = super::common::filter_daemon_output(&out);
540        println!("{out}");
541        return;
542    }
543    super::common::daemon_fallback_hint();
544
545    let (out, _original) = crate::tools::ctx_glob::handle(
546        pattern,
547        path,
548        true,
549        roles::active_role().io.allow_secret_paths,
550        200,
551    );
552    println!("{out}");
553    crate::core::stats::record("cli_glob", 0, 0);
554    if out.starts_with("ERROR:") {
555        std::process::exit(1);
556    }
557}
558
559pub fn cmd_find(args: &[String]) {
560    if args.is_empty() {
561        eprintln!("Usage: lean-ctx find <pattern> [path]");
562        std::process::exit(1);
563    }
564
565    let raw_pattern = &args[0];
566    let path = args.get(1).map_or(".", std::string::String::as_str);
567
568    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
569    let glob_matcher = if is_glob {
570        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
571    } else {
572        None
573    };
574    let substring = raw_pattern.to_lowercase();
575
576    let mut found = false;
577    let walk_root = crate::core::walk_filter::explicit_walk_root(std::path::Path::new(path));
578    for entry in ignore::WalkBuilder::new(&walk_root)
579        .hidden(true)
580        .git_ignore(true)
581        .git_global(true)
582        .git_exclude(true)
583        .require_git(false)
584        .max_depth(Some(10))
585        .filter_entry(crate::core::walk_filter::keep_entry)
586        .build()
587        .flatten()
588    {
589        let name = entry.file_name().to_string_lossy().to_lowercase();
590        let matches = if let Some(ref g) = glob_matcher {
591            g.matches(&name)
592        } else {
593            name.contains(&substring)
594        };
595        if matches {
596            println!("{}", entry.path().display());
597            found = true;
598        }
599    }
600
601    crate::core::stats::record("cli_find", 0, 0);
602
603    if !found {
604        std::process::exit(1);
605    }
606}
607
608pub fn cmd_ls(args: &[String]) {
609    let mut raw_path = ".";
610    let mut depth = 3usize;
611    let mut show_hidden = false;
612    let mut respect_gitignore = true;
613    let mut i = 0;
614
615    while i < args.len() {
616        let arg = &args[i];
617        if arg == "--depth" {
618            i += 1;
619            if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
620                depth = d.min(10);
621            }
622        } else if arg == "--all" || arg == "-a" {
623            show_hidden = true;
624        } else if arg == "--no-gitignore" {
625            respect_gitignore = false;
626        } else if arg.starts_with('-') {
627            eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
628            eprintln!(
629                "lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement."
630            );
631            eprintln!(
632                "The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n"
633            );
634            eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
635            std::process::exit(1);
636        } else {
637            raw_path = arg;
638        }
639        i += 1;
640    }
641
642    let abs_path = resolve_cli_path(raw_path);
643    let path = abs_path.as_str();
644
645    #[cfg(unix)]
646    {
647        #[cfg(unix)]
648        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
649            "ctx_tree",
650            Some(serde_json::json!({
651                "path": path,
652                "depth": depth,
653                "show_hidden": show_hidden,
654                "respect_gitignore": respect_gitignore,
655            })),
656        ) {
657            println!("{}", super::common::filter_daemon_output(&out));
658            return;
659        }
660    }
661    super::common::daemon_fallback_hint();
662
663    let (out, original) =
664        crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
665    println!("{out}");
666    super::common::cli_track_tree(original, count_tokens(&out));
667}
668
669pub fn cmd_deps(args: &[String]) {
670    let path = args.first().map_or(".", std::string::String::as_str);
671
672    if let Some(result) = deps_cmd::detect_and_compress(path) {
673        println!("{result}");
674        crate::core::stats::record("cli_deps", 0, 0);
675    } else {
676        eprintln!("No dependency file found in {path}");
677        std::process::exit(1);
678    }
679}
680
681#[cfg(test)]
682mod cap_tests {
683    use super::{cap_cli_to_raw, count_tokens};
684
685    #[test]
686    fn caps_to_raw_when_framing_inflates() {
687        // A tiny file: the `path [NL]` header (+ any footer) pushes the framed
688        // payload past the bare content, so the cap must ship the content
689        // verbatim — the additive CLI default must never inflate a read (#361).
690        let raw = "x = 1\n";
691        let raw_tokens = count_tokens(raw);
692        let framed = format!("some/very/long/path/header.rs [1L]\n{raw}\n[lean-ctx: 0 tok saved]");
693        assert!(count_tokens(&framed) > raw_tokens, "fixture must inflate");
694        assert_eq!(cap_cli_to_raw(framed, raw, raw_tokens), raw);
695    }
696
697    #[test]
698    fn keeps_framing_when_it_saves() {
699        // A genuinely compressed payload (fewer tokens than raw) is kept as-is.
700        let raw = "fn a() {}\n".repeat(300);
701        let raw_tokens = count_tokens(&raw);
702        let framed = "f.rs [300L]\nfn a() {} …".to_string();
703        assert!(count_tokens(&framed) < raw_tokens);
704        assert_eq!(cap_cli_to_raw(framed.clone(), &raw, raw_tokens), framed);
705    }
706
707    #[test]
708    fn keeps_framing_for_empty_file() {
709        // raw_tokens == 0 disables the cap so an empty file still gets a signal.
710        let framed = "empty.rs [0L]\n".to_string();
711        assert_eq!(cap_cli_to_raw(framed.clone(), "", 0), framed);
712    }
713
714    #[test]
715    fn break_even_is_not_inflation() {
716        // Equal token counts use strict `>`, so framing is preserved at break-even.
717        let raw = "alpha beta gamma delta";
718        let raw_tokens = count_tokens(raw);
719        let framed = raw.to_string();
720        assert_eq!(count_tokens(&framed), raw_tokens);
721        assert_eq!(cap_cli_to_raw(framed.clone(), raw, raw_tokens), framed);
722    }
723
724    #[test]
725    fn emitted_never_exceeds_raw_across_sizes() {
726        // The invariant itself: for any bloated framing over a non-empty file the
727        // emitted token count is ≤ the raw token count.
728        for n in [1usize, 5, 50, 500] {
729            let raw = "data line here\n".repeat(n);
730            let raw_tokens = count_tokens(&raw);
731            let framed = format!("a/b/c/path.txt [{n}L]\n{raw}\n[lean-ctx: {n} tok saved ({n}%)]");
732            let out = cap_cli_to_raw(framed, &raw, raw_tokens);
733            assert!(
734                count_tokens(&out) <= raw_tokens,
735                "n={n}: emitted {} tok exceeds raw {raw_tokens}",
736                count_tokens(&out)
737            );
738        }
739    }
740}
741
742#[cfg(test)]
743mod fresh_tests {
744    use super::should_force_fresh;
745
746    #[test]
747    fn hook_child_forces_fresh_even_without_flags() {
748        // #1037: a hook child must always read verbatim (no `cached … [NL]` stub),
749        // even when the caller passed no `--fresh`/`--no-cache` flag.
750        assert!(should_force_fresh(&[], true));
751        assert!(!should_force_fresh(&[], false));
752    }
753
754    #[test]
755    fn explicit_flags_force_fresh() {
756        assert!(should_force_fresh(&["--fresh".to_string()], false));
757        assert!(should_force_fresh(&["--no-cache".to_string()], false));
758        assert!(!should_force_fresh(&["file.rs".to_string()], false));
759    }
760}
761
762#[cfg(test)]
763mod mode_arg_tests {
764    use super::resolve_cli_read_mode;
765
766    fn args(list: &[&str]) -> Vec<String> {
767        list.iter().map(ToString::to_string).collect()
768    }
769
770    // `lean-ctx read f.ps1 map` used to silently IGNORE the positional mode and
771    // serve the `auto` default — reads must honour it like `--mode map`
772    // (limitations audit 2026-07-03).
773    #[test]
774    fn positional_mode_is_honoured() {
775        assert_eq!(resolve_cli_read_mode(&args(&["f.ps1", "map"])), "map");
776        assert_eq!(
777            resolve_cli_read_mode(&args(&["f.rs", "lines:5-10"])),
778            "lines:5-10"
779        );
780    }
781
782    #[test]
783    fn mode_flag_wins_over_positional() {
784        assert_eq!(
785            resolve_cli_read_mode(&args(&["f.rs", "map", "--mode", "signatures"])),
786            "signatures"
787        );
788        assert_eq!(
789            resolve_cli_read_mode(&args(&["f.rs", "-m", "full"])),
790            "full"
791        );
792    }
793
794    #[test]
795    fn defaults_to_auto_and_skips_flags_and_junk() {
796        assert_eq!(resolve_cli_read_mode(&args(&["f.rs"])), "auto");
797        assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "--fresh"])), "auto");
798        // An unknown positional is not silently treated as a mode.
799        assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "banana"])), "auto");
800    }
801}