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            println!("{out}");
90            return;
91        }
92    }
93    super::common::daemon_fallback_hint();
94
95    if !force_fresh && mode == "full" {
96        use crate::core::cli_cache::{self, CacheResult};
97        match cli_cache::check_and_read(path) {
98            CacheResult::Hit { entry, file_ref } => {
99                let msg = cli_cache::format_hit(&entry, &file_ref, &short);
100                println!("{msg}");
101                let sent = count_tokens(&msg);
102                super::common::cli_track_read_cached(path, "full", entry.original_tokens, sent);
103                return;
104            }
105            CacheResult::Miss { content } if content.is_empty() => {
106                eprintln!("Error: could not read {path}");
107                std::process::exit(1);
108            }
109            CacheResult::Miss { content } => {
110                let line_count = content.lines().count();
111                println!("{short} [{line_count}L]");
112                println!("{content}");
113                let tok = count_tokens(&content);
114                super::common::cli_track_read(path, "full", tok, tok);
115                return;
116            }
117        }
118    }
119
120    let content = match crate::tools::ctx_read::read_file_lossy(path) {
121        Ok(c) => c,
122        Err(e) => {
123            eprintln!("Error: {e}");
124            std::process::exit(1);
125        }
126    };
127
128    let ext = Path::new(path)
129        .extension()
130        .and_then(|e| e.to_str())
131        .unwrap_or("");
132    let line_count = content.lines().count();
133    let original_tokens = count_tokens(&content);
134
135    let mode = if mode == "auto" {
136        if crate::tools::ctx_read::is_instruction_file(path) {
137            "full".to_string()
138        } else {
139            let sig = crate::core::mode_predictor::FileSignature::from_path(path, original_tokens);
140            let predictor = crate::core::mode_predictor::ModePredictor::new();
141            predictor
142                .predict_best_mode(&sig)
143                .unwrap_or_else(|| "full".to_string())
144        }
145    } else {
146        mode.to_string()
147    };
148    let mode = mode.as_str();
149
150    match mode {
151        "map" => {
152            let sigs = signatures::extract_signatures(&content, ext);
153            let dep_info = dep_extract::extract_deps(&content, ext);
154
155            let mut output_buf = format!("{short} [{line_count}L]");
156            if !dep_info.imports.is_empty() {
157                output_buf.push_str(&format!("\n  deps: {}", dep_info.imports.join(", ")));
158            }
159            if !dep_info.exports.is_empty() {
160                output_buf.push_str(&format!("\n  exports: {}", dep_info.exports.join(", ")));
161            }
162            let key_sigs: Vec<_> = sigs
163                .iter()
164                .filter(|s| s.is_exported || s.indent == 0)
165                .collect();
166            if !key_sigs.is_empty() {
167                output_buf.push_str("\n  API:");
168                for sig in &key_sigs {
169                    output_buf.push_str(&format!("\n    {}", sig.to_compact()));
170                }
171            }
172            println!("{output_buf}");
173            let sent = count_tokens(&output_buf);
174            print_savings(original_tokens, sent);
175            super::common::cli_track_read(path, "map", original_tokens, sent);
176        }
177        "signatures" => {
178            let sigs = signatures::extract_signatures(&content, ext);
179            let mut output_buf = format!("{short} [{line_count}L]");
180            for sig in &sigs {
181                output_buf.push_str(&format!("\n{}", sig.to_compact()));
182            }
183            println!("{output_buf}");
184            let sent = count_tokens(&output_buf);
185            print_savings(original_tokens, sent);
186            super::common::cli_track_read(path, "signatures", original_tokens, sent);
187        }
188        "aggressive" => {
189            let compressed = compressor::aggressive_compress(&content, Some(ext));
190            println!("{short} [{line_count}L]");
191            println!("{compressed}");
192            let sent = count_tokens(&compressed);
193            print_savings(original_tokens, sent);
194            super::common::cli_track_read(path, "aggressive", original_tokens, sent);
195        }
196        "entropy" => {
197            let result = entropy::entropy_compress(&content);
198            let avg_h = entropy::analyze_entropy(&content).avg_entropy;
199            println!("{short} [{line_count}L] (H̄={avg_h:.1})");
200            for tech in &result.techniques {
201                println!("{tech}");
202            }
203            println!("{}", result.output);
204            let sent = count_tokens(&result.output);
205            print_savings(original_tokens, sent);
206            super::common::cli_track_read(path, "entropy", original_tokens, sent);
207        }
208        _ => {
209            let mut output = format!("{short} [{line_count}L]\n{content}");
210            let config = crate::core::config::Config::load();
211            let level = crate::core::config::CompressionLevel::effective(&config);
212            if level.is_active() {
213                let terse_result = crate::core::terse::pipeline::compress(&output, &level, None);
214                if terse_result.quality_passed && terse_result.savings_pct >= 1.0 {
215                    output = terse_result.output;
216                }
217            }
218            println!("{output}");
219            let sent = count_tokens(&output);
220            super::common::cli_track_read(path, "full", original_tokens, sent);
221        }
222    }
223}
224
225pub fn cmd_diff(args: &[String]) {
226    if args.len() < 2 {
227        eprintln!("Usage: lean-ctx diff <file1> <file2>");
228        std::process::exit(1);
229    }
230
231    let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
232        Ok(c) => c,
233        Err(e) => {
234            eprintln!("Error reading {}: {e}", args[0]);
235            std::process::exit(1);
236        }
237    };
238
239    let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
240        Ok(c) => c,
241        Err(e) => {
242            eprintln!("Error reading {}: {e}", args[1]);
243            std::process::exit(1);
244        }
245    };
246
247    let diff = compressor::diff_content(&content1, &content2);
248    let original = count_tokens(&content1) + count_tokens(&content2);
249    let sent = count_tokens(&diff);
250
251    println!(
252        "diff {} {}",
253        protocol::shorten_path(&args[0]),
254        protocol::shorten_path(&args[1])
255    );
256    println!("{diff}");
257    print_savings(original, sent);
258    crate::core::stats::record("cli_diff", original, sent);
259}
260
261pub fn cmd_grep(args: &[String]) {
262    if args.is_empty() {
263        eprintln!("Usage: lean-ctx grep <pattern> [path]");
264        std::process::exit(1);
265    }
266
267    let pattern = &args[0];
268    let raw_path = args.get(1).map_or(".", std::string::String::as_str);
269    let abs_path = resolve_cli_path(raw_path);
270    let path = abs_path.as_str();
271
272    #[cfg(unix)]
273    {
274        #[cfg(unix)]
275        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
276            "ctx_search",
277            Some(serde_json::json!({
278                "pattern": pattern,
279                "path": path,
280            })),
281        ) {
282            println!("{out}");
283            if out.trim_start().starts_with("0 matches") {
284                std::process::exit(1);
285            }
286            return;
287        }
288    }
289    super::common::daemon_fallback_hint();
290
291    let (out, original) = crate::tools::ctx_search::handle(
292        pattern,
293        path,
294        None,
295        20,
296        crate::tools::CrpMode::effective(),
297        true,
298        roles::active_role().io.allow_secret_paths,
299    );
300    println!("{out}");
301    super::common::cli_track_search(original, count_tokens(&out));
302    if original == 0 && out.trim_start().starts_with("0 matches") {
303        std::process::exit(1);
304    }
305}
306
307pub fn cmd_find(args: &[String]) {
308    if args.is_empty() {
309        eprintln!("Usage: lean-ctx find <pattern> [path]");
310        std::process::exit(1);
311    }
312
313    let raw_pattern = &args[0];
314    let path = args.get(1).map_or(".", std::string::String::as_str);
315
316    let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
317    let glob_matcher = if is_glob {
318        glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
319    } else {
320        None
321    };
322    let substring = raw_pattern.to_lowercase();
323
324    let mut found = false;
325    for entry in ignore::WalkBuilder::new(path)
326        .hidden(true)
327        .git_ignore(true)
328        .git_global(true)
329        .git_exclude(true)
330        .max_depth(Some(10))
331        .build()
332        .flatten()
333    {
334        let name = entry.file_name().to_string_lossy().to_lowercase();
335        let matches = if let Some(ref g) = glob_matcher {
336            g.matches(&name)
337        } else {
338            name.contains(&substring)
339        };
340        if matches {
341            println!("{}", entry.path().display());
342            found = true;
343        }
344    }
345
346    crate::core::stats::record("cli_find", 0, 0);
347
348    if !found {
349        std::process::exit(1);
350    }
351}
352
353pub fn cmd_ls(args: &[String]) {
354    let raw_path = args.first().map_or(".", std::string::String::as_str);
355    let abs_path = resolve_cli_path(raw_path);
356    let path = abs_path.as_str();
357    let depth = 3usize;
358    let show_hidden = false;
359
360    #[cfg(unix)]
361    {
362        #[cfg(unix)]
363        if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
364            "ctx_tree",
365            Some(serde_json::json!({
366                "path": path,
367                "depth": depth,
368                "show_hidden": show_hidden,
369            })),
370        ) {
371            println!("{out}");
372            return;
373        }
374    }
375    super::common::daemon_fallback_hint();
376
377    let (out, _original) = crate::tools::ctx_tree::handle(path, depth, show_hidden);
378    println!("{out}");
379    super::common::cli_track_tree(0, count_tokens(&out));
380}
381
382pub fn cmd_deps(args: &[String]) {
383    let path = args.first().map_or(".", std::string::String::as_str);
384
385    if let Some(result) = deps_cmd::detect_and_compress(path) {
386        println!("{result}");
387        crate::core::stats::record("cli_deps", 0, 0);
388    } else {
389        eprintln!("No dependency file found in {path}");
390        std::process::exit(1);
391    }
392}