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
27fn 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 let requested_auto = mode == "auto";
68
69 let short = protocol::shorten_path(path);
70
71 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 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 let read_start = std::time::Instant::now();
118
119 if !force_fresh && mode == "full" {
120 use crate::core::cli_cache::{self, CacheResult};
121 match cli_cache::check_and_read(path) {
122 CacheResult::Hit { entry, file_ref } => {
123 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
124 println!("{msg}");
125 let sent = count_tokens(&msg);
126 super::common::cli_track_read_cached(
127 path,
128 "full",
129 entry.original_tokens,
130 sent,
131 &msg,
132 read_start.elapsed(),
133 );
134 return;
135 }
136 CacheResult::Miss { content } if content.is_empty() => {
137 eprintln!("Error: could not read {path}");
138 std::process::exit(1);
139 }
140 CacheResult::Miss { content } => {
141 let line_count = content.lines().count();
142 let raw_tokens = count_tokens(&content);
143 let framed = format!("{short} [{line_count}L]\n{content}");
144 let output = cap_cli_to_raw(framed, &content, raw_tokens);
145 println!("{output}");
146 let sent = count_tokens(&output);
147 super::common::cli_track_read(
148 path,
149 "full",
150 raw_tokens,
151 sent,
152 &output,
153 read_start.elapsed(),
154 );
155 return;
156 }
157 }
158 }
159
160 let content = match crate::tools::ctx_read::read_file_lossy(path) {
161 Ok(c) => c,
162 Err(e) => {
163 eprintln!("Error: {e}");
164 std::process::exit(1);
165 }
166 };
167
168 let ext = Path::new(path)
169 .extension()
170 .and_then(|e| e.to_str())
171 .unwrap_or("");
172 let line_count = content.lines().count();
173 let original_tokens = count_tokens(&content);
174
175 let mode = if mode == "auto" {
176 crate::core::auto_mode_resolver::resolve(
182 &crate::core::auto_mode_resolver::AutoModeContext {
183 path,
184 token_count: original_tokens,
185 task: None,
186 cache: None,
187 },
188 )
189 .mode
190 } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
191 "full".to_string()
192 } else {
193 mode.to_string()
194 };
195 let mode = mode.as_str();
196
197 match mode {
198 "map" => {
199 let structured = match ext {
200 "md" | "mdx" | "rst" => {
201 crate::core::structured_read::extract_markdown_outline(&content)
202 }
203 "json" => crate::core::structured_read::extract_json_structure(&content),
204 "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
205 "toml" => crate::core::structured_read::extract_toml_structure(&content),
206 _ if path.to_lowercase().ends_with(".lock")
207 || path.to_lowercase().ends_with("go.sum") =>
208 {
209 crate::core::structured_read::extract_lock_summary(&content, path)
210 }
211 _ => String::new(),
212 };
213
214 let mut output_buf = if structured.is_empty() {
215 let sigs = signatures::extract_signatures(&content, ext);
216 let dep_info = dep_extract::extract_deps(&content, ext);
217 let mut buf = format!("{short} [{line_count}L]");
218 if !dep_info.imports.is_empty() {
219 buf.push_str(&format!("\n deps: {}", dep_info.imports.join(", ")));
220 }
221 let key_sigs: Vec<&signatures::Signature> = sigs
222 .iter()
223 .filter(|s| s.is_exported || s.indent == 0)
224 .collect();
225 let extra_exports =
229 signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
230 if !extra_exports.is_empty() {
231 buf.push_str(&format!("\n exports: {}", extra_exports.join(", ")));
232 }
233 if !key_sigs.is_empty() {
234 buf.push_str("\n API:");
235 for sig in &key_sigs {
236 buf.push_str(&format!("\n {}", sig.to_compact_located()));
237 }
238 }
239 buf
240 } else {
241 format!("{short} [{line_count}L]\n{structured}")
242 };
243
244 let sent = count_tokens(&output_buf);
245 output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
246 if requested_auto {
247 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
248 }
249 let sent = count_tokens(&output_buf);
250 println!("{output_buf}");
251 super::common::cli_track_read(
252 path,
253 "map",
254 original_tokens,
255 sent,
256 &output_buf,
257 read_start.elapsed(),
258 );
259 }
260 "signatures" => {
261 let sigs = signatures::extract_signatures(&content, ext);
262 let mut output_buf = format!("{short} [{line_count}L]");
263 for sig in &sigs {
264 output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
265 }
266 if requested_auto {
267 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
268 }
269 println!("{output_buf}");
270 let sent = count_tokens(&output_buf);
271 print_savings(original_tokens, sent);
272 super::common::cli_track_read(
273 path,
274 "signatures",
275 original_tokens,
276 sent,
277 &output_buf,
278 read_start.elapsed(),
279 );
280 }
281 "aggressive" => {
282 let compressed = compressor::aggressive_compress(&content, Some(ext));
283 println!("{short} [{line_count}L]");
284 println!("{compressed}");
285 let sent = count_tokens(&compressed);
286 print_savings(original_tokens, sent);
287 super::common::cli_track_read(
288 path,
289 "aggressive",
290 original_tokens,
291 sent,
292 &compressed,
293 read_start.elapsed(),
294 );
295 }
296 "entropy" => {
297 let result = entropy::entropy_compress(&content);
298 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
299 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
300 for tech in &result.techniques {
301 println!("{tech}");
302 }
303 println!("{}", result.output);
304 let sent = count_tokens(&result.output);
305 print_savings(original_tokens, sent);
306 super::common::cli_track_read(
307 path,
308 "entropy",
309 original_tokens,
310 sent,
311 &result.output,
312 read_start.elapsed(),
313 );
314 }
315 _ => {
316 let mut output = format!("{short} [{line_count}L]\n{content}");
323 if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
324 let config = crate::core::config::Config::load();
325 let level = crate::core::config::CompressionLevel::effective(&config);
326 if level.is_active() {
327 let terse_result =
328 crate::core::terse::pipeline::compress(&output, &level, None);
329 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
330 output = terse_result.output;
331 }
332 }
333 }
334 let output = cap_cli_to_raw(output, &content, original_tokens);
337 println!("{output}");
338 let sent = count_tokens(&output);
339 super::common::cli_track_read(
340 path,
341 "full",
342 original_tokens,
343 sent,
344 &output,
345 read_start.elapsed(),
346 );
347 }
348 }
349}
350
351pub fn cmd_diff(args: &[String]) {
352 if args.len() < 2 {
353 eprintln!("Usage: lean-ctx diff <file1> <file2>");
354 std::process::exit(1);
355 }
356
357 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
358 Ok(c) => c,
359 Err(e) => {
360 eprintln!("Error reading {}: {e}", args[0]);
361 std::process::exit(1);
362 }
363 };
364
365 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
366 Ok(c) => c,
367 Err(e) => {
368 eprintln!("Error reading {}: {e}", args[1]);
369 std::process::exit(1);
370 }
371 };
372
373 let diff = compressor::diff_content(&content1, &content2);
374 let original = count_tokens(&content1) + count_tokens(&content2);
375 let sent = count_tokens(&diff);
376
377 println!(
378 "diff {} {}",
379 protocol::shorten_path(&args[0]),
380 protocol::shorten_path(&args[1])
381 );
382 println!("{diff}");
383 print_savings(original, sent);
384 crate::core::stats::record("cli_diff", original, sent);
385}
386
387pub fn cmd_grep(args: &[String]) {
388 if args.is_empty() {
389 eprintln!("Usage: lean-ctx grep <pattern> [path]");
390 std::process::exit(1);
391 }
392
393 let pattern = &args[0];
394 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
395 let abs_path = resolve_cli_path(raw_path);
396 let path = abs_path.as_str();
397
398 #[cfg(unix)]
399 {
400 #[cfg(unix)]
401 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
402 "ctx_search",
403 Some(serde_json::json!({
404 "pattern": pattern,
405 "path": path,
406 })),
407 ) {
408 let out = super::common::filter_daemon_output(&out);
409 println!("{out}");
410 if out.trim_start().starts_with("0 matches") {
411 std::process::exit(1);
412 }
413 return;
414 }
415 }
416 super::common::daemon_fallback_hint();
417
418 let search_start = std::time::Instant::now();
420
421 let outcome = crate::tools::ctx_search::handle(
422 pattern,
423 path,
424 None,
425 20,
426 crate::tools::CrpMode::effective(),
427 true,
428 roles::active_role().io.allow_secret_paths,
429 );
430 let out = outcome.text;
431 println!("{out}");
432 super::common::cli_track_search(
433 outcome.modeled_baseline,
434 outcome.observed_tokens,
435 count_tokens(&out),
436 pattern,
437 path,
438 &out,
439 search_start.elapsed(),
440 );
441 if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
442 std::process::exit(1);
443 }
444}
445
446pub fn cmd_glob(args: &[String]) {
451 if args.is_empty() {
452 eprintln!("Usage: lean-ctx glob <pattern> [path]");
453 std::process::exit(1);
454 }
455
456 let pattern = &args[0];
457 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
458 let abs_path = resolve_cli_path(raw_path);
459 let path = abs_path.as_str();
460
461 #[cfg(unix)]
462 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
463 "ctx_glob",
464 Some(serde_json::json!({
465 "pattern": pattern,
466 "path": path,
467 })),
468 ) {
469 let out = super::common::filter_daemon_output(&out);
470 println!("{out}");
471 return;
472 }
473 super::common::daemon_fallback_hint();
474
475 let (out, _original) = crate::tools::ctx_glob::handle(
476 pattern,
477 path,
478 true,
479 roles::active_role().io.allow_secret_paths,
480 200,
481 );
482 println!("{out}");
483 crate::core::stats::record("cli_glob", 0, 0);
484 if out.starts_with("ERROR:") {
485 std::process::exit(1);
486 }
487}
488
489pub fn cmd_find(args: &[String]) {
490 if args.is_empty() {
491 eprintln!("Usage: lean-ctx find <pattern> [path]");
492 std::process::exit(1);
493 }
494
495 let raw_pattern = &args[0];
496 let path = args.get(1).map_or(".", std::string::String::as_str);
497
498 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
499 let glob_matcher = if is_glob {
500 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
501 } else {
502 None
503 };
504 let substring = raw_pattern.to_lowercase();
505
506 let mut found = false;
507 for entry in ignore::WalkBuilder::new(path)
508 .hidden(true)
509 .git_ignore(true)
510 .git_global(true)
511 .git_exclude(true)
512 .require_git(false)
513 .max_depth(Some(10))
514 .filter_entry(crate::core::walk_filter::keep_entry)
515 .build()
516 .flatten()
517 {
518 let name = entry.file_name().to_string_lossy().to_lowercase();
519 let matches = if let Some(ref g) = glob_matcher {
520 g.matches(&name)
521 } else {
522 name.contains(&substring)
523 };
524 if matches {
525 println!("{}", entry.path().display());
526 found = true;
527 }
528 }
529
530 crate::core::stats::record("cli_find", 0, 0);
531
532 if !found {
533 std::process::exit(1);
534 }
535}
536
537pub fn cmd_ls(args: &[String]) {
538 let mut raw_path = ".";
539 let mut depth = 3usize;
540 let mut show_hidden = false;
541 let mut respect_gitignore = true;
542 let mut i = 0;
543
544 while i < args.len() {
545 let arg = &args[i];
546 if arg == "--depth" {
547 i += 1;
548 if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
549 depth = d.min(10);
550 }
551 } else if arg == "--all" || arg == "-a" {
552 show_hidden = true;
553 } else if arg == "--no-gitignore" {
554 respect_gitignore = false;
555 } else if arg.starts_with('-') {
556 eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
557 eprintln!(
558 "lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement."
559 );
560 eprintln!(
561 "The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n"
562 );
563 eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
564 std::process::exit(1);
565 } else {
566 raw_path = arg;
567 }
568 i += 1;
569 }
570
571 let abs_path = resolve_cli_path(raw_path);
572 let path = abs_path.as_str();
573
574 #[cfg(unix)]
575 {
576 #[cfg(unix)]
577 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
578 "ctx_tree",
579 Some(serde_json::json!({
580 "path": path,
581 "depth": depth,
582 "show_hidden": show_hidden,
583 "respect_gitignore": respect_gitignore,
584 })),
585 ) {
586 println!("{}", super::common::filter_daemon_output(&out));
587 return;
588 }
589 }
590 super::common::daemon_fallback_hint();
591
592 let (out, _original) =
593 crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
594 println!("{out}");
595 super::common::cli_track_tree(0, count_tokens(&out));
596}
597
598pub fn cmd_deps(args: &[String]) {
599 let path = args.first().map_or(".", std::string::String::as_str);
600
601 if let Some(result) = deps_cmd::detect_and_compress(path) {
602 println!("{result}");
603 crate::core::stats::record("cli_deps", 0, 0);
604 } else {
605 eprintln!("No dependency file found in {path}");
606 std::process::exit(1);
607 }
608}
609
610#[cfg(test)]
611mod cap_tests {
612 use super::{cap_cli_to_raw, count_tokens};
613
614 #[test]
615 fn caps_to_raw_when_framing_inflates() {
616 let raw = "x = 1\n";
620 let raw_tokens = count_tokens(raw);
621 let framed = format!("some/very/long/path/header.rs [1L]\n{raw}\n[lean-ctx: 0 tok saved]");
622 assert!(count_tokens(&framed) > raw_tokens, "fixture must inflate");
623 assert_eq!(cap_cli_to_raw(framed, raw, raw_tokens), raw);
624 }
625
626 #[test]
627 fn keeps_framing_when_it_saves() {
628 let raw = "fn a() {}\n".repeat(300);
630 let raw_tokens = count_tokens(&raw);
631 let framed = "f.rs [300L]\nfn a() {} …".to_string();
632 assert!(count_tokens(&framed) < raw_tokens);
633 assert_eq!(cap_cli_to_raw(framed.clone(), &raw, raw_tokens), framed);
634 }
635
636 #[test]
637 fn keeps_framing_for_empty_file() {
638 let framed = "empty.rs [0L]\n".to_string();
640 assert_eq!(cap_cli_to_raw(framed.clone(), "", 0), framed);
641 }
642
643 #[test]
644 fn break_even_is_not_inflation() {
645 let raw = "alpha beta gamma delta";
647 let raw_tokens = count_tokens(raw);
648 let framed = raw.to_string();
649 assert_eq!(count_tokens(&framed), raw_tokens);
650 assert_eq!(cap_cli_to_raw(framed.clone(), raw, raw_tokens), framed);
651 }
652
653 #[test]
654 fn emitted_never_exceeds_raw_across_sizes() {
655 for n in [1usize, 5, 50, 500] {
658 let raw = "data line here\n".repeat(n);
659 let raw_tokens = count_tokens(&raw);
660 let framed = format!("a/b/c/path.txt [{n}L]\n{raw}\n[lean-ctx: {n} tok saved ({n}%)]");
661 let out = cap_cli_to_raw(framed, &raw, raw_tokens);
662 assert!(
663 count_tokens(&out) <= raw_tokens,
664 "n={n}: emitted {} tok exceeds raw {raw_tokens}",
665 count_tokens(&out)
666 );
667 }
668 }
669}