1use clap::{Parser, Subcommand};
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4
5mod adapters;
6mod calls;
7mod context;
8mod core;
9mod deps;
10mod file_filter;
11mod graph_cache;
12mod hook;
13mod impact;
14mod installers;
15mod main_helpers;
16mod mcp;
17mod path_glob;
18mod project_root;
19mod prompt;
20mod run;
21mod search;
22mod squeeze;
23mod surface;
24
25use crate::core::{DigestOptions, MapOptions, ParseResult};
26
27#[derive(Parser)]
28#[command(name = "ast-bro")]
29#[command(version)]
30#[command(about = "Fast, AST-based structural outline for source files", long_about = None)]
31struct Cli {
32 #[command(subcommand)]
33 command: Commands,
34}
35
36#[derive(Subcommand)]
37enum Commands {
38 Map {
40 #[arg(num_args = 1..)]
42 paths: Vec<PathBuf>,
43
44 #[arg(long)]
45 no_private: bool,
46 #[arg(long)]
47 no_fields: bool,
48 #[arg(long)]
49 no_docs: bool,
50 #[arg(long)]
51 no_attrs: bool,
52 #[arg(long)]
53 no_lines: bool,
54 #[arg(long)]
55 glob: Option<String>,
56 #[arg(long)]
58 json: bool,
59 #[arg(long)]
61 compact: bool,
62 },
63 Show {
65 path: PathBuf,
66 symbol: String,
67 #[arg(num_args = 0..)]
68 others: Vec<String>,
69 #[arg(long)]
71 json: bool,
72 #[arg(long)]
74 compact: bool,
75 },
76 Squeeze {
83 path: PathBuf,
85 #[arg(value_parser = parse_line_range)]
87 range: Option<LineRange>,
88 #[arg(long)]
90 raw: bool,
91 #[arg(long)]
93 json: bool,
94 #[arg(long)]
96 compact: bool,
97 },
98 Digest {
100 #[arg(num_args = 1..)]
101 paths: Vec<PathBuf>,
102
103 #[arg(long)]
104 include_private: bool,
105 #[arg(long)]
106 include_fields: bool,
107 #[arg(long, default_value_t = 50)]
108 max_members: usize,
109 #[arg(long)]
111 json: bool,
112 #[arg(long)]
114 compact: bool,
115 },
116 Implements {
118 target: String,
119 #[arg(num_args = 1..)]
120 paths: Vec<PathBuf>,
121
122 #[arg(short, long)]
123 direct: bool,
124 #[arg(long)]
126 json: bool,
127 #[arg(long)]
129 compact: bool,
130 },
131 Prompt,
133 Install {
135 #[arg(long, conflicts_with = "all")]
136 target: Option<String>,
137 #[arg(long, conflicts_with = "target")]
138 all: bool,
139 #[arg(long)]
140 local: bool,
141 #[arg(long, conflicts_with = "local")]
142 global: bool,
143 #[arg(long)]
144 always: bool,
145 #[arg(long, default_value_t = 200)]
146 min_lines: usize,
147 #[arg(long)]
148 dry_run: bool,
149 #[arg(long)]
150 force: bool,
151 #[arg(long)]
154 mcp: bool,
155 #[arg(long)]
158 skills: bool,
159 },
160 Uninstall {
162 #[arg(long, conflicts_with = "all")]
163 target: Option<String>,
164 #[arg(long, conflicts_with = "target")]
165 all: bool,
166 #[arg(long)]
167 local: bool,
168 #[arg(long, conflicts_with = "local")]
169 global: bool,
170 #[arg(long)]
171 dry_run: bool,
172 },
173 Status {
175 #[arg(long)]
176 local: bool,
177 #[arg(long, conflicts_with = "local")]
178 global: bool,
179 },
180 Hook {
182 #[arg(long)]
183 protocol: String,
184 #[arg(long, default_value_t = 200)]
185 min_lines: usize,
186 #[arg(long)]
187 always: bool,
188 },
189 Mcp,
191 Search {
193 query: String,
195 #[arg(default_value = ".")]
197 path: PathBuf,
198 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
200 top_k: usize,
201 #[arg(long)]
203 alpha: Option<f32>,
204 #[arg(long = "lang")]
206 languages: Vec<String>,
207 #[arg(long)]
209 rebuild: bool,
210 #[arg(long)]
212 json: bool,
213 #[arg(long)]
215 compact: bool,
216 },
217 FindRelated {
223 #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
226 target: Option<String>,
227 #[arg(default_value = ".")]
229 path: PathBuf,
230 #[arg(long, requires = "line")]
232 file: Option<String>,
233 #[arg(long, requires = "file")]
235 line: Option<u32>,
236 #[arg(short = 'k', long = "top-k", default_value_t = 10)]
237 top_k: usize,
238 #[arg(long)]
239 json: bool,
240 #[arg(long)]
241 compact: bool,
242 },
243 Surface {
245 #[arg(default_value = ".")]
247 path: PathBuf,
248 #[arg(long)]
250 tree: bool,
251 #[arg(long)]
253 include_chain: bool,
254 #[arg(long, default_value_t = 16)]
256 max_depth: usize,
257 #[arg(long)]
259 include_private: bool,
260 #[arg(long)]
262 lang: Option<String>,
263 #[arg(long)]
265 json: bool,
266 #[arg(long)]
268 compact: bool,
269 },
270 Deps {
272 file: PathBuf,
273 #[arg(long, default_value_t = 3)]
274 depth: usize,
275 #[arg(long)]
278 hide_external: bool,
279 #[arg(long)]
281 rebuild: bool,
282 #[arg(long)]
283 json: bool,
284 #[arg(long)]
285 compact: bool,
286 },
287 ReverseDeps {
289 file: PathBuf,
290 #[arg(long, default_value_t = 3)]
291 depth: usize,
292 #[arg(long, default_value_t = 200)]
293 limit: usize,
294 #[arg(long)]
296 tests: bool,
297 #[arg(long, conflicts_with = "tests")]
299 exclude_tests: bool,
300 #[arg(long)]
301 rebuild: bool,
302 #[arg(long)]
303 json: bool,
304 #[arg(long)]
305 compact: bool,
306 },
307 Cycles {
309 #[arg(default_value = ".")]
310 path: PathBuf,
311 #[arg(long, default_value_t = 2)]
312 min_size: usize,
313 #[arg(long)]
314 rebuild: bool,
315 #[arg(long)]
316 json: bool,
317 #[arg(long)]
318 compact: bool,
319 },
320 Graph {
322 #[arg(default_value = ".")]
323 path: PathBuf,
324 #[arg(long)]
325 json: bool,
326 #[arg(long)]
329 hide_external: bool,
330 #[arg(long, hide = true)]
332 include_external: bool,
333 #[arg(long)]
334 rebuild: bool,
335 #[arg(long)]
336 compact: bool,
337 },
338 Context {
340 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
342 target: Option<String>,
343 #[arg(default_value = ".")]
345 path: PathBuf,
346 #[arg(long, requires = "symbol")]
348 file: Option<String>,
349 #[arg(long, requires = "file")]
351 symbol: Option<String>,
352 #[arg(long, default_value_t = 8000)]
354 budget: usize,
355 #[arg(long)]
356 rebuild: bool,
357 #[arg(long)]
358 json: bool,
359 #[arg(long)]
360 compact: bool,
361 },
362 Callers {
369 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
371 target: Option<String>,
372 #[arg(default_value = ".")]
374 path: PathBuf,
375 #[arg(long, requires = "symbol")]
377 file: Option<String>,
378 #[arg(long, requires = "file")]
380 symbol: Option<String>,
381 #[arg(long, default_value_t = 1)]
383 depth: usize,
384 #[arg(long, default_value_t = 200)]
386 limit: usize,
387 #[arg(long)]
390 hide_ambiguous: bool,
391 #[arg(long, hide = true)]
393 include_ambiguous: bool,
394 #[arg(long)]
396 tests: bool,
397 #[arg(long, conflicts_with = "tests")]
399 exclude_tests: bool,
400 #[arg(long)]
402 rebuild: bool,
403 #[arg(long)]
404 json: bool,
405 #[arg(long)]
406 compact: bool,
407 },
408 Callees {
413 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
414 target: Option<String>,
415 #[arg(default_value = ".")]
416 path: PathBuf,
417 #[arg(long, requires = "symbol")]
418 file: Option<String>,
419 #[arg(long, requires = "file")]
420 symbol: Option<String>,
421 #[arg(long, default_value_t = 1)]
422 depth: usize,
423 #[arg(long)]
426 hide_external: bool,
427 #[arg(long, hide = true)]
429 external: bool,
430 #[arg(long)]
431 rebuild: bool,
432 #[arg(long)]
433 json: bool,
434 #[arg(long)]
435 compact: bool,
436 },
437 Trace {
446 from: String,
448 to: String,
450 #[arg(default_value = ".")]
452 path: PathBuf,
453 #[arg(long, default_value_t = 12)]
455 depth: usize,
456 #[arg(long)]
458 rebuild: bool,
459 #[arg(long)]
460 json: bool,
461 #[arg(long)]
462 compact: bool,
463 },
464 Impact {
466 #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
468 target: Option<String>,
469 #[arg(default_value = ".")]
471 path: PathBuf,
472 #[arg(long, requires = "symbol")]
474 file: Option<String>,
475 #[arg(long, requires = "file")]
477 symbol: Option<String>,
478 #[arg(long, default_value_t = 2)]
480 depth: usize,
481 #[arg(long, default_value_t = 200)]
483 limit: usize,
484 #[arg(long, default_value = "all")]
486 mode: String,
487 #[arg(long)]
490 hide_ambiguous: bool,
491 #[arg(long)]
493 tests: bool,
494 #[arg(long, conflicts_with = "tests")]
496 exclude_tests: bool,
497 #[arg(long)]
498 rebuild: bool,
499 #[arg(long)]
500 json: bool,
501 #[arg(long)]
502 compact: bool,
503 },
504 Index {
506 #[arg(default_value = ".")]
508 path: PathBuf,
509 #[arg(long)]
511 rebuild: bool,
512 #[arg(long)]
514 stats: bool,
515 #[arg(long)]
517 json: bool,
518 #[arg(long)]
520 compact: bool,
521 },
522 Run {
524 #[arg(short, long)]
526 pattern: String,
527
528 #[arg(short, long)]
530 rewrite: Option<String>,
531
532 #[arg(short, long)]
534 lang: Option<String>,
535
536 paths: Vec<PathBuf>,
538
539 #[arg(long)]
541 glob: Option<String>,
542
543 #[arg(long)]
545 write: bool,
546
547 #[arg(long)]
549 json: bool,
550
551 #[arg(long)]
553 compact: bool,
554 },
555}
556
557pub(crate) fn parse_file(path: &Path) -> Option<ParseResult> {
558 crate::main_helpers::parse_file_for_hook(path)
559}
560
561#[derive(Clone, Debug)]
565pub struct LineRange {
566 pub start: Option<usize>,
567 pub end: Option<usize>,
568}
569
570impl LineRange {
571 fn resolve(&self, line_count: usize) -> (usize, usize) {
575 clamp_line_range(self.start.unwrap_or(1), self.end, line_count)
576 }
577}
578
579pub(crate) fn clamp_line_range(
581 start: usize,
582 end: Option<usize>,
583 line_count: usize,
584) -> (usize, usize) {
585 let end = end.unwrap_or(line_count).min(line_count).max(start);
586 (start, end)
587}
588
589fn parse_line_range(s: &str) -> Result<LineRange, String> {
594 let parse_bound = |part: &str| -> Result<Option<usize>, String> {
595 if part.is_empty() {
596 return Ok(None);
597 }
598 match part.parse::<usize>() {
599 Ok(0) => Err("line numbers are 1-indexed (got 0)".to_string()),
600 Ok(n) => Ok(Some(n)),
601 Err(_) => Err(format!("invalid line number: {part:?}")),
602 }
603 };
604
605 let range = match s.split_once(':') {
606 Some((a, b)) => LineRange {
608 start: parse_bound(a)?,
609 end: parse_bound(b)?,
610 },
611 None => {
613 let n = parse_bound(s)?;
614 LineRange { start: n, end: n }
615 }
616 };
617
618 if let (Some(start), Some(end)) = (range.start, range.end) {
619 if start > end {
620 return Err(format!("range start {start} is after end {end}"));
621 }
622 }
623
624 Ok(range)
625}
626
627fn parse_file_line(s: &str) -> Option<(String, u32)> {
630 let (file, line) = s.rsplit_once(':')?;
631 if file.is_empty() {
632 return None;
633 }
634 Some((file.to_string(), line.parse().ok()?))
635}
636
637fn build_filtered_walker(
640 paths: &[PathBuf],
641 glob_str: Option<&str>,
642) -> Option<(WalkBuilder, Vec<PathBuf>)> {
643 if paths.is_empty() {
644 return None;
645 }
646
647 let existing: Vec<PathBuf> = paths
648 .iter()
649 .flat_map(|p| {
650 let expanded = path_glob::expand_existing(p);
651 if expanded.is_empty() {
652 println!("# note: path not found: {}", p.display());
653 }
654 expanded
655 })
656 .collect();
657 if existing.is_empty() {
658 return None;
659 }
660
661 let mut builder = WalkBuilder::new(&existing[0]);
662 for p in existing.iter().skip(1) {
663 builder.add(p);
664 }
665
666 builder.hidden(false);
667 file_filter::add_filters(&mut builder, &existing[0]);
668
669 if let Some(g) = glob_str {
670 if let Ok(override_builder) = ignore::overrides::OverrideBuilder::new("").add(g) {
671 if let Ok(over) = override_builder.build() {
672 builder.overrides(over);
673 }
674 }
675 }
676
677 Some((builder, existing))
678}
679
680pub(crate) fn walk_paths(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<PathBuf> {
681 let (tx, rx) = std::sync::mpsc::channel();
682 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
683 return Vec::new();
684 };
685 let walker = builder.build_parallel();
686 let root = existing[0].clone();
687
688 walker.run(|| {
689 let tx = tx.clone();
690 let root = root.clone();
691 Box::new(move |result| {
692 if let Ok(entry) = result {
693 if entry.file_type().is_some_and(|ft| ft.is_file())
694 && !file_filter::should_skip_path(entry.path(), &root)
695 {
696 let _ = tx.send(entry.path().to_path_buf());
697 }
698 }
699 ignore::WalkState::Continue
700 })
701 });
702
703 drop(tx);
704 let mut results: Vec<_> = rx.into_iter().collect();
705 results.sort();
706 results
707}
708
709pub(crate) fn walk_and_parse(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<ParseResult> {
710 let (tx, rx) = std::sync::mpsc::channel();
711 let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
712 return Vec::new();
713 };
714 let walker = builder.build_parallel();
715 let root = existing[0].clone();
716
717 walker.run(|| {
718 let tx = tx.clone();
719 let root = root.clone();
720 Box::new(move |result| {
721 if let Ok(entry) = result {
722 if entry.file_type().is_some_and(|ft| ft.is_file())
723 && !file_filter::should_skip_path(entry.path(), &root)
724 {
725 if let Some(parsed) = parse_file(entry.path()) {
726 let _ = tx.send(parsed);
727 }
728 }
729 }
730 ignore::WalkState::Continue
731 })
732 });
733
734 drop(tx);
735 let mut results: Vec<_> = rx.into_iter().collect();
736 results.sort_by(|a, b| a.path.cmp(&b.path));
737 results
738}
739
740pub fn run() {
741 use clap::error::ErrorKind;
742 use clap::CommandFactory;
743
744 let cli = match Cli::try_parse() {
750 Ok(c) => c,
751 Err(e) => match e.kind() {
752 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
753 e.exit();
754 }
755 _ => {
756 let mut cmd = Cli::command();
757 let _ = cmd.print_help();
758 println!();
759 println!(
760 "# note: could not parse args ({}). Showing help instead.",
761 e.kind()
762 );
763 std::process::exit(0);
764 }
765 },
766 };
767
768 match &cli.command {
769 Commands::Map {
770 paths,
771 no_private,
772 no_fields,
773 no_docs,
774 no_attrs,
775 no_lines,
776 glob,
777 json,
778 compact,
779 } => {
780 let results = walk_and_parse(paths, glob.as_deref());
781 let opts = MapOptions {
782 include_private: !(*no_private),
783 include_fields: !(*no_fields),
784 include_docs: !(*no_docs),
785 include_attributes: !(*no_attrs),
786 include_line_numbers: !(*no_lines),
787 max_doc_lines: 6,
788 max_members: None,
789 };
790 let json_on = *json;
791 let pretty = !(*compact);
792 if json_on {
793 println!("{}", crate::core::render_json_map(&results, &opts, pretty));
794 } else {
795 for res in results {
796 println!("{}", crate::core::render_map(&res, &opts));
797 println!();
798 }
799 }
800 }
801 Commands::Show {
802 path,
803 symbol,
804 others,
805 json,
806 compact,
807 } => {
808 if !path.exists() {
809 println!("# note: path not found: {}", path.display());
810 } else if let Some(res) = parse_file(path) {
811 let mut symbols = vec![symbol.as_str()];
812 symbols.extend(others.iter().map(|s| s.as_str()));
813 if *json {
814 let mut seen = std::collections::HashSet::new();
815 let mut all_matches = Vec::new();
816 for sym in &symbols {
817 for m in crate::core::find_symbols(&res, sym) {
818 let key = (m.start_line, m.end_line, m.qualified_name.clone());
819 if seen.insert(key) {
820 all_matches.push(m);
821 }
822 }
823 }
824 println!(
825 "{}",
826 crate::core::render_json_show(&res, &all_matches, !(*compact))
827 );
828 if all_matches.is_empty() {
829 println!(
832 "# note: no symbol matching {:?} in {}",
833 symbol,
834 path.display()
835 );
836 }
837 } else {
838 let mut any_match = false;
839 for sym in &symbols {
840 let matches = crate::core::find_symbols(&res, sym);
841 for m in matches {
842 any_match = true;
843 println!(
844 "# {}:{}-{} {} ({})",
845 res.path.display(),
846 m.start_line,
847 m.end_line,
848 m.qualified_name,
849 m.kind
850 );
851 if !m.ancestor_signatures.is_empty() {
852 println!("# in: {}", m.ancestor_signatures.join(" → "));
853 }
854 println!("{}", m.source);
855 }
856 }
857 if !any_match {
858 let joined = symbols.join(", ");
859 println!(
860 "# note: no symbol matching '{}' in {}",
861 joined,
862 path.display()
863 );
864 }
865 }
866 } else {
867 println!(
868 "# note: unsupported file type for `show`: {}",
869 path.display()
870 );
871 }
872 }
873 Commands::Squeeze {
874 path,
875 range,
876 raw,
877 json,
878 compact,
879 } => {
880 if !path.exists() {
881 println!("# note: path not found: {}", path.display());
882 return;
883 }
884 let text = match std::fs::read_to_string(path) {
885 Ok(t) => t,
886 Err(e) => {
887 if path.is_dir() {
888 println!("# note: path is a directory: {}", path.display());
889 } else {
890 println!("# note: could not read {}: {}", path.display(), e);
891 }
892 return;
893 }
894 };
895 let line_count = text.lines().count();
896 let resolved: Option<(usize, usize)> = range.as_ref().map(|r| r.resolve(line_count));
897 let sliced = crate::squeeze::render::slice_lines(&text, resolved);
898 let path_str = path.display().to_string();
899 let report = crate::squeeze::render::SqueezeReport {
900 path: &path_str,
901 range: resolved,
902 raw: &sliced,
903 raw_requested: *raw,
904 };
905 if *json {
906 println!(
907 "{}",
908 crate::squeeze::render::render_json(&report, !(*compact))
909 );
910 } else {
911 println!("{}", crate::squeeze::render::render_text(&report));
912 }
913 }
914 Commands::Digest {
915 paths,
916 include_private,
917 include_fields,
918 max_members,
919 json,
920 compact,
921 } => {
922 let results = walk_and_parse(paths, None);
923 if *json {
924 let opts = MapOptions {
925 include_private: *include_private,
926 include_fields: *include_fields,
927 include_docs: true,
928 include_attributes: true,
929 include_line_numbers: true,
930 max_doc_lines: 6,
931 max_members: Some(*max_members),
932 };
933 println!(
934 "{}",
935 crate::core::render_json_map(&results, &opts, !(*compact))
936 );
937 } else {
938 let opts = DigestOptions {
939 include_private: *include_private,
940 include_fields: *include_fields,
941 max_members_per_type: *max_members,
942 max_heading_depth: 3,
943 };
944 let root = if paths.len() == 1 && paths[0].is_dir() {
945 Some(paths[0].as_path())
946 } else {
947 None
948 };
949 println!("{}", crate::core::render_digest(&results, &opts, root));
950 }
951 }
952 Commands::Implements {
953 target,
954 paths,
955 direct,
956 json,
957 compact,
958 } => {
959 let results = walk_and_parse(paths, None);
960 let transitive = !direct;
961 let matches = crate::core::find_implementations(&results, target, transitive);
962 if *json {
963 println!(
964 "{}",
965 crate::core::render_json_implements(target, &matches, transitive, !(*compact),)
966 );
967 } else {
968 println!(
969 "# {} match(es) for '{}' (incl. transitive):",
970 matches.len(),
971 target
972 );
973 for m in matches {
974 let via = if m.via.is_empty() {
975 String::new()
976 } else {
977 format!(" [via {}]", m.via.last().unwrap())
978 };
979 println!("{}:{} {} {}{}", m.path, m.start_line, m.kind, m.name, via);
980 }
981 }
982 }
983 Commands::Prompt => {
984 println!("{}", crate::prompt::agent_prompt());
985 }
986 Commands::Install {
987 target,
988 all,
989 local,
990 global,
991 always,
992 min_lines,
993 dry_run,
994 force,
995 mcp,
996 skills,
997 } => {
998 let scope = resolve_scope(*local, *global);
999 let opts = installers::InstallOpts {
1000 min_lines: *min_lines,
1001 always: *always,
1002 dry_run: *dry_run,
1003 force: *force,
1004 };
1005 let exit = run_install(target.as_deref(), *all, *mcp, *skills, &scope, &opts);
1006 std::process::exit(exit);
1007 }
1008 Commands::Uninstall {
1009 target,
1010 all,
1011 local,
1012 global,
1013 dry_run,
1014 } => {
1015 let scope = resolve_scope(*local, *global);
1016 let opts = installers::InstallOpts {
1017 dry_run: *dry_run,
1018 ..installers::InstallOpts::default()
1019 };
1020 let exit = run_uninstall(target.as_deref(), *all, &scope, &opts);
1021 std::process::exit(exit);
1022 }
1023 Commands::Status { local, global } => {
1024 let scope = resolve_scope(*local, *global);
1025 run_status(&scope);
1026 }
1027 Commands::Hook {
1028 protocol,
1029 min_lines,
1030 always,
1031 } => {
1032 let exit = hook::run(protocol, *min_lines, *always);
1033 std::process::exit(exit);
1034 }
1035 Commands::Mcp => {
1036 let exit = mcp::run();
1037 std::process::exit(exit);
1038 }
1039 Commands::Search {
1040 query,
1041 path,
1042 top_k,
1043 alpha,
1044 languages,
1045 rebuild,
1046 json,
1047 compact,
1048 } => {
1049 if *rebuild {
1050 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1051 if let Err(e) = crate::search::index::Index::build(path, &cwd) {
1052 eprintln!("ast-bro: rebuild failed: {e}");
1053 std::process::exit(1);
1054 }
1055 }
1056 let exit = crate::search::cli::run_search(
1057 query,
1058 path,
1059 *top_k,
1060 *alpha,
1061 languages.clone(),
1062 *json,
1063 !(*compact),
1064 );
1065 std::process::exit(exit);
1066 }
1067 Commands::FindRelated {
1068 target,
1069 path,
1070 file,
1071 line,
1072 top_k,
1073 json,
1074 compact,
1075 } => {
1076 let (file_path, line_num) = match (target, file, line) {
1078 (Some(t), _, _) => match parse_file_line(t) {
1079 Some(parsed) => parsed,
1080 None => {
1081 println!(
1082 "# note: expected <FILE>:<LINE>, got {t:?} \
1083 (or use --file FILE --line N instead)"
1084 );
1085 return;
1086 }
1087 },
1088 (None, Some(f), Some(l)) => (f.clone(), *l),
1089 _ => unreachable!("clap should have rejected this argument combination"),
1090 };
1091 let exit = crate::search::cli::run_find_related(
1092 &file_path,
1093 line_num,
1094 path,
1095 *top_k,
1096 *json,
1097 !(*compact),
1098 );
1099 std::process::exit(exit);
1100 }
1101 Commands::Surface {
1102 path,
1103 tree,
1104 include_chain,
1105 max_depth,
1106 include_private,
1107 lang,
1108 json,
1109 compact,
1110 } => {
1111 let lang_override = match lang {
1112 Some(s) => match crate::surface::LangOverride::parse(s) {
1113 Some(l) => Some(l),
1114 None => {
1115 println!(
1116 "# note: unknown --lang value '{}'. Expected rust|python|fallback.",
1117 s
1118 );
1119 return;
1120 }
1121 },
1122 None => None,
1123 };
1124 let json_on = *json;
1125 let pretty = !(*compact);
1126 let output = if json_on {
1127 crate::surface::OutputMode::Json { compact: !pretty }
1128 } else if *tree {
1129 crate::surface::OutputMode::Tree
1130 } else {
1131 crate::surface::OutputMode::Flat
1132 };
1133 let opts = crate::surface::SurfaceOptions {
1134 output,
1135 include_private: *include_private,
1136 max_depth: *max_depth,
1137 include_chain: *include_chain,
1138 lang_override,
1139 };
1140 match crate::surface::resolve_surface(path, &opts) {
1141 Ok(entries) => {
1142 let rendered =
1143 crate::surface::render::render(&entries, opts.output, opts.include_chain);
1144 print!("{}", rendered);
1145 }
1146 Err(e) => {
1147 println!("# note: {e}");
1148 }
1149 }
1150 }
1151 Commands::Deps {
1152 file,
1153 depth,
1154 hide_external,
1155 rebuild,
1156 json,
1157 compact,
1158 } => {
1159 let exit = crate::deps::cli::run_deps(
1160 file,
1161 *depth,
1162 !(*hide_external),
1163 *json,
1164 !(*compact),
1165 *rebuild,
1166 );
1167 std::process::exit(exit);
1168 }
1169 Commands::ReverseDeps {
1170 file,
1171 depth,
1172 limit,
1173 tests,
1174 exclude_tests,
1175 rebuild,
1176 json,
1177 compact,
1178 } => {
1179 let exit = crate::deps::cli::run_reverse_deps(
1180 file,
1181 *depth,
1182 *limit,
1183 *tests,
1184 *exclude_tests,
1185 *json,
1186 !(*compact),
1187 *rebuild,
1188 );
1189 std::process::exit(exit);
1190 }
1191 Commands::Cycles {
1192 path,
1193 min_size,
1194 rebuild,
1195 json,
1196 compact,
1197 } => {
1198 let exit = crate::deps::cli::run_cycles(path, *min_size, *json, !(*compact), *rebuild);
1199 std::process::exit(exit);
1200 }
1201 Commands::Graph {
1202 path,
1203 json,
1204 hide_external,
1205 include_external,
1206 rebuild,
1207 compact,
1208 } => {
1209 if *include_external {
1210 eprintln!("# note: --include-external is deprecated; unresolved imports are shown by default now (use --hide-external to drop them)");
1211 }
1212 let exit =
1213 crate::deps::cli::run_graph(path, *json, !(*hide_external), !(*compact), *rebuild);
1214 std::process::exit(exit);
1215 }
1216 Commands::Index {
1217 path,
1218 rebuild,
1219 stats,
1220 json,
1221 compact,
1222 } => {
1223 let exit = crate::search::cli::run_index(path, *rebuild, *stats, *json, !(*compact));
1224 std::process::exit(exit);
1225 }
1226 Commands::Callers {
1227 target,
1228 path,
1229 file,
1230 symbol,
1231 depth,
1232 limit,
1233 hide_ambiguous,
1234 include_ambiguous,
1235 tests,
1236 exclude_tests,
1237 rebuild,
1238 json,
1239 compact,
1240 } => {
1241 if *include_ambiguous {
1242 eprintln!("# note: --include-ambiguous is deprecated; ambiguous callers are shown by default now (use --hide-ambiguous to drop them)");
1243 }
1244 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1245 let exit = crate::calls::cli::run_callers(
1246 &resolved,
1247 path,
1248 *depth,
1249 *limit,
1250 !(*hide_ambiguous),
1251 *tests,
1252 *exclude_tests,
1253 *rebuild,
1254 *json,
1255 !(*compact),
1256 );
1257 std::process::exit(exit);
1258 }
1259 Commands::Context {
1260 target,
1261 path,
1262 file,
1263 symbol,
1264 budget,
1265 rebuild,
1266 json,
1267 compact,
1268 } => {
1269 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1270 let exit = crate::context::run_context(
1271 &resolved,
1272 path,
1273 &crate::context::ContextOptions {
1274 budget: *budget,
1275 json: *json,
1276 pretty: !(*compact),
1277 },
1278 *rebuild,
1279 );
1280 std::process::exit(exit);
1281 }
1282 Commands::Callees {
1283 target,
1284 path,
1285 file,
1286 symbol,
1287 depth,
1288 hide_external,
1289 external,
1290 rebuild,
1291 json,
1292 compact,
1293 } => {
1294 if *external {
1295 eprintln!("# note: --external is deprecated; unresolved/external callees are shown by default now (use --hide-external to drop them)");
1296 }
1297 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1298 let exit = crate::calls::cli::run_callees(
1299 &resolved,
1300 path,
1301 *depth,
1302 !(*hide_external),
1303 *rebuild,
1304 *json,
1305 !(*compact),
1306 );
1307 std::process::exit(exit);
1308 }
1309 Commands::Trace {
1310 from,
1311 to,
1312 path,
1313 depth,
1314 rebuild,
1315 json,
1316 compact,
1317 } => {
1318 let exit =
1319 crate::calls::cli::run_trace(from, to, path, *depth, *rebuild, *json, !(*compact));
1320 std::process::exit(exit);
1321 }
1322 Commands::Impact {
1323 target,
1324 path,
1325 file,
1326 symbol,
1327 depth,
1328 limit,
1329 mode,
1330 hide_ambiguous,
1331 tests,
1332 exclude_tests,
1333 rebuild,
1334 json,
1335 compact,
1336 } => {
1337 let impact_mode = match crate::impact::ImpactMode::parse(mode) {
1338 Some(m) => m,
1339 None => {
1340 eprintln!(
1341 "# note: unknown --mode '{}'. Expected: deps, dependents, tests, all",
1342 mode
1343 );
1344 std::process::exit(2);
1345 }
1346 };
1347 let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1348 let opts = crate::impact::ImpactOptions {
1349 depth: *depth,
1350 limit: *limit,
1351 mode: impact_mode,
1352 include_ambiguous: !(*hide_ambiguous),
1353 tests: *tests,
1354 exclude_tests: *exclude_tests,
1355 json: *json,
1356 pretty: !(*compact),
1357 };
1358 let exit = crate::impact::run_impact(&resolved, path, &opts, *rebuild);
1359 std::process::exit(exit);
1360 }
1361 Commands::Run {
1362 pattern,
1363 rewrite,
1364 lang,
1365 paths,
1366 glob,
1367 write,
1368 json,
1369 compact,
1370 } => {
1371 let exit = crate::run::cli::run(
1372 pattern,
1373 rewrite.as_deref(),
1374 lang.as_deref(),
1375 paths,
1376 glob.as_deref(),
1377 *write,
1378 *json,
1379 !(*compact),
1380 );
1381 std::process::exit(exit);
1382 }
1383 }
1384}
1385
1386fn compose_target(target: Option<&str>, file: Option<&str>, symbol: Option<&str>) -> String {
1390 if let Some(t) = target {
1391 return t.to_string();
1392 }
1393 match (file, symbol) {
1394 (Some(f), Some(s)) => format!("{}:{}", f, s),
1395 _ => unreachable!("clap guarantees target XOR (file && symbol)"),
1396 }
1397}
1398
1399fn resolve_scope(local: bool, _global: bool) -> installers::Scope {
1400 if local {
1401 installers::Scope::Local(std::env::current_dir().expect("cwd"))
1402 } else {
1403 installers::Scope::Global
1404 }
1405}
1406
1407fn run_install(
1408 target: Option<&str>,
1409 all: bool,
1410 mcp: bool,
1411 skills: bool,
1412 scope: &installers::Scope,
1413 opts: &installers::InstallOpts,
1414) -> i32 {
1415 let registry = installers::registry();
1416 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1417 select_all(®istry, scope)
1418 } else if let Some(name) = target {
1419 match registry.iter().find(|i| i.name() == name) {
1420 Some(i) => vec![i],
1421 None => {
1422 eprintln!("unknown --target '{}'. Known: {}", name, names(®istry));
1423 return 2;
1424 }
1425 }
1426 } else {
1427 eprintln!(
1428 "must pass --target <name> or --all. Known: {}",
1429 names(®istry)
1430 );
1431 return 2;
1432 };
1433
1434 let exclusive_mode = mcp || skills;
1435 let mut any_installed = false;
1436 let mut any_failed = false;
1437 for inst in chosen {
1438 let label = inst.name();
1439 if !exclusive_mode {
1440 match inst.install_prompt(scope, opts) {
1441 Ok(c) => {
1442 print_change(label, "prompt", &c);
1443 if !matches!(
1444 c,
1445 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1446 ) {
1447 any_installed = true;
1448 }
1449 }
1450 Err(e) => {
1451 eprintln!("{}: prompt: {}", label, e);
1452 any_failed = true;
1453 }
1454 }
1455 match inst.install_hook(scope, opts) {
1456 Ok(c) => {
1457 print_change(label, "hook", &c);
1458 if !matches!(
1459 c,
1460 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1461 ) {
1462 any_installed = true;
1463 }
1464 }
1465 Err(e) => {
1466 eprintln!("{}: hook: {}", label, e);
1467 any_failed = true;
1468 }
1469 }
1470 match inst.install_subagents(scope, opts) {
1471 Ok(changes) => {
1472 for c in &changes {
1473 print_change(label, "subagent", c);
1474 if !matches!(
1475 c,
1476 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1477 ) {
1478 any_installed = true;
1479 }
1480 }
1481 }
1482 Err(e) => {
1483 eprintln!("{}: subagent: {}", label, e);
1484 any_failed = true;
1485 }
1486 }
1487 } else {
1488 if mcp {
1489 match inst.install_mcp(scope, opts) {
1490 Ok(c) => {
1491 print_change(label, "mcp", &c);
1492 if !matches!(
1493 c,
1494 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1495 ) {
1496 any_installed = true;
1497 }
1498 }
1499 Err(e) => {
1500 eprintln!("{}: mcp: {}", label, e);
1501 any_failed = true;
1502 }
1503 }
1504 }
1505 if skills {
1506 match inst.install_skills(scope, opts) {
1507 Ok(c) => {
1508 print_change(label, "skills", &c);
1509 if !matches!(
1510 c,
1511 installers::Change::Skipped { .. } | installers::Change::NotApplicable
1512 ) {
1513 any_installed = true;
1514 }
1515 }
1516 Err(e) => {
1517 eprintln!("{}: skills: {}", label, e);
1518 any_failed = true;
1519 }
1520 }
1521 }
1522 }
1523 }
1524
1525 if any_failed && any_installed {
1526 1
1527 } else if any_failed {
1528 2
1529 } else {
1530 0
1531 }
1532}
1533
1534fn run_uninstall(
1535 target: Option<&str>,
1536 all: bool,
1537 scope: &installers::Scope,
1538 opts: &installers::InstallOpts,
1539) -> i32 {
1540 let registry = installers::registry();
1541 let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1542 select_all(®istry, scope)
1543 } else if let Some(name) = target {
1544 match registry.iter().find(|i| i.name() == name) {
1545 Some(i) => vec![i],
1546 None => {
1547 eprintln!("unknown --target '{}'. Known: {}", name, names(®istry));
1548 return 2;
1549 }
1550 }
1551 } else {
1552 eprintln!(
1553 "must pass --target <name> or --all. Known: {}",
1554 names(®istry)
1555 );
1556 return 2;
1557 };
1558
1559 let mut any_failed = false;
1560 for inst in chosen {
1561 match inst.uninstall(scope, opts) {
1562 Ok(changes) => {
1563 for c in changes {
1564 print_change(inst.name(), "uninstall", &c);
1565 }
1566 }
1567 Err(e) => {
1568 eprintln!("{}: {}", inst.name(), e);
1569 any_failed = true;
1570 }
1571 }
1572 }
1573 if any_failed {
1574 1
1575 } else {
1576 0
1577 }
1578}
1579
1580fn run_status(scope: &installers::Scope) {
1581 for inst in installers::registry() {
1582 let s = inst.status(scope);
1583 let prompt = if s.prompt_installed {
1584 format!("prompt {}", s.prompt_version.unwrap_or_else(|| "?".into()))
1585 } else {
1586 "prompt -".to_string()
1587 };
1588 let hook = if s.hook_installed {
1589 "hook ✓"
1590 } else {
1591 "hook -"
1592 };
1593 let mcp = if s.mcp_installed { "mcp ✓" } else { "mcp -" };
1594 let skills = if s.skills_installed {
1595 "skills ✓"
1596 } else {
1597 "skills -"
1598 };
1599 println!(
1600 "{:<14} {:<14} {:<8} {:<8} {}",
1601 inst.name(),
1602 prompt,
1603 hook,
1604 mcp,
1605 skills
1606 );
1607 }
1608}
1609
1610fn names(registry: &[Box<dyn installers::Installer>]) -> String {
1611 registry
1612 .iter()
1613 .map(|i| i.name())
1614 .collect::<Vec<_>>()
1615 .join(", ")
1616}
1617
1618#[allow(clippy::borrowed_box)]
1623fn select_all<'a>(
1624 registry: &'a [Box<dyn installers::Installer>],
1625 scope: &installers::Scope,
1626) -> Vec<&'a Box<dyn installers::Installer>> {
1627 let bypass_detection = matches!(scope, installers::Scope::Local(_));
1628 registry
1629 .iter()
1630 .filter(|inst| {
1631 if bypass_detection {
1632 return true;
1633 }
1634 let d = inst.detect(scope);
1635 if !d.present {
1636 println!(
1637 "{:<14} {:<10} skipped (not detected on this system)",
1638 inst.name(),
1639 "detect"
1640 );
1641 }
1642 d.present
1643 })
1644 .collect()
1645}
1646
1647fn print_change(target: &str, phase: &str, change: &installers::Change) {
1648 use installers::Change::*;
1649 match change {
1650 Created(p) => println!("{:<14} {:<10} created {}", target, phase, p.display()),
1651 Updated(p) => println!("{:<14} {:<10} updated {}", target, phase, p.display()),
1652 Removed(p) => println!("{:<14} {:<10} removed {}", target, phase, p.display()),
1653 Skipped { path, reason } => {
1654 println!(
1655 "{:<14} {:<10} skipped {} ({})",
1656 target,
1657 phase,
1658 path.display(),
1659 reason
1660 )
1661 }
1662 NotApplicable => println!("{:<14} {:<10} n/a", target, phase),
1663 }
1664}