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 = mode.as_str();
226
227 match mode {
228 "map" => {
229 let structured = match ext {
230 "md" | "mdx" | "rst" => {
231 crate::core::structured_read::extract_markdown_outline(&content)
232 }
233 "json" => crate::core::structured_read::extract_json_structure(&content),
234 "yaml" | "yml" => crate::core::structured_read::extract_yaml_structure(&content),
235 "toml" => crate::core::structured_read::extract_toml_structure(&content),
236 _ if path.to_lowercase().ends_with(".lock")
237 || path.to_lowercase().ends_with("go.sum") =>
238 {
239 crate::core::structured_read::extract_lock_summary(&content, path)
240 }
241 _ => String::new(),
242 };
243
244 let mut output_buf = if structured.is_empty() {
245 let sigs = signatures::extract_signatures(&content, ext);
246 let dep_info = dep_extract::extract_deps(&content, ext);
247 let mut buf = format!("{short} [{line_count}L]");
248 if !dep_info.imports.is_empty() {
249 buf.push_str(&format!("\n deps: {}", dep_info.imports.join(", ")));
250 }
251 let key_sigs: Vec<&signatures::Signature> = sigs
252 .iter()
253 .filter(|s| s.is_exported || s.indent == 0)
254 .collect();
255 let extra_exports =
259 signatures::exports_not_in_signatures(&dep_info.exports, &key_sigs);
260 if !extra_exports.is_empty() {
261 buf.push_str(&format!("\n exports: {}", extra_exports.join(", ")));
262 }
263 if !key_sigs.is_empty() {
264 buf.push_str("\n API:");
265 for sig in &key_sigs {
266 buf.push_str(&format!("\n {}", sig.to_compact_located()));
267 }
268 }
269 if key_sigs.is_empty() && dep_info.imports.is_empty() && extra_exports.is_empty() {
272 buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
273 }
274 buf
275 } else {
276 format!("{short} [{line_count}L]\n{structured}")
277 };
278
279 let sent = count_tokens(&output_buf);
280 output_buf = protocol::append_savings(&output_buf, original_tokens, sent);
281 if requested_auto {
282 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
283 }
284 let sent = count_tokens(&output_buf);
285 println!("{output_buf}");
286 super::common::cli_track_read(
287 path,
288 "map",
289 original_tokens,
290 sent,
291 &output_buf,
292 read_start.elapsed(),
293 );
294 }
295 "signatures" => {
296 let sigs = signatures::extract_signatures(&content, ext);
297 let mut output_buf = format!("{short} [{line_count}L]");
298 for sig in &sigs {
299 output_buf.push_str(&format!("\n{}", sig.to_compact_located()));
300 }
301 if sigs.is_empty() {
303 output_buf.push_str(&crate::tools::ctx_read::no_structure_marker(ext));
304 }
305 if requested_auto {
306 output_buf = cap_cli_to_raw(output_buf, &content, original_tokens);
307 }
308 println!("{output_buf}");
309 let sent = count_tokens(&output_buf);
310 print_savings(original_tokens, sent);
311 super::common::cli_track_read(
312 path,
313 "signatures",
314 original_tokens,
315 sent,
316 &output_buf,
317 read_start.elapsed(),
318 );
319 }
320 "aggressive" => {
321 let compressed = compressor::aggressive_compress(&content, Some(ext));
322 println!("{short} [{line_count}L]");
323 println!("{compressed}");
324 let sent = count_tokens(&compressed);
325 print_savings(original_tokens, sent);
326 super::common::cli_track_read(
327 path,
328 "aggressive",
329 original_tokens,
330 sent,
331 &compressed,
332 read_start.elapsed(),
333 );
334 }
335 "entropy" => {
336 let result = entropy::entropy_compress(&content);
337 let avg_h = entropy::analyze_entropy(&content).avg_entropy;
338 println!("{short} [{line_count}L] (H̄={avg_h:.1})");
339 for tech in &result.techniques {
340 println!("{tech}");
341 }
342 println!("{}", result.output);
343 let sent = count_tokens(&result.output);
344 print_savings(original_tokens, sent);
345 super::common::cli_track_read(
346 path,
347 "entropy",
348 original_tokens,
349 sent,
350 &result.output,
351 read_start.elapsed(),
352 );
353 }
354 m if m.starts_with("lines:") => {
355 let range_str = &m[6..];
359 let extracted = crate::tools::ctx_read::extract_line_range(&content, range_str);
360 let multi_hint = if range_str.contains(',') {
361 crate::tools::ctx_read::LINES_COMMA_HINT
362 } else {
363 ""
364 };
365 let output =
366 format!("{short} [{line_count}L] lines:{range_str}\n{extracted}{multi_hint}");
367 println!("{output}");
368 let sent = count_tokens(&output);
369 print_savings(original_tokens, sent);
370 super::common::cli_track_read(
371 path,
372 "lines",
373 original_tokens,
374 sent,
375 &output,
376 read_start.elapsed(),
377 );
378 }
379 _ => {
380 let mut output = format!("{short} [{line_count}L]\n{content}");
387 if !crate::core::terse::is_verbatim_read("ctx_read", Some(mode)) {
388 let config = crate::core::config::Config::load();
389 let level = crate::core::config::CompressionLevel::effective(&config);
390 if level.is_active() {
391 let terse_result =
392 crate::core::terse::pipeline::compress(&output, &level, None);
393 if terse_result.quality_passed && terse_result.savings_pct >= 3.0 {
394 output = terse_result.output;
395 }
396 }
397 }
398 let output = cap_cli_to_raw(output, &content, original_tokens);
401 println!("{output}");
402 let sent = count_tokens(&output);
403 super::common::cli_track_read(
404 path,
405 "full",
406 original_tokens,
407 sent,
408 &output,
409 read_start.elapsed(),
410 );
411 }
412 }
413}
414
415pub fn cmd_diff(args: &[String]) {
416 if args.len() < 2 {
417 eprintln!("Usage: lean-ctx diff <file1> <file2>");
418 std::process::exit(1);
419 }
420
421 let content1 = match crate::tools::ctx_read::read_file_lossy(&args[0]) {
422 Ok(c) => c,
423 Err(e) => {
424 eprintln!("Error reading {}: {e}", args[0]);
425 std::process::exit(1);
426 }
427 };
428
429 let content2 = match crate::tools::ctx_read::read_file_lossy(&args[1]) {
430 Ok(c) => c,
431 Err(e) => {
432 eprintln!("Error reading {}: {e}", args[1]);
433 std::process::exit(1);
434 }
435 };
436
437 let diff = compressor::diff_content(&content1, &content2);
438 let original = count_tokens(&content1) + count_tokens(&content2);
439 let sent = count_tokens(&diff);
440
441 println!(
442 "diff {} {}",
443 protocol::shorten_path(&args[0]),
444 protocol::shorten_path(&args[1])
445 );
446 println!("{diff}");
447 print_savings(original, sent);
448 crate::core::stats::record("cli_diff", original, sent);
449}
450
451pub fn cmd_grep(args: &[String]) {
452 if args.is_empty() {
453 eprintln!("Usage: lean-ctx grep <pattern> [path]");
454 std::process::exit(1);
455 }
456
457 let pattern = &args[0];
458 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
459 let abs_path = resolve_cli_path(raw_path);
460 let path = abs_path.as_str();
461
462 #[cfg(unix)]
463 {
464 #[cfg(unix)]
465 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
466 "ctx_search",
467 Some(serde_json::json!({
468 "pattern": pattern,
469 "path": path,
470 })),
471 ) {
472 let out = super::common::filter_daemon_output(&out);
473 println!("{out}");
474 if out.trim_start().starts_with("0 matches") {
475 std::process::exit(1);
476 }
477 return;
478 }
479 }
480 super::common::daemon_fallback_hint();
481
482 let search_start = std::time::Instant::now();
484
485 let outcome = crate::tools::ctx_search::handle(
486 pattern,
487 path,
488 None,
489 20,
490 crate::tools::CrpMode::effective(),
491 true,
492 roles::active_role().io.allow_secret_paths,
493 false,
494 );
495 let out = outcome.text;
496 println!("{out}");
497 super::common::cli_track_search(
498 outcome.modeled_baseline,
499 outcome.observed_tokens,
500 count_tokens(&out),
501 pattern,
502 path,
503 &out,
504 search_start.elapsed(),
505 );
506 if outcome.modeled_baseline == 0 && out.trim_start().starts_with("0 matches") {
507 std::process::exit(1);
508 }
509}
510
511pub fn cmd_glob(args: &[String]) {
516 if args.is_empty() {
517 eprintln!("Usage: lean-ctx glob <pattern> [path]");
518 std::process::exit(1);
519 }
520
521 let pattern = &args[0];
522 let raw_path = args.get(1).map_or(".", std::string::String::as_str);
523 let abs_path = resolve_cli_path(raw_path);
524 let path = abs_path.as_str();
525
526 #[cfg(unix)]
527 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
528 "ctx_glob",
529 Some(serde_json::json!({
530 "pattern": pattern,
531 "path": path,
532 })),
533 ) {
534 let out = super::common::filter_daemon_output(&out);
535 println!("{out}");
536 return;
537 }
538 super::common::daemon_fallback_hint();
539
540 let (out, _original) = crate::tools::ctx_glob::handle(
541 pattern,
542 path,
543 true,
544 roles::active_role().io.allow_secret_paths,
545 200,
546 );
547 println!("{out}");
548 crate::core::stats::record("cli_glob", 0, 0);
549 if out.starts_with("ERROR:") {
550 std::process::exit(1);
551 }
552}
553
554pub fn cmd_find(args: &[String]) {
555 if args.is_empty() {
556 eprintln!("Usage: lean-ctx find <pattern> [path]");
557 std::process::exit(1);
558 }
559
560 let raw_pattern = &args[0];
561 let path = args.get(1).map_or(".", std::string::String::as_str);
562
563 let is_glob = raw_pattern.contains('*') || raw_pattern.contains('?');
564 let glob_matcher = if is_glob {
565 glob::Pattern::new(&raw_pattern.to_lowercase()).ok()
566 } else {
567 None
568 };
569 let substring = raw_pattern.to_lowercase();
570
571 let mut found = false;
572 let walk_root = crate::core::walk_filter::explicit_walk_root(std::path::Path::new(path));
573 for entry in ignore::WalkBuilder::new(&walk_root)
574 .hidden(true)
575 .git_ignore(true)
576 .git_global(true)
577 .git_exclude(true)
578 .require_git(false)
579 .max_depth(Some(10))
580 .filter_entry(crate::core::walk_filter::keep_entry)
581 .build()
582 .flatten()
583 {
584 let name = entry.file_name().to_string_lossy().to_lowercase();
585 let matches = if let Some(ref g) = glob_matcher {
586 g.matches(&name)
587 } else {
588 name.contains(&substring)
589 };
590 if matches {
591 println!("{}", entry.path().display());
592 found = true;
593 }
594 }
595
596 crate::core::stats::record("cli_find", 0, 0);
597
598 if !found {
599 std::process::exit(1);
600 }
601}
602
603pub fn cmd_ls(args: &[String]) {
604 let mut raw_path = ".";
605 let mut depth = 3usize;
606 let mut show_hidden = false;
607 let mut respect_gitignore = true;
608 let mut i = 0;
609
610 while i < args.len() {
611 let arg = &args[i];
612 if arg == "--depth" {
613 i += 1;
614 if let Some(d) = args.get(i).and_then(|s| s.parse::<usize>().ok()) {
615 depth = d.min(10);
616 }
617 } else if arg == "--all" || arg == "-a" {
618 show_hidden = true;
619 } else if arg == "--no-gitignore" {
620 respect_gitignore = false;
621 } else if arg.starts_with('-') {
622 eprintln!("Error: lean-ctx ls does not support flag '{arg}'.\n");
623 eprintln!(
624 "lean-ctx ls is a compressed directory tree viewer for AI context, not a drop-in ls replacement."
625 );
626 eprintln!(
627 "The shell hook (lean-ctx -t ls {arg} ...) passes flags to system ls transparently.\n"
628 );
629 eprintln!("Usage: lean-ctx ls [path] [--depth N] [--all] [--no-gitignore]");
630 std::process::exit(1);
631 } else {
632 raw_path = arg;
633 }
634 i += 1;
635 }
636
637 let abs_path = resolve_cli_path(raw_path);
638 let path = abs_path.as_str();
639
640 #[cfg(unix)]
641 {
642 #[cfg(unix)]
643 if let Some(out) = crate::daemon_client::try_daemon_tool_call_blocking_text(
644 "ctx_tree",
645 Some(serde_json::json!({
646 "path": path,
647 "depth": depth,
648 "show_hidden": show_hidden,
649 "respect_gitignore": respect_gitignore,
650 })),
651 ) {
652 println!("{}", super::common::filter_daemon_output(&out));
653 return;
654 }
655 }
656 super::common::daemon_fallback_hint();
657
658 let (out, original) =
659 crate::tools::ctx_tree::handle(path, depth, show_hidden, respect_gitignore);
660 println!("{out}");
661 super::common::cli_track_tree(original, count_tokens(&out));
662}
663
664pub fn cmd_deps(args: &[String]) {
665 let path = args.first().map_or(".", std::string::String::as_str);
666
667 if let Some(result) = deps_cmd::detect_and_compress(path) {
668 println!("{result}");
669 crate::core::stats::record("cli_deps", 0, 0);
670 } else {
671 eprintln!("No dependency file found in {path}");
672 std::process::exit(1);
673 }
674}
675
676#[cfg(test)]
677mod cap_tests {
678 use super::{cap_cli_to_raw, count_tokens};
679
680 #[test]
681 fn caps_to_raw_when_framing_inflates() {
682 let raw = "x = 1\n";
686 let raw_tokens = count_tokens(raw);
687 let framed = format!("some/very/long/path/header.rs [1L]\n{raw}\n[lean-ctx: 0 tok saved]");
688 assert!(count_tokens(&framed) > raw_tokens, "fixture must inflate");
689 assert_eq!(cap_cli_to_raw(framed, raw, raw_tokens), raw);
690 }
691
692 #[test]
693 fn keeps_framing_when_it_saves() {
694 let raw = "fn a() {}\n".repeat(300);
696 let raw_tokens = count_tokens(&raw);
697 let framed = "f.rs [300L]\nfn a() {} …".to_string();
698 assert!(count_tokens(&framed) < raw_tokens);
699 assert_eq!(cap_cli_to_raw(framed.clone(), &raw, raw_tokens), framed);
700 }
701
702 #[test]
703 fn keeps_framing_for_empty_file() {
704 let framed = "empty.rs [0L]\n".to_string();
706 assert_eq!(cap_cli_to_raw(framed.clone(), "", 0), framed);
707 }
708
709 #[test]
710 fn break_even_is_not_inflation() {
711 let raw = "alpha beta gamma delta";
713 let raw_tokens = count_tokens(raw);
714 let framed = raw.to_string();
715 assert_eq!(count_tokens(&framed), raw_tokens);
716 assert_eq!(cap_cli_to_raw(framed.clone(), raw, raw_tokens), framed);
717 }
718
719 #[test]
720 fn emitted_never_exceeds_raw_across_sizes() {
721 for n in [1usize, 5, 50, 500] {
724 let raw = "data line here\n".repeat(n);
725 let raw_tokens = count_tokens(&raw);
726 let framed = format!("a/b/c/path.txt [{n}L]\n{raw}\n[lean-ctx: {n} tok saved ({n}%)]");
727 let out = cap_cli_to_raw(framed, &raw, raw_tokens);
728 assert!(
729 count_tokens(&out) <= raw_tokens,
730 "n={n}: emitted {} tok exceeds raw {raw_tokens}",
731 count_tokens(&out)
732 );
733 }
734 }
735}
736
737#[cfg(test)]
738mod fresh_tests {
739 use super::should_force_fresh;
740
741 #[test]
742 fn hook_child_forces_fresh_even_without_flags() {
743 assert!(should_force_fresh(&[], true));
746 assert!(!should_force_fresh(&[], false));
747 }
748
749 #[test]
750 fn explicit_flags_force_fresh() {
751 assert!(should_force_fresh(&["--fresh".to_string()], false));
752 assert!(should_force_fresh(&["--no-cache".to_string()], false));
753 assert!(!should_force_fresh(&["file.rs".to_string()], false));
754 }
755}
756
757#[cfg(test)]
758mod mode_arg_tests {
759 use super::resolve_cli_read_mode;
760
761 fn args(list: &[&str]) -> Vec<String> {
762 list.iter().map(ToString::to_string).collect()
763 }
764
765 #[test]
769 fn positional_mode_is_honoured() {
770 assert_eq!(resolve_cli_read_mode(&args(&["f.ps1", "map"])), "map");
771 assert_eq!(
772 resolve_cli_read_mode(&args(&["f.rs", "lines:5-10"])),
773 "lines:5-10"
774 );
775 }
776
777 #[test]
778 fn mode_flag_wins_over_positional() {
779 assert_eq!(
780 resolve_cli_read_mode(&args(&["f.rs", "map", "--mode", "signatures"])),
781 "signatures"
782 );
783 assert_eq!(
784 resolve_cli_read_mode(&args(&["f.rs", "-m", "full"])),
785 "full"
786 );
787 }
788
789 #[test]
790 fn defaults_to_auto_and_skips_flags_and_junk() {
791 assert_eq!(resolve_cli_read_mode(&args(&["f.rs"])), "auto");
792 assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "--fresh"])), "auto");
793 assert_eq!(resolve_cli_read_mode(&args(&["f.rs", "banana"])), "auto");
795 }
796}