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    if !force_fresh && mode == "full" {
116        use crate::core::cli_cache::{self, CacheResult};
117        match cli_cache::check_and_read(path) {
118            CacheResult::Hit { entry, file_ref } => {
119                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
120                println!("{msg}");
121                let sent = count_tokens(&msg);
122                super::common::cli_track_read_cached(path, "full", entry.original_tokens, sent);
123                return;
124            }
125            CacheResult::Miss { content } if content.is_empty() => {
126                eprintln!("Error: could not read {path}");
127                std::process::exit(1);
128            }
129            CacheResult::Miss { content } => {
130                let line_count = content.lines().count();
131                let raw_tokens = count_tokens(&content);
132                let framed = format!("{short} [{line_count}L]\n{content}");
133                let output = cap_cli_to_raw(framed, &content, raw_tokens);
134                println!("{output}");
135                let sent = count_tokens(&output);
136                super::common::cli_track_read(path, "full", raw_tokens, sent);
137                return;
138            }
139        }
140    }
141
142    let content = match crate::tools::ctx_read::read_file_lossy(path) {
143        Ok(c) => c,
144        Err(e) => {
145            eprintln!("Error: {e}");
146            std::process::exit(1);
147        }
148    };
149
150    let ext = Path::new(path)
151        .extension()
152        .and_then(|e| e.to_str())
153        .unwrap_or("");
154    let line_count = content.lines().count();
155    let original_tokens = count_tokens(&content);
156
157    let mode = if mode == "auto" {
158        // Unified resolver — the single source of truth shared with the MCP
159        // path. The old CLI-local predictor lacked the small-file / config /
160        // instruction guards, so auto could pick a compressing mode that
161        // inflated a tiny file. Routing through `resolve` fixes that at the
162        // source (#361).
163        crate::core::auto_mode_resolver::resolve(
164            &crate::core::auto_mode_resolver::AutoModeContext {
165                path,
166                token_count: original_tokens,
167                task: None,
168                cache: None,
169            },
170        )
171        .mode
172    } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
173        "full".to_string()
174    } else {
175        mode.to_string()
176    };
177    let mode = mode.as_str();
178
179    match mode {
180        "map" => {
181            let structured = match ext {
182                "md" | "mdx" | "rst" => {
183                    crate::core::structured_read::extract_markdown_outline(&content)
184                }
185                "json" => crate::core::structured_read::extract_json_structure(&content),
186                "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
187                "toml" => crate::core::structured_read::extract_toml_structure(&content),
188                _ if path.to_lowercase().ends_with(".lock")
189                    || path.to_lowercase().ends_with("go.sum") =>
190                {
191                    crate::core::structured_read::extract_lock_summary(&content, path)
192                }
193                _ => String::new(),
194            };
195
196            let mut output_buf = if structured.is_empty() {
197                let sigs = signatures::extract_signatures(&content, ext);
198                let dep_info = dep_extract::extract_deps(&content, ext);
199                let mut buf = format!("{short} [{line_count}L]");
200                if !dep_info.imports.is_empty() {
201                    buf.push_str(&format!("\n  deps: {}", dep_info.imports.join(", ")));
202                }
203                let key_sigs: Vec<&signatures::Signature> = sigs
204                    .iter()
205                    .filter(|s| s.is_exported || s.indent == 0)
206                    .collect();
207                // Drop exports the API section already lists (same symbol in a
208                // fuller form) so map drops the duplicate names — mirrors the
209                // MCP map renderer in ctx_read::render (#361).
210                let extra_exports =
211                    signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
212                if !extra_exports.is_empty() {
213                    buf.push_str(&format!("\n  exports: {}", extra_exports.join(", ")));
214                }
215                if !key_sigs.is_empty() {
216                    buf.push_str("\n  API:");
217                    for sig in &key_sigs {
218                        buf.push_str(&format!("\n    {}", sig.to_compact_located()));
219                    }
220                }
221                buf
222            } else {
223                format!("{short} [{line_count}L]\n{structured}")
224            };
225
226            let sent = count_tokens(&output_buf);
227            output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
228            if requested_auto {
229                output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
230            }
231            let sent = count_tokens(&output_buf);
232            println!("{output_buf}");
233            super::common::cli_track_read(path, "map", original_tokens, sent);
234        }
235        "signatures" => {
236            let sigs = signatures::extract_signatures(&content, ext);
237            let mut output_buf = format!("{short} [{line_count}L]");
238            for sig in &sigs {
239                output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
240            }
241            if requested_auto {
242                output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
243            }
244            println!("{output_buf}");
245            let sent = count_tokens(&output_buf);
246            print_savings(original_tokens, sent);
247            super::common::cli_track_read(path, "signatures", original_tokens, sent);
248        }
249        "aggressive" => {
250            let compressed = compressor::aggressive_compress(&content, Some(ext));
251            println!("{short} [{line_count}L]");
252            println!("{compressed}");
253            let sent = count_tokens(&compressed);
254            print_savings(original_tokens, sent);
255            super::common::cli_track_read(path, "aggressive", original_tokens, sent);
256        }
257        "entropy" => {
258            let result = entropy::entropy_compress(&content);
259            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
260            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
261            for tech in &result.techniques {
262                println!("{tech}");
263            }
264            println!("{}", result.output);
265            let sent = count_tokens(&result.output);
266            print_savings(original_tokens, sent);
267            super::common::cli_track_read(path, "entropy", original_tokens, sent);
268        }
269        _ => {
270            // `full`, `lines:` and any unrecognized mode land here. These are
271            // verbatim reads — the prose terse pipeline would mangle source
272            // (dictionary substitutions, line-drop dedup) and break a `full`
273            // read's "complete content" contract, so it must never run here
274            // (#404). Intentionally-lossy modes (map/signatures/aggressive/
275            // entropy) have their own arms above.
276            let mut output = format!("{short} [{line_count}L]\n{content}");
277            if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
278                let config = crate::core::config::Config::load();
279                let level = crate::core::config::CompressionLevel::effective(&config);
280                if level.is_active() {
281                    let terse_result =
282                        crate::core::terse::pipeline::compress(&output, &level, None);
283                    if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
284                        output = terse_result.output;
285                    }
286                }
287            }
288            // Full/verbatim reads never beat raw via framing — if terse didn't
289            // compress below the bare file, ship the file itself (#361).
290            let output = cap_cli_to_raw(output, &content, original_tokens);
291            println!("{output}");
292            let sent = count_tokens(&output);
293            super::common::cli_track_read(path, "full", original_tokens, sent);
294        }
295    }
296}
297
298pub fn cmd_diff(args: &[String]) {
299    if args.len() < 2 {
300        eprintln!("Usage: lean-ctx diff <file1> <file2>");
301        std::process::exit(1);
302    }
303
304    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
305        Ok(c) => c,
306        Err(e) => {
307            eprintln!("Error reading {}: {e}", args[0]);
308            std::process::exit(1);
309        }
310    };
311
312    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
313        Ok(c) => c,
314        Err(e) => {
315            eprintln!("Error reading {}: {e}", args[1]);
316            std::process::exit(1);
317        }
318    };
319
320    let diff = compressor::diff_content(&content1, &content2);
321    let original = count_tokens(&content1) + count_tokens(&content2);
322    let sent = count_tokens(&diff);
323
324    println!(
325        "diff {} {}",
326        protocol::shorten_path(&args[0]),
327        protocol::shorten_path(&args[1])
328    );
329    println!("{diff}");
330    print_savings(original, sent);
331    crate::core::stats::record("cli_diff", original, sent);
332}
333
334pub fn cmd_grep(args: &[String]) {
335    if args.is_empty() {
336        eprintln!("Usage: lean-ctx grep <pattern> [path]");
337        std::process::exit(1);
338    }
339
340    let pattern = &args[0];
341    let raw_path = args.get(1).map_or(".", std::string::String::as_str);
342    let abs_path = resolve_cli_path(raw_path);
343    let path = abs_path.as_str();
344
345    #[cfg(unix)]
346    {
347        #[cfg(unix)]
348        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
349            "ctx_search",
350            Some(serde_json::json!({
351                "pattern": pattern,
352                "path": path,
353            })),
354        ) {
355            let out = super::common::filter_daemon_output(&out);
356            println!("{out}");
357            if out.trim_start().starts_with("0 matches") {
358                std::process::exit(1);
359            }
360            return;
361        }
362    }
363    super::common::daemon_fallback_hint();
364
365    let outcome = crate::tools::ctx_search::handle(
366        pattern,
367        path,
368        None,
369        20,
370        crate::tools::CrpMode::effective(),
371        true,
372        roles::active_role().io.allow_secret_paths,
373    );
374    let out = outcome.text;
375    println!("{out}");
376    super::common::cli_track_search(
377        outcome.modeled_baseline,
378        outcome.observed_tokens,
379        count_tokens(&out),
380    );
381    if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
382        std::process::exit(1);
383    }
384}
385
386pub fn cmd_find(args: &[String]) {
387    if args.is_empty() {
388        eprintln!("Usage: lean-ctx find <pattern> [path]");
389        std::process::exit(1);
390    }
391
392    let raw_pattern = &args[0];
393    let path = args.get(1).map_or(".", std::string::String::as_str);
394
395    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
396    let glob_matcher = if is_glob {
397        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
398    } else {
399        None
400    };
401    let substring = raw_pattern.to_lowercase();
402
403    let mut found = false;
404    for entry in ignore::WalkBuilder::new(path)
405        .hidden(true)
406        .git_ignore(true)
407        .git_global(true)
408        .git_exclude(true)
409        .require_git(false)
410        .max_depth(Some(10))
411        .filter_entry(crate::core::walk_filter::keep_entry)
412        .build()
413        .flatten()
414    {
415        let name = entry.file_name().to_string_lossy().to_lowercase();
416        let matches = if let Some(ref g) = glob_matcher {
417            g.matches(&name)
418        } else {
419            name.contains(&substring)
420        };
421        if matches {
422            println!("{}", entry.path().display());
423            found = true;
424        }
425    }
426
427    crate::core::stats::record("cli_find", 0, 0);
428
429    if !found {
430        std::process::exit(1);
431    }
432}
433
434pub fn cmd_ls(args: &[String]) {
435    let mut raw_path = ".";
436    let mut depth = 3usize;
437    let mut show_hidden = false;
438    let mut respect_gitignore = true;
439    let mut i = 0;
440
441    while i < args.len() {
442        let arg = &args[i];
443        if arg == "--depth" {
444            i += 1;
445            if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
446                depth = d.min(10);
447            }
448        } else if arg == "--all" || arg == "-a" {
449            show_hidden = true;
450        } else if arg == "--no-gitignore" {
451            respect_gitignore = false;
452        } else if arg.starts_with('-') {
453            eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
454            eprintln!(
455                "lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement."
456            );
457            eprintln!(
458                "The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n"
459            );
460            eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
461            std::process::exit(1);
462        } else {
463            raw_path = arg;
464        }
465        i += 1;
466    }
467
468    let abs_path = resolve_cli_path(raw_path);
469    let path = abs_path.as_str();
470
471    #[cfg(unix)]
472    {
473        #[cfg(unix)]
474        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
475            "ctx_tree",
476            Some(serde_json::json!({
477                "path": path,
478                "depth": depth,
479                "show_hidden": show_hidden,
480                "respect_gitignore": respect_gitignore,
481            })),
482        ) {
483            println!("{}", super::common::filter_daemon_output(&out));
484            return;
485        }
486    }
487    super::common::daemon_fallback_hint();
488
489    let (out, _original) =
490        crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
491    println!("{out}");
492    super::common::cli_track_tree(0, count_tokens(&out));
493}
494
495pub fn cmd_deps(args: &[String]) {
496    let path = args.first().map_or(".", std::string::String::as_str);
497
498    if let Some(result) = deps_cmd::detect_and_compress(path) {
499        println!("{result}");
500        crate::core::stats::record("cli_deps", 0, 0);
501    } else {
502        eprintln!("No dependency file found in {path}");
503        std::process::exit(1);
504    }
505}