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