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