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
41fn should_force_fresh(args: &[String], hook_child: bool) -> bool {
46 hook_child || args.iter().any(|a| a == "--fresh" || a == "--no-cache")
47}
48
49fn resolve_cli_read_mode(args: &[String]) -> &str {
55 if let Some(m) = args
56 .iter()
57 .position(|a| a == "--mode" || a == "-m")
58 .and_then(|i| args.get(i + 1))
59 {
60 return m.as_str();
61 }
62 args.iter()
63 .skip(1)
64 .find(|a| !a.starts_with('-') && a.parse::<crate::tools::ctx_read::ReadMode>().is_ok())
65 .map_or("auto", std::string::String::as_str)
66}
67
68pub fn cmd_read(args: &[String]) {
69 if args.is_empty() {
70 eprintln!(
71 "Usage: lean-ctx read <file> [--mode auto|full|map|signatures|aggressive|entropy] [--fresh]"
72 );
73 std::process::exit(1);
74 }
75
76 let raw_path = &args[0];
77 let path = if Path::new(raw_path).is_relative() {
78 std::env::current_dir().ok().map_or_else(
79 || raw_path.clone(),
80 |cwd| cwd.join(raw_path).to_string_lossy().into_owned(),
81 )
82 } else {
83 raw_path.clone()
84 };
85 let path = path.as_str();
86 let mode = resolve_cli_read_mode(args);
87 let force_fresh = should_force_fresh(args, crate::core::runtime_flags::hook_child_enabled());
94 let requested_auto = mode == "auto";
97
98 let short = protocol::shorten_path(path);
99
100 if let Ok(abs) = std::fs::canonicalize(path) {
103 match io_boundary::check_secret_path_for_tool("cli_read", &abs) {
104 Ok(Some(w)) => eprintln!("{w}"),
105 Ok(None) => {}
106 Err(e) => {
107 eprintln!("{e}");
108 std::process::exit(1);
109 }
110 }
111 } else {
112 let raw = std::path::Path::new(path);
114 match io_boundary::check_secret_path_for_tool("cli_read", raw) {
115 Ok(Some(w)) => eprintln!("{w}"),
116 Ok(None) => {}
117 Err(e) => {
118 eprintln!("{e}");
119 std::process::exit(1);
120 }
121 }
122 }
123
124 #[cfg(unix)]
125 {
126 #[cfg(unix)]
127 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
128 "ctx_read",
129 Some(serde_json::json!({
130 "path": path,
131 "mode": mode,
132 "fresh": force_fresh,
133 })),
134 ) {
135 let filtered = super::common::filter_daemon_output(&out);
136 if !filtered.trim().is_empty() {
137 println!("{filtered}");
138 return;
139 }
140 }
141 }
142 super::common::daemon_fallback_hint();
143
144 let read_start = std::time::Instant::now();
147
148 if !force_fresh && mode == "full" {
149 use crate::core::cli_cache::{self, CacheResult};
150 match cli_cache::check_and_read(path) {
151 CacheResult::Hit { entry, file_ref } => {
152 let msg = cli_cache::format_hit(&entry, &file_ref, &short);
153 println!("{msg}");
154 let sent = count_tokens(&msg);
155 super::common::cli_track_read_cached(
156 path,
157 "full",
158 entry.original_tokens,
159 sent,
160 &msg,
161 read_start.elapsed(),
162 );
163 return;
164 }
165 CacheResult::Miss { content } if content.is_empty() => {
166 eprintln!("Error: could not read {path}");
167 std::process::exit(1);
168 }
169 CacheResult::Miss { content } => {
170 let line_count = content.lines().count();
171 let raw_tokens = count_tokens(&content);
172 let framed = format!("{short} [{line_count}L]\n{content}");
173 let output = cap_cli_to_raw(framed, &content, raw_tokens);
174 println!("{output}");
175 let sent = count_tokens(&output);
176 super::common::cli_track_read(
177 path,
178 "full",
179 raw_tokens,
180 sent,
181 &output,
182 read_start.elapsed(),
183 );
184 return;
185 }
186 }
187 }
188
189 let content = match crate::tools::ctx_read::read_file_lossy(path) {
190 Ok(c) => c,
191 Err(e) => {
192 eprintln!("Error: {e}");
193 std::process::exit(1);
194 }
195 };
196
197 let ext = Path::new(path)
198 .extension()
199 .and_then(|e| e.to_str())
200 .unwrap_or("");
201 let line_count = content.lines().count();
202 let original_tokens = count_tokens(&content);
203
204 let mode = if mode == "auto" {
205 crate::core::auto_mode_resolver::resolve(
211 &crate::core::auto_mode_resolver::AutoModeContext {
212 path,
213 token_count: original_tokens,
214 line_count: None,
215 task: None,
216 cache: None,
217 },
218 )
219 .mode
220 } else if mode != "full" && crate::tools::ctx_read::is_instruction_file(path) {
221 "full".to_string()
222 } else {
223 mode.to_string()
224 };
225 let mode = if (mode == "map" || mode == "signatures") && original_tokens <= 400 {
226 "full".to_string()
227 } else {
228 mode
229 };
230 let mode = mode.as_str();
231
232 match mode {
233 "map" => {
234 let structured = match ext {
235 "md" | "mdx" | "rst" => {
236 crate::core::structured_read::extract_markdown_outline(&content)
237 }
238 "json" => crate::core::structured_read::extract_json_structure(&content),
239 "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
240 "toml" => crate::core::structured_read::extract_toml_structure(&content),
241 _ if path.to_lowercase().ends_with(".lock")
242 || path.to_lowercase().ends_with("go.sum") =>
243 {
244 crate::core::structured_read::extract_lock_summary(&content, path)
245 }
246 _ => String::new(),
247 };
248
249 let mut output_buf = if structured.is_empty() {
250 let sigs = signatures::extract_signatures(&content, ext);
251 let dep_info = dep_extract::extract_deps(&content, ext);
252 let mut buf = format!("{short} [{line_count}L]");
253 if !dep_info.imports.is_empty() {
254 buf.push_str(&format!("\n deps: {}", dep_info.imports.join(", ")));
255 }
256 let key_sigs: Vec<&signatures::Signature> = sigs
257 .iter()
258 .filter(|s| s.is_exported || s.indent == 0)
259 .collect();
260 let extra_exports =
264 signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
265 if !extra_exports.is_empty() {
266 buf.push_str(&format!("\n exports: {}", extra_exports.join(", ")));
267 }
268 if !key_sigs.is_empty() {
269 buf.push_str("\n API:");
270 for sig in &key_sigs {
271 buf.push_str(&format!("\n {}", sig.to_compact_located()));
272 }
273 }
274 if key_sigs.is_empty() && dep_info.imports.is_empty() && extra_exports.is_empty() {
277 buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
278 }
279 buf
280 } else {
281 format!("{short} [{line_count}L]\n{structured}")
282 };
283
284 let sent = count_tokens(&output_buf);
285 output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
286 if requested_auto {
287 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
288 }
289 let sent = count_tokens(&output_buf);
290 println!("{output_buf}");
291 super::common::cli_track_read(
292 path,
293 "map",
294 original_tokens,
295 sent,
296 &output_buf,
297 read_start.elapsed(),
298 );
299 }
300 "signatures" => {
301 let sigs = signatures::extract_signatures(&content, ext);
302 let mut output_buf = format!("{short} [{line_count}L]");
303 for sig in &sigs {
304 output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
305 }
306 if sigs.is_empty() {
308 output_buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
309 }
310 if requested_auto {
311 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
312 }
313 println!("{output_buf}");
314 let sent = count_tokens(&output_buf);
315 print_savings(original_tokens, sent);
316 super::common::cli_track_read(
317 path,
318 "signatures",
319 original_tokens,
320 sent,
321 &output_buf,
322 read_start.elapsed(),
323 );
324 }
325 "aggressive" => {
326 let compressed = compressor::aggressive_compress(&content, Some(ext));
327 println!("{short} [{line_count}L]");
328 println!("{compressed}");
329 let sent = count_tokens(&compressed);
330 print_savings(original_tokens, sent);
331 super::common::cli_track_read(
332 path,
333 "aggressive",
334 original_tokens,
335 sent,
336 &compressed,
337 read_start.elapsed(),
338 );
339 }
340 "entropy" => {
341 let result = entropy::entropy_compress(&content);
342 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
343 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
344 for tech in &result.techniques {
345 println!("{tech}");
346 }
347 println!("{}", result.output);
348 let sent = count_tokens(&result.output);
349 print_savings(original_tokens, sent);
350 super::common::cli_track_read(
351 path,
352 "entropy",
353 original_tokens,
354 sent,
355 &result.output,
356 read_start.elapsed(),
357 );
358 }
359 m if m.starts_with("lines:") => {
360 let range_str = &m[6..];
364 let extracted = crate::tools::ctx_read::extract_line_range(&content, range_str);
365 let multi_hint = if range_str.contains(',') {
366 crate::tools::ctx_read::LINES_COMMA_HINT
367 } else {
368 ""
369 };
370 let output =
371 format!("{short} [{line_count}L] lines:{range_str}\n{extracted}{multi_hint}");
372 println!("{output}");
373 let sent = count_tokens(&output);
374 print_savings(original_tokens, sent);
375 super::common::cli_track_read(
376 path,
377 "lines",
378 original_tokens,
379 sent,
380 &output,
381 read_start.elapsed(),
382 );
383 }
384 _ => {
385 let mut output = format!("{short} [{line_count}L]\n{content}");
392 if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
393 let config = crate::core::config::Config::load();
394 let level = crate::core::config::CompressionLevel::effective(&config);
395 if level.is_active() {
396 let terse_result =
397 crate::core::terse::pipeline::compress(&output, &level, None);
398 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
399 output = terse_result.output;
400 }
401 }
402 }
403 let output = cap_cli_to_raw(output, &content, original_tokens);
406 println!("{output}");
407 let sent = count_tokens(&output);
408 super::common::cli_track_read(
409 path,
410 "full",
411 original_tokens,
412 sent,
413 &output,
414 read_start.elapsed(),
415 );
416 }
417 }
418}
419
420pub fn cmd_diff(args: &[String]) {
421 if args.len() < 2 {
422 eprintln!("Usage: lean-ctx diff <file1> <file2>");
423 std::process::exit(1);
424 }
425
426 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
427 Ok(c) => c,
428 Err(e) => {
429 eprintln!("Error reading {}: {e}", args[0]);
430 std::process::exit(1);
431 }
432 };
433
434 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
435 Ok(c) => c,
436 Err(e) => {
437 eprintln!("Error reading {}: {e}", args[1]);
438 std::process::exit(1);
439 }
440 };
441
442 let diff = compressor::diff_content(&content1, &content2);
443 let original = count_tokens(&content1) + count_tokens(&content2);
444 let sent = count_tokens(&diff);
445
446 println!(
447 "diff {} {}",
448 protocol::shorten_path(&args[0]),
449 protocol::shorten_path(&args[1])
450 );
451 println!("{diff}");
452 print_savings(original, sent);
453 crate::core::stats::record("cli_diff", original, sent);
454}
455
456pub fn cmd_grep(args: &[String]) {
457 if args.is_empty() {
458 eprintln!("Usage: lean-ctx grep <pattern> [path]");
459 std::process::exit(1);
460 }
461
462 let pattern = &args[0];
463 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
464 let abs_path = resolve_cli_path(raw_path);
465 let path = abs_path.as_str();
466
467 #[cfg(unix)]
468 {
469 #[cfg(unix)]
470 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
471 "ctx_search",
472 Some(serde_json::json!({
473 "pattern": pattern,
474 "path": path,
475 })),
476 ) {
477 let out = super::common::filter_daemon_output(&out);
478 println!("{out}");
479 if out.trim_start().starts_with("0 matches") {
480 std::process::exit(1);
481 }
482 return;
483 }
484 }
485 super::common::daemon_fallback_hint();
486
487 let search_start = std::time::Instant::now();
489
490 let outcome = crate::tools::ctx_search::handle(
491 pattern,
492 path,
493 None,
494 20,
495 crate::tools::CrpMode::effective(),
496 true,
497 roles::active_role().io.allow_secret_paths,
498 false,
499 );
500 let out = outcome.text;
501 println!("{out}");
502 super::common::cli_track_search(
503 outcome.modeled_baseline,
504 outcome.observed_tokens,
505 count_tokens(&out),
506 pattern,
507 path,
508 &out,
509 search_start.elapsed(),
510 );
511 if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
512 std::process::exit(1);
513 }
514}
515
516pub fn cmd_glob(args: &[String]) {
521 if args.is_empty() {
522 eprintln!("Usage: lean-ctx glob <pattern> [path]");
523 std::process::exit(1);
524 }
525
526 let pattern = &args[0];
527 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
528 let abs_path = resolve_cli_path(raw_path);
529 let path = abs_path.as_str();
530
531 #[cfg(unix)]
532 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
533 "ctx_glob",
534 Some(serde_json::json!({
535 "pattern": pattern,
536 "path": path,
537 })),
538 ) {
539 let out = super::common::filter_daemon_output(&out);
540 println!("{out}");
541 return;
542 }
543 super::common::daemon_fallback_hint();
544
545 let (out, _original) = crate::tools::ctx_glob::handle(
546 pattern,
547 path,
548 true,
549 roles::active_role().io.allow_secret_paths,
550 200,
551 );
552 println!("{out}");
553 crate::core::stats::record("cli_glob", 0, 0);
554 if out.starts_with("ERROR:") {
555 std::process::exit(1);
556 }
557}
558
559pub fn cmd_find(args: &[String]) {
560 if args.is_empty() {
561 eprintln!("Usage: lean-ctx find <pattern> [path]");
562 std::process::exit(1);
563 }
564
565 let raw_pattern = &args[0];
566 let path = args.get(1).map_or(".", std::string::String::as_str);
567
568 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
569 let glob_matcher = if is_glob {
570 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
571 } else {
572 None
573 };
574 let substring = raw_pattern.to_lowercase();
575
576 let mut found = false;
577 let walk_root = crate::core::walk_filter::explicit_walk_root(std::path::Path::new(path));
578 for entry in ignore::WalkBuilder::new(&walk_root)
579 .hidden(true)
580 .git_ignore(true)
581 .git_global(true)
582 .git_exclude(true)
583 .require_git(false)
584 .max_depth(Some(10))
585 .filter_entry(crate::core::walk_filter::keep_entry)
586 .build()
587 .flatten()
588 {
589 let name = entry.file_name().to_string_lossy().to_lowercase();
590 let matches = if let Some(ref g) = glob_matcher {
591 g.matches(&name)
592 } else {
593 name.contains(&substring)
594 };
595 if matches {
596 println!("{}", entry.path().display());
597 found = true;
598 }
599 }
600
601 crate::core::stats::record("cli_find", 0, 0);
602
603 if !found {
604 std::process::exit(1);
605 }
606}
607
608pub fn cmd_ls(args: &[String]) {
609 let mut raw_path = ".";
610 let mut depth = 3usize;
611 let mut show_hidden = false;
612 let mut respect_gitignore = true;
613 let mut i = 0;
614
615 while i < args.len() {
616 let arg = &args[i];
617 if arg == "--depth" {
618 i += 1;
619 if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
620 depth = d.min(10);
621 }
622 } else if arg == "--all" || arg == "-a" {
623 show_hidden = true;
624 } else if arg == "--no-gitignore" {
625 respect_gitignore = false;
626 } else if arg.starts_with('-') {
627 eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
628 eprintln!(
629 "lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement."
630 );
631 eprintln!(
632 "The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n"
633 );
634 eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
635 std::process::exit(1);
636 } else {
637 raw_path = arg;
638 }
639 i += 1;
640 }
641
642 let abs_path = resolve_cli_path(raw_path);
643 let path = abs_path.as_str();
644
645 #[cfg(unix)]
646 {
647 #[cfg(unix)]
648 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
649 "ctx_tree",
650 Some(serde_json::json!({
651 "path": path,
652 "depth": depth,
653 "show_hidden": show_hidden,
654 "respect_gitignore": respect_gitignore,
655 })),
656 ) {
657 println!("{}", super::common::filter_daemon_output(&out));
658 return;
659 }
660 }
661 super::common::daemon_fallback_hint();
662
663 let (out, original) =
664 crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
665 println!("{out}");
666 super::common::cli_track_tree(original, count_tokens(&out));
667}
668
669pub fn cmd_deps(args: &[String]) {
670 let path = args.first().map_or(".", std::string::String::as_str);
671
672 if let Some(result) = deps_cmd::detect_and_compress(path) {
673 println!("{result}");
674 crate::core::stats::record("cli_deps", 0, 0);
675 } else {
676 eprintln!("No dependency file found in {path}");
677 std::process::exit(1);
678 }
679}
680
681#[cfg(test)]
682mod cap_tests {
683 use super::{cap_cli_to_raw, count_tokens};
684
685 #[test]
686 fn caps_to_raw_when_framing_inflates() {
687 let raw = "x = 1\n";
691 let raw_tokens = count_tokens(raw);
692 let framed = format!("some/very/long/path/header.rs [1L]\n{raw}\n[lean-ctx: 0 tok saved]");
693 assert!(count_tokens(&framed) > raw_tokens, "fixture must inflate");
694 assert_eq!(cap_cli_to_raw(framed, raw, raw_tokens), raw);
695 }
696
697 #[test]
698 fn keeps_framing_when_it_saves() {
699 let raw = "fn a() {}\n".repeat(300);
701 let raw_tokens = count_tokens(&raw);
702 let framed = "f.rs [300L]\nfn a() {} …".to_string();
703 assert!(count_tokens(&framed) < raw_tokens);
704 assert_eq!(cap_cli_to_raw(framed.clone(), &raw, raw_tokens), framed);
705 }
706
707 #[test]
708 fn keeps_framing_for_empty_file() {
709 let framed = "empty.rs [0L]\n".to_string();
711 assert_eq!(cap_cli_to_raw(framed.clone(), "", 0), framed);
712 }
713
714 #[test]
715 fn break_even_is_not_inflation() {
716 let raw = "alpha beta gamma delta";
718 let raw_tokens = count_tokens(raw);
719 let framed = raw.to_string();
720 assert_eq!(count_tokens(&framed), raw_tokens);
721 assert_eq!(cap_cli_to_raw(framed.clone(), raw, raw_tokens), framed);
722 }
723
724 #[test]
725 fn emitted_never_exceeds_raw_across_sizes() {
726 for n in [1usize, 5, 50, 500] {
729 let raw = "data line here\n".repeat(n);
730 let raw_tokens = count_tokens(&raw);
731 let framed = format!("a/b/c/path.txt [{n}L]\n{raw}\n[lean-ctx: {n} tok saved ({n}%)]");
732 let out = cap_cli_to_raw(framed, &raw, raw_tokens);
733 assert!(
734 count_tokens(&out) <= raw_tokens,
735 "n={n}: emitted {} tok exceeds raw {raw_tokens}",
736 count_tokens(&out)
737 );
738 }
739 }
740}
741
742#[cfg(test)]
743mod fresh_tests {
744 use super::should_force_fresh;
745
746 #[test]
747 fn hook_child_forces_fresh_even_without_flags() {
748 assert!(should_force_fresh(&[], true));
751 assert!(!should_force_fresh(&[], false));
752 }
753
754 #[test]
755 fn explicit_flags_force_fresh() {
756 assert!(should_force_fresh(&["--fresh".to_string()], false));
757 assert!(should_force_fresh(&["--no-cache".to_string()], false));
758 assert!(!should_force_fresh(&["file.rs".to_string()], false));
759 }
760}
761
762#[cfg(test)]
763mod mode_arg_tests {
764 use super::resolve_cli_read_mode;
765
766 fn args(list: &[&str]) -> Vec<String> {
767 list.iter().map(ToString::to_string).collect()
768 }
769
770 #[test]
774 fn positional_mode_is_honoured() {
775 assert_eq!(resolve_cli_read_mode(&args(&["f.ps1", "map"])), "map");
776 assert_eq!(
777 resolve_cli_read_mode(&args(&["f.rs", "lines:5-10"])),
778 "lines:5-10"
779 );
780 }
781
782 #[test]
783 fn mode_flag_wins_over_positional() {
784 assert_eq!(
785 resolve_cli_read_mode(&args(&["f.rs", "map", "--mode", "signatures"])),
786 "signatures"
787 );
788 assert_eq!(
789 resolve_cli_read_mode(&args(&["f.rs", "-m", "full"])),
790 "full"
791 );
792 }
793
794 #[test]
795 fn defaults_to_auto_and_skips_flags_and_junk() {
796 assert_eq!(resolve_cli_read_mode(&args(&["f.rs"])), "auto");
797 assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "--fresh"])), "auto");
798 assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "banana"])), "auto");
800 }
801}