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        if let Ok(cwd) = std::env::current_dir() {
18            return cwd.join(raw).to_string_lossy().into_owned();
19        }
20    }
21    raw.to_string()
22}
23use crate::core::tokens::count_tokens;
24
25use super::common::print_savings;
26
27pub fn cmd_read(args: &[String]) {
28    if args.is_empty() {
29        eprintln!(
30            "Usage: lean-ctx read <file> [--mode auto|full|map|signatures|aggressive|entropy] [--fresh]"
31        );
32        std::process::exit(1);
33    }
34
35    let raw_path = &args[0];
36    let path = if Path::new(raw_path).is_relative() {
37        std::env::current_dir().ok().map_or_else(
38            || raw_path.clone(),
39            |cwd| cwd.join(raw_path).to_string_lossy().into_owned(),
40        )
41    } else {
42        raw_path.clone()
43    };
44    let path = path.as_str();
45    let mode = args
46        .iter()
47        .position(|a| a == "--mode" || a == "-m")
48        .and_then(|i| args.get(i + 1))
49        .map_or("auto", std::string::String::as_str);
50    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
51
52    let short = protocol::shorten_path(path);
53
54    // Apply the same secret-path policy in CLI mode as in MCP tools.
55    // Default is warn; enforce depends on active role/policy.
56    if let Ok(abs) = std::fs::canonicalize(path) {
57        match io_boundary::check_secret_path_for_tool("cli_read", &abs) {
58            Ok(Some(w)) => eprintln!("{w}"),
59            Ok(None) => {}
60            Err(e) => {
61                eprintln!("{e}");
62                std::process::exit(1);
63            }
64        }
65    } else {
66        // Best-effort: still check the raw path string.
67        let raw = std::path::Path::new(path);
68        match io_boundary::check_secret_path_for_tool("cli_read", raw) {
69            Ok(Some(w)) => eprintln!("{w}"),
70            Ok(None) => {}
71            Err(e) => {
72                eprintln!("{e}");
73                std::process::exit(1);
74            }
75        }
76    }
77
78    #[cfg(unix)]
79    {
80        #[cfg(unix)]
81        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
82            "ctx_read",
83            Some(serde_json::json!({
84                "path": path,
85                "mode": mode,
86                "fresh": force_fresh,
87            })),
88        ) {
89            let filtered = super::common::filter_daemon_output(&out);
90            if !filtered.trim().is_empty() {
91                println!("{filtered}");
92                return;
93            }
94        }
95    }
96    super::common::daemon_fallback_hint();
97
98    if !force_fresh && mode == "full" {
99        use crate::core::cli_cache::{self, CacheResult};
100        match cli_cache::check_and_read(path) {
101            CacheResult::Hit { entry, file_ref } => {
102                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
103                println!("{msg}");
104                let sent = count_tokens(&msg);
105                super::common::cli_track_read_cached(path, "full", entry.original_tokens, sent);
106                return;
107            }
108            CacheResult::Miss { content } if content.is_empty() => {
109                eprintln!("Error: could not read {path}");
110                std::process::exit(1);
111            }
112            CacheResult::Miss { content } => {
113                let line_count = content.lines().count();
114                println!("{short} [{line_count}L]");
115                println!("{content}");
116                let tok = count_tokens(&content);
117                super::common::cli_track_read(path, "full", tok, tok);
118                return;
119            }
120        }
121    }
122
123    let content = match crate::tools::ctx_read::read_file_lossy(path) {
124        Ok(c) => c,
125        Err(e) => {
126            eprintln!("Error: {e}");
127            std::process::exit(1);
128        }
129    };
130
131    let ext = Path::new(path)
132        .extension()
133        .and_then(|e| e.to_str())
134        .unwrap_or("");
135    let line_count = content.lines().count();
136    let original_tokens = count_tokens(&content);
137
138    let mode = if mode == "auto" {
139        if crate::tools::ctx_read::is_instruction_file(path) {
140            "full".to_string()
141        } else {
142            let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
143            let predictor = crate::core::mode_predictor::ModePredictor::new();
144            predictor
145                .predict_best_mode(&sig)
146                .unwrap_or_else(|| "full".to_string())
147        }
148    } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
149        "full".to_string()
150    } else {
151        mode.to_string()
152    };
153    let mode = mode.as_str();
154
155    match mode {
156        "map" => {
157            let structured = match ext {
158                "md" | "mdx" | "rst" => {
159                    crate::core::structured_read::extract_markdown_outline(&content)
160                }
161                "json" => crate::core::structured_read::extract_json_structure(&content),
162                "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
163                "toml" => crate::core::structured_read::extract_toml_structure(&content),
164                _ if path.to_lowercase().ends_with(".lock")
165                    || path.to_lowercase().ends_with("go.sum") =>
166                {
167                    crate::core::structured_read::extract_lock_summary(&content, path)
168                }
169                _ => String::new(),
170            };
171
172            let mut output_buf = if structured.is_empty() {
173                let sigs = signatures::extract_signatures(&content, ext);
174                let dep_info = dep_extract::extract_deps(&content, ext);
175                let mut buf = format!("{short} [{line_count}L]");
176                if !dep_info.imports.is_empty() {
177                    buf.push_str(&format!("\n  deps: {}", dep_info.imports.join(", ")));
178                }
179                if !dep_info.exports.is_empty() {
180                    buf.push_str(&format!("\n  exports: {}", dep_info.exports.join(", ")));
181                }
182                let key_sigs: Vec<_> = sigs
183                    .iter()
184                    .filter(|s| s.is_exported || s.indent == 0)
185                    .collect();
186                if !key_sigs.is_empty() {
187                    buf.push_str("\n  API:");
188                    for sig in &key_sigs {
189                        buf.push_str(&format!("\n    {}", sig.to_compact_located()));
190                    }
191                }
192                buf
193            } else {
194                format!("{short} [{line_count}L]\n{structured}")
195            };
196
197            let sent = count_tokens(&output_buf);
198            let savings = protocol::append_savings(&output_buf, original_tokens, sent);
199            output_buf = savings;
200            println!("{output_buf}");
201            super::common::cli_track_read(path, "map", original_tokens, sent);
202        }
203        "signatures" => {
204            let sigs = signatures::extract_signatures(&content, ext);
205            let mut output_buf = format!("{short} [{line_count}L]");
206            for sig in &sigs {
207                output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
208            }
209            println!("{output_buf}");
210            let sent = count_tokens(&output_buf);
211            print_savings(original_tokens, sent);
212            super::common::cli_track_read(path, "signatures", original_tokens, sent);
213        }
214        "aggressive" => {
215            let compressed = compressor::aggressive_compress(&content, Some(ext));
216            println!("{short} [{line_count}L]");
217            println!("{compressed}");
218            let sent = count_tokens(&compressed);
219            print_savings(original_tokens, sent);
220            super::common::cli_track_read(path, "aggressive", original_tokens, sent);
221        }
222        "entropy" => {
223            let result = entropy::entropy_compress(&content);
224            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
225            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
226            for tech in &result.techniques {
227                println!("{tech}");
228            }
229            println!("{}", result.output);
230            let sent = count_tokens(&result.output);
231            print_savings(original_tokens, sent);
232            super::common::cli_track_read(path, "entropy", original_tokens, sent);
233        }
234        _ => {
235            // `full`, `lines:` and any unrecognized mode land here. These are
236            // verbatim reads — the prose terse pipeline would mangle source
237            // (dictionary substitutions, line-drop dedup) and break a `full`
238            // read's "complete content" contract, so it must never run here
239            // (#404). Intentionally-lossy modes (map/signatures/aggressive/
240            // entropy) have their own arms above.
241            let mut output = format!("{short} [{line_count}L]\n{content}");
242            if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
243                let config = crate::core::config::Config::load();
244                let level = crate::core::config::CompressionLevel::effective(&config);
245                if level.is_active() {
246                    let terse_result =
247                        crate::core::terse::pipeline::compress(&output, &level, None);
248                    if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
249                        output = terse_result.output;
250                    }
251                }
252            }
253            println!("{output}");
254            let sent = count_tokens(&output);
255            super::common::cli_track_read(path, "full", original_tokens, sent);
256        }
257    }
258}
259
260pub fn cmd_diff(args: &[String]) {
261    if args.len() < 2 {
262        eprintln!("Usage: lean-ctx diff <file1> <file2>");
263        std::process::exit(1);
264    }
265
266    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
267        Ok(c) => c,
268        Err(e) => {
269            eprintln!("Error reading {}: {e}", args[0]);
270            std::process::exit(1);
271        }
272    };
273
274    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
275        Ok(c) => c,
276        Err(e) => {
277            eprintln!("Error reading {}: {e}", args[1]);
278            std::process::exit(1);
279        }
280    };
281
282    let diff = compressor::diff_content(&content1, &content2);
283    let original = count_tokens(&content1) + count_tokens(&content2);
284    let sent = count_tokens(&diff);
285
286    println!(
287        "diff {} {}",
288        protocol::shorten_path(&args[0]),
289        protocol::shorten_path(&args[1])
290    );
291    println!("{diff}");
292    print_savings(original, sent);
293    crate::core::stats::record("cli_diff", original, sent);
294}
295
296pub fn cmd_grep(args: &[String]) {
297    if args.is_empty() {
298        eprintln!("Usage: lean-ctx grep <pattern> [path]");
299        std::process::exit(1);
300    }
301
302    let pattern = &args[0];
303    let raw_path = args.get(1).map_or(".", std::string::String::as_str);
304    let abs_path = resolve_cli_path(raw_path);
305    let path = abs_path.as_str();
306
307    #[cfg(unix)]
308    {
309        #[cfg(unix)]
310        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
311            "ctx_search",
312            Some(serde_json::json!({
313                "pattern": pattern,
314                "path": path,
315            })),
316        ) {
317            let out = super::common::filter_daemon_output(&out);
318            println!("{out}");
319            if out.trim_start().starts_with("0 matches") {
320                std::process::exit(1);
321            }
322            return;
323        }
324    }
325    super::common::daemon_fallback_hint();
326
327    let outcome = crate::tools::ctx_search::handle(
328        pattern,
329        path,
330        None,
331        20,
332        crate::tools::CrpMode::effective(),
333        true,
334        roles::active_role().io.allow_secret_paths,
335    );
336    let out = outcome.text;
337    println!("{out}");
338    super::common::cli_track_search(
339        outcome.modeled_baseline,
340        outcome.observed_tokens,
341        count_tokens(&out),
342    );
343    if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
344        std::process::exit(1);
345    }
346}
347
348pub fn cmd_find(args: &[String]) {
349    if args.is_empty() {
350        eprintln!("Usage: lean-ctx find <pattern> [path]");
351        std::process::exit(1);
352    }
353
354    let raw_pattern = &args[0];
355    let path = args.get(1).map_or(".", std::string::String::as_str);
356
357    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
358    let glob_matcher = if is_glob {
359        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
360    } else {
361        None
362    };
363    let substring = raw_pattern.to_lowercase();
364
365    let mut found = false;
366    for entry in ignore::WalkBuilder::new(path)
367        .hidden(true)
368        .git_ignore(true)
369        .git_global(true)
370        .git_exclude(true)
371        .require_git(false)
372        .max_depth(Some(10))
373        .filter_entry(crate::core::walk_filter::keep_entry)
374        .build()
375        .flatten()
376    {
377        let name = entry.file_name().to_string_lossy().to_lowercase();
378        let matches = if let Some(ref g) = glob_matcher {
379            g.matches(&name)
380        } else {
381            name.contains(&substring)
382        };
383        if matches {
384            println!("{}", entry.path().display());
385            found = true;
386        }
387    }
388
389    crate::core::stats::record("cli_find", 0, 0);
390
391    if !found {
392        std::process::exit(1);
393    }
394}
395
396pub fn cmd_ls(args: &[String]) {
397    let mut raw_path = ".";
398    let mut depth = 3usize;
399    let mut show_hidden = false;
400    let mut respect_gitignore = true;
401    let mut i = 0;
402
403    while i < args.len() {
404        let arg = &args[i];
405        if arg == "--depth" {
406            i += 1;
407            if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
408                depth = d.min(10);
409            }
410        } else if arg == "--all" || arg == "-a" {
411            show_hidden = true;
412        } else if arg == "--no-gitignore" {
413            respect_gitignore = false;
414        } else if arg.starts_with('-') {
415            eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
416            eprintln!("lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement.");
417            eprintln!("The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n");
418            eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
419            std::process::exit(1);
420        } else {
421            raw_path = arg;
422        }
423        i += 1;
424    }
425
426    let abs_path = resolve_cli_path(raw_path);
427    let path = abs_path.as_str();
428
429    #[cfg(unix)]
430    {
431        #[cfg(unix)]
432        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
433            "ctx_tree",
434            Some(serde_json::json!({
435                "path": path,
436                "depth": depth,
437                "show_hidden": show_hidden,
438                "respect_gitignore": respect_gitignore,
439            })),
440        ) {
441            println!("{}", super::common::filter_daemon_output(&out));
442            return;
443        }
444    }
445    super::common::daemon_fallback_hint();
446
447    let (out, _original) =
448        crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
449    println!("{out}");
450    super::common::cli_track_tree(0, count_tokens(&out));
451}
452
453pub fn cmd_deps(args: &[String]) {
454    let path = args.first().map_or(".", std::string::String::as_str);
455
456    if let Some(result) = deps_cmd::detect_and_compress(path) {
457        println!("{result}");
458        crate::core::stats::record("cli_deps", 0, 0);
459    } else {
460        eprintln!("No dependency file found in {path}");
461        std::process::exit(1);
462    }
463}