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