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