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