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