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;
11use crate::core::tokens::count_tokens;
12
13use super::common::print_savings;
14
15pub fn cmd_read(args: &[String]) {
16    if args.is_empty() {
17        eprintln!(
18            "Usage: lean-ctx read <file> [--mode auto|full|map|signatures|aggressive|entropy] [--fresh]"
19        );
20        std::process::exit(1);
21    }
22
23    let raw_path = &args[0];
24    let path = if Path::new(raw_path).is_relative() {
25        std::env::current_dir().ok().map_or_else(
26            || raw_path.clone(),
27            |cwd| cwd.join(raw_path).to_string_lossy().into_owned(),
28        )
29    } else {
30        raw_path.clone()
31    };
32    let path = path.as_str();
33    let mode = args
34        .iter()
35        .position(|a| a == "--mode" || a == "-m")
36        .and_then(|i| args.get(i + 1))
37        .map_or("auto", std::string::String::as_str);
38    let force_fresh = args.iter().any(|a| a == "--fresh" || a == "--no-cache");
39
40    let short = protocol::shorten_path(path);
41
42    // Apply the same secret-path policy in CLI mode as in MCP tools.
43    // Default is warn; enforce depends on active role/policy.
44    if let Ok(abs) = std::fs::canonicalize(path) {
45        match io_boundary::check_secret_path_for_tool("cli_read", &abs) {
46            Ok(Some(w)) => eprintln!("{w}"),
47            Ok(None) => {}
48            Err(e) => {
49                eprintln!("{e}");
50                std::process::exit(1);
51            }
52        }
53    } else {
54        // Best-effort: still check the raw path string.
55        let raw = std::path::Path::new(path);
56        match io_boundary::check_secret_path_for_tool("cli_read", raw) {
57            Ok(Some(w)) => eprintln!("{w}"),
58            Ok(None) => {}
59            Err(e) => {
60                eprintln!("{e}");
61                std::process::exit(1);
62            }
63        }
64    }
65
66    #[cfg(unix)]
67    {
68        #[cfg(unix)]
69        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
70            "ctx_read",
71            Some(serde_json::json!({
72                "path": path,
73                "mode": mode,
74                "fresh": force_fresh,
75            })),
76        ) {
77            println!("{out}");
78            return;
79        }
80    }
81    super::common::daemon_fallback_hint();
82
83    if !force_fresh && mode == "full" {
84        use crate::core::cli_cache::{self, CacheResult};
85        match cli_cache::check_and_read(path) {
86            CacheResult::Hit { entry, file_ref } => {
87                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
88                println!("{msg}");
89                let sent = count_tokens(&msg);
90                super::common::cli_track_read_cached(path, "full", entry.original_tokens, sent);
91                return;
92            }
93            CacheResult::Miss { content } if content.is_empty() => {
94                eprintln!("Error: could not read {path}");
95                std::process::exit(1);
96            }
97            CacheResult::Miss { content } => {
98                let line_count = content.lines().count();
99                println!("{short} [{line_count}L]");
100                println!("{content}");
101                let tok = count_tokens(&content);
102                super::common::cli_track_read(path, "full", tok, tok);
103                return;
104            }
105        }
106    }
107
108    let content = match crate::tools::ctx_read::read_file_lossy(path) {
109        Ok(c) => c,
110        Err(e) => {
111            eprintln!("Error: {e}");
112            std::process::exit(1);
113        }
114    };
115
116    let ext = Path::new(path)
117        .extension()
118        .and_then(|e| e.to_str())
119        .unwrap_or("");
120    let line_count = content.lines().count();
121    let original_tokens = count_tokens(&content);
122
123    let mode = if mode == "auto" {
124        let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
125        let predictor = crate::core::mode_predictor::ModePredictor::new();
126        predictor
127            .predict_best_mode(&sig)
128            .unwrap_or_else(|| "full".to_string())
129    } else {
130        mode.to_string()
131    };
132    let mode = mode.as_str();
133
134    match mode {
135        "map" => {
136            let sigs = signatures::extract_signatures(&content, ext);
137            let dep_info = dep_extract::extract_deps(&content, ext);
138
139            let mut output_buf = format!("{short} [{line_count}L]");
140            if !dep_info.imports.is_empty() {
141                output_buf.push_str(&format!("\n  deps: {}", dep_info.imports.join(", ")));
142            }
143            if !dep_info.exports.is_empty() {
144                output_buf.push_str(&format!("\n  exports: {}", dep_info.exports.join(", ")));
145            }
146            let key_sigs: Vec<_> = sigs
147                .iter()
148                .filter(|s| s.is_exported || s.indent == 0)
149                .collect();
150            if !key_sigs.is_empty() {
151                output_buf.push_str("\n  API:");
152                for sig in &key_sigs {
153                    output_buf.push_str(&format!("\n    {}", sig.to_compact()));
154                }
155            }
156            println!("{output_buf}");
157            let sent = count_tokens(&output_buf);
158            print_savings(original_tokens, sent);
159            super::common::cli_track_read(path, "map", original_tokens, sent);
160        }
161        "signatures" => {
162            let sigs = signatures::extract_signatures(&content, ext);
163            let mut output_buf = format!("{short} [{line_count}L]");
164            for sig in &sigs {
165                output_buf.push_str(&format!("\n{}", sig.to_compact()));
166            }
167            println!("{output_buf}");
168            let sent = count_tokens(&output_buf);
169            print_savings(original_tokens, sent);
170            super::common::cli_track_read(path, "signatures", original_tokens, sent);
171        }
172        "aggressive" => {
173            let compressed = compressor::aggressive_compress(&content, Some(ext));
174            println!("{short} [{line_count}L]");
175            println!("{compressed}");
176            let sent = count_tokens(&compressed);
177            print_savings(original_tokens, sent);
178            super::common::cli_track_read(path, "aggressive", original_tokens, sent);
179        }
180        "entropy" => {
181            let result = entropy::entropy_compress(&content);
182            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
183            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
184            for tech in &result.techniques {
185                println!("{tech}");
186            }
187            println!("{}", result.output);
188            let sent = count_tokens(&result.output);
189            print_savings(original_tokens, sent);
190            super::common::cli_track_read(path, "entropy", original_tokens, sent);
191        }
192        _ => {
193            let full_output = format!("{short} [{line_count}L]\n{content}");
194            println!("{full_output}");
195            let sent = count_tokens(&full_output);
196            super::common::cli_track_read(path, "full", original_tokens, sent);
197        }
198    }
199}
200
201pub fn cmd_diff(args: &[String]) {
202    if args.len() < 2 {
203        eprintln!("Usage: lean-ctx diff <file1> <file2>");
204        std::process::exit(1);
205    }
206
207    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
208        Ok(c) => c,
209        Err(e) => {
210            eprintln!("Error reading {}: {e}", args[0]);
211            std::process::exit(1);
212        }
213    };
214
215    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
216        Ok(c) => c,
217        Err(e) => {
218            eprintln!("Error reading {}: {e}", args[1]);
219            std::process::exit(1);
220        }
221    };
222
223    let diff = compressor::diff_content(&content1, &content2);
224    let original = count_tokens(&content1) + count_tokens(&content2);
225    let sent = count_tokens(&diff);
226
227    println!(
228        "diff {} {}",
229        protocol::shorten_path(&args[0]),
230        protocol::shorten_path(&args[1])
231    );
232    println!("{diff}");
233    print_savings(original, sent);
234    crate::core::stats::record("cli_diff", original, sent);
235}
236
237pub fn cmd_grep(args: &[String]) {
238    if args.is_empty() {
239        eprintln!("Usage: lean-ctx grep <pattern> [path]");
240        std::process::exit(1);
241    }
242
243    let pattern = &args[0];
244    let path = args.get(1).map_or(".", std::string::String::as_str);
245
246    #[cfg(unix)]
247    {
248        #[cfg(unix)]
249        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
250            "ctx_search",
251            Some(serde_json::json!({
252                "pattern": pattern,
253                "path": path,
254            })),
255        ) {
256            println!("{out}");
257            if out.trim_start().starts_with("0 matches") {
258                std::process::exit(1);
259            }
260            return;
261        }
262    }
263    super::common::daemon_fallback_hint();
264
265    let (out, original) = crate::tools::ctx_search::handle(
266        pattern,
267        path,
268        None,
269        20,
270        crate::tools::CrpMode::effective(),
271        true,
272        roles::active_role().io.allow_secret_paths,
273    );
274    println!("{out}");
275    super::common::cli_track_search(original, count_tokens(&out));
276    if original == 0 && out.trim_start().starts_with("0 matches") {
277        std::process::exit(1);
278    }
279}
280
281pub fn cmd_find(args: &[String]) {
282    if args.is_empty() {
283        eprintln!("Usage: lean-ctx find <pattern> [path]");
284        std::process::exit(1);
285    }
286
287    let raw_pattern = &args[0];
288    let path = args.get(1).map_or(".", std::string::String::as_str);
289
290    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
291    let glob_matcher = if is_glob {
292        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
293    } else {
294        None
295    };
296    let substring = raw_pattern.to_lowercase();
297
298    let mut found = false;
299    for entry in ignore::WalkBuilder::new(path)
300        .hidden(true)
301        .git_ignore(true)
302        .git_global(true)
303        .git_exclude(true)
304        .max_depth(Some(10))
305        .build()
306        .flatten()
307    {
308        let name = entry.file_name().to_string_lossy().to_lowercase();
309        let matches = if let Some(ref g) = glob_matcher {
310            g.matches(&name)
311        } else {
312            name.contains(&substring)
313        };
314        if matches {
315            println!("{}", entry.path().display());
316            found = true;
317        }
318    }
319
320    crate::core::stats::record("cli_find", 0, 0);
321
322    if !found {
323        std::process::exit(1);
324    }
325}
326
327pub fn cmd_ls(args: &[String]) {
328    let path = args.first().map_or(".", std::string::String::as_str);
329    let depth = 3usize;
330    let show_hidden = false;
331
332    #[cfg(unix)]
333    {
334        #[cfg(unix)]
335        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
336            "ctx_tree",
337            Some(serde_json::json!({
338                "path": path,
339                "depth": depth,
340                "show_hidden": show_hidden,
341            })),
342        ) {
343            println!("{out}");
344            return;
345        }
346    }
347    super::common::daemon_fallback_hint();
348
349    let (out, _original) = crate::tools::ctx_tree::handle(path, depth, show_hidden);
350    println!("{out}");
351    super::common::cli_track_tree(0, count_tokens(&out));
352}
353
354pub fn cmd_deps(args: &[String]) {
355    let path = args.first().map_or(".", std::string::String::as_str);
356
357    if let Some(result) = deps_cmd::detect_and_compress(path) {
358        println!("{result}");
359        crate::core::stats::record("cli_deps", 0, 0);
360    } else {
361        eprintln!("No dependency file found in {path}");
362        std::process::exit(1);
363    }
364}