1use anyhow::{Context, Result};
4use clap::{CommandFactory, Parser, Subcommand};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7use std::time::Instant;
8use indicatif::{ProgressBar, ProgressStyle};
9use owo_colors::OwoColorize;
10
11use crate::cache::CacheManager;
12use crate::indexer::Indexer;
13use crate::models::{IndexConfig, Language};
14use crate::output;
15use crate::query::{QueryEngine, QueryFilter};
16
17#[derive(Parser, Debug)]
19#[command(
20 name = "rfx",
21 version,
22 about = "A fast, deterministic code search engine built for AI",
23 long_about = "Reflex is a local-first, structure-aware code search engine that returns \
24 structured results (symbols, spans, scopes) with sub-100ms latency. \
25 Designed for AI coding agents and automation."
26)]
27pub struct Cli {
28 #[arg(short, long, action = clap::ArgAction::Count)]
30 pub verbose: u8,
31
32 #[command(subcommand)]
33 pub command: Option<Command>,
34}
35
36#[derive(Subcommand, Debug)]
37pub enum IndexSubcommand {
38 Status,
40
41 Compact {
51 #[arg(long)]
53 json: bool,
54
55 #[arg(long)]
57 pretty: bool,
58 },
59}
60
61#[derive(Subcommand, Debug)]
62pub enum Command {
63 Index {
65 #[arg(value_name = "PATH", default_value = ".")]
67 path: PathBuf,
68
69 #[arg(short, long)]
71 force: bool,
72
73 #[arg(short, long, value_delimiter = ',')]
75 languages: Vec<String>,
76
77 #[arg(short, long)]
79 quiet: bool,
80
81 #[command(subcommand)]
83 command: Option<IndexSubcommand>,
84 },
85
86 Query {
110 pattern: Option<String>,
112
113 #[arg(short, long)]
115 symbols: bool,
116
117 #[arg(short, long)]
120 lang: Option<String>,
121
122 #[arg(short, long)]
125 kind: Option<String>,
126
127 #[arg(long)]
142 ast: bool,
143
144 #[arg(short = 'r', long)]
160 regex: bool,
161
162 #[arg(long)]
164 json: bool,
165
166 #[arg(long)]
169 pretty: bool,
170
171 #[arg(long)]
175 ai: bool,
176
177 #[arg(short = 'n', long)]
179 limit: Option<usize>,
180
181 #[arg(short = 'o', long)]
184 offset: Option<usize>,
185
186 #[arg(long)]
189 expand: bool,
190
191 #[arg(short = 'f', long)]
194 file: Option<String>,
195
196 #[arg(long)]
199 exact: bool,
200
201 #[arg(long)]
216 contains: bool,
217
218 #[arg(short, long)]
220 count: bool,
221
222 #[arg(short = 't', long, default_value = "30")]
224 timeout: u64,
225
226 #[arg(long)]
228 plain: bool,
229
230 #[arg(short = 'g', long)]
244 glob: Vec<String>,
245
246 #[arg(short = 'x', long)]
255 exclude: Vec<String>,
256
257 #[arg(short = 'p', long)]
260 paths: bool,
261
262 #[arg(long)]
265 no_truncate: bool,
266
267 #[arg(short = 'a', long)]
270 all: bool,
271
272 #[arg(long)]
278 force: bool,
279
280 #[arg(long)]
283 dependencies: bool,
284 },
285
286 Serve {
288 #[arg(short, long, default_value = "7878")]
290 port: u16,
291
292 #[arg(long, default_value = "127.0.0.1")]
294 host: String,
295 },
296
297 Stats {
299 #[arg(long)]
301 json: bool,
302
303 #[arg(long)]
305 pretty: bool,
306 },
307
308 Clear {
310 #[arg(short, long)]
312 yes: bool,
313 },
314
315 ListFiles {
317 #[arg(long)]
319 json: bool,
320
321 #[arg(long)]
323 pretty: bool,
324 },
325
326 Watch {
335 #[arg(value_name = "PATH", default_value = ".")]
337 path: PathBuf,
338
339 #[arg(short, long, default_value = "15000")]
343 debounce: u64,
344
345 #[arg(short, long)]
347 quiet: bool,
348 },
349
350 Mcp,
367
368 Analyze {
384 #[arg(long)]
386 circular: bool,
387
388 #[arg(long)]
390 hotspots: bool,
391
392 #[arg(long, default_value = "2", requires = "hotspots")]
394 min_dependents: usize,
395
396 #[arg(long)]
398 unused: bool,
399
400 #[arg(long)]
402 islands: bool,
403
404 #[arg(long, default_value = "2", requires = "islands")]
406 min_island_size: usize,
407
408 #[arg(long, requires = "islands")]
410 max_island_size: Option<usize>,
411
412 #[arg(short = 'f', long, default_value = "tree")]
414 format: String,
415
416 #[arg(long)]
418 json: bool,
419
420 #[arg(long)]
422 pretty: bool,
423
424 #[arg(short, long)]
426 count: bool,
427
428 #[arg(short = 'a', long)]
431 all: bool,
432
433 #[arg(long)]
435 plain: bool,
436
437 #[arg(short = 'g', long)]
440 glob: Vec<String>,
441
442 #[arg(short = 'x', long)]
445 exclude: Vec<String>,
446
447 #[arg(long)]
450 force: bool,
451
452 #[arg(short = 'n', long)]
454 limit: Option<usize>,
455
456 #[arg(short = 'o', long)]
458 offset: Option<usize>,
459
460 #[arg(long)]
464 sort: Option<String>,
465 },
466
467 Deps {
477 file: PathBuf,
479
480 #[arg(short, long)]
482 reverse: bool,
483
484 #[arg(short, long, default_value = "1")]
486 depth: usize,
487
488 #[arg(short = 'f', long, default_value = "tree")]
490 format: String,
491
492 #[arg(long)]
494 json: bool,
495
496 #[arg(long)]
498 pretty: bool,
499 },
500
501 Ask {
527 question: Option<String>,
529
530 #[arg(short, long)]
532 execute: bool,
533
534 #[arg(short, long)]
536 provider: Option<String>,
537
538 #[arg(long)]
540 json: bool,
541
542 #[arg(long)]
544 pretty: bool,
545
546 #[arg(long)]
548 additional_context: Option<String>,
549
550 #[arg(long)]
552 configure: bool,
553
554 #[arg(long)]
556 agentic: bool,
557
558 #[arg(long, default_value = "2")]
560 max_iterations: usize,
561
562 #[arg(long)]
564 no_eval: bool,
565
566 #[arg(long)]
568 show_reasoning: bool,
569
570 #[arg(long)]
572 verbose: bool,
573
574 #[arg(long)]
576 quiet: bool,
577
578 #[arg(long)]
580 answer: bool,
581
582 #[arg(short = 'i', long)]
584 interactive: bool,
585
586 #[arg(long)]
588 debug: bool,
589 },
590
591 Context {
608 #[arg(long)]
610 structure: bool,
611
612 #[arg(short, long)]
614 path: Option<String>,
615
616 #[arg(long)]
618 file_types: bool,
619
620 #[arg(long)]
622 project_type: bool,
623
624 #[arg(long)]
626 framework: bool,
627
628 #[arg(long)]
630 entry_points: bool,
631
632 #[arg(long)]
634 test_layout: bool,
635
636 #[arg(long)]
638 config_files: bool,
639
640 #[arg(long, default_value = "1")]
642 depth: usize,
643
644 #[arg(long)]
646 json: bool,
647 },
648
649 #[command(hide = true)]
651 IndexSymbolsInternal {
652 cache_dir: PathBuf,
654 },
655}
656
657fn try_background_compact(cache: &CacheManager, command: &Command) {
669 match command {
671 Command::Clear { .. } => {
672 log::debug!("Skipping compaction for Clear command");
673 return;
674 }
675 Command::Mcp => {
676 log::debug!("Skipping compaction for Mcp command");
677 return;
678 }
679 Command::Watch { .. } => {
680 log::debug!("Skipping compaction for Watch command");
681 return;
682 }
683 Command::Serve { .. } => {
684 log::debug!("Skipping compaction for Serve command");
685 return;
686 }
687 _ => {}
688 }
689
690 let should_compact = match cache.should_compact() {
692 Ok(true) => true,
693 Ok(false) => {
694 log::debug!("Compaction not needed yet (last run <24h ago)");
695 return;
696 }
697 Err(e) => {
698 log::warn!("Failed to check compaction status: {}", e);
699 return;
700 }
701 };
702
703 if !should_compact {
704 return;
705 }
706
707 log::info!("Starting background cache compaction...");
708
709 let cache_path = cache.path().to_path_buf();
711
712 std::thread::spawn(move || {
714 let cache = CacheManager::new(cache_path.parent().expect("Cache should have parent directory"));
715
716 match cache.compact() {
717 Ok(report) => {
718 log::info!(
719 "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
720 report.files_removed,
721 report.space_saved_bytes as f64 / 1_048_576.0,
722 report.duration_ms
723 );
724 }
725 Err(e) => {
726 log::warn!("Background compaction failed: {}", e);
727 }
728 }
729 });
730
731 log::debug!("Background compaction thread spawned - main command continuing");
732}
733
734impl Cli {
735 pub fn execute(self) -> Result<()> {
737 let log_level = match self.verbose {
739 0 => "warn", 1 => "info", 2 => "debug", _ => "trace", };
744 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
745 .init();
746
747 if let Some(ref command) = self.command {
749 let cache = CacheManager::new(".");
751 try_background_compact(&cache, command);
752 }
753
754 match self.command {
756 None => {
757 Cli::command().print_help()?;
759 println!(); Ok(())
761 }
762 Some(Command::Index { path, force, languages, quiet, command }) => {
763 match command {
764 None => {
765 handle_index_build(&path, &force, &languages, &quiet)
767 }
768 Some(IndexSubcommand::Status) => {
769 handle_index_status()
770 }
771 Some(IndexSubcommand::Compact { json, pretty }) => {
772 handle_index_compact(&json, &pretty)
773 }
774 }
775 }
776 Some(Command::Query { pattern, symbols, lang, kind, ast, regex, json, pretty, ai, limit, offset, expand, file, exact, contains, count, timeout, plain, glob, exclude, paths, no_truncate, all, force, dependencies }) => {
777 match pattern {
779 None => handle_interactive(),
780 Some(pattern) => handle_query(pattern, symbols, lang, kind, ast, regex, json, pretty, ai, limit, offset, expand, file, exact, contains, count, timeout, plain, glob, exclude, paths, no_truncate, all, force, dependencies)
781 }
782 }
783 Some(Command::Serve { port, host }) => {
784 handle_serve(port, host)
785 }
786 Some(Command::Stats { json, pretty }) => {
787 handle_stats(json, pretty)
788 }
789 Some(Command::Clear { yes }) => {
790 handle_clear(yes)
791 }
792 Some(Command::ListFiles { json, pretty }) => {
793 handle_list_files(json, pretty)
794 }
795 Some(Command::Watch { path, debounce, quiet }) => {
796 handle_watch(path, debounce, quiet)
797 }
798 Some(Command::Mcp) => {
799 handle_mcp()
800 }
801 Some(Command::Analyze { circular, hotspots, min_dependents, unused, islands, min_island_size, max_island_size, format, json, pretty, count, all, plain, glob, exclude, force, limit, offset, sort }) => {
802 handle_analyze(circular, hotspots, min_dependents, unused, islands, min_island_size, max_island_size, format, json, pretty, count, all, plain, glob, exclude, force, limit, offset, sort)
803 }
804 Some(Command::Deps { file, reverse, depth, format, json, pretty }) => {
805 handle_deps(file, reverse, depth, format, json, pretty)
806 }
807 Some(Command::Ask { question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug }) => {
808 handle_ask(question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug)
809 }
810 Some(Command::Context { structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json }) => {
811 handle_context(structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json)
812 }
813 Some(Command::IndexSymbolsInternal { cache_dir }) => {
814 handle_index_symbols_internal(cache_dir)
815 }
816 }
817 }
818}
819
820fn handle_index_status() -> Result<()> {
822 log::info!("Checking background symbol indexing status");
823
824 let cache = CacheManager::new(".");
825 let cache_path = cache.path().to_path_buf();
826
827 match crate::background_indexer::BackgroundIndexer::get_status(&cache_path) {
828 Ok(Some(status)) => {
829 println!("Background Symbol Indexing Status");
830 println!("==================================");
831 println!("State: {:?}", status.state);
832 println!("Total files: {}", status.total_files);
833 println!("Processed: {}", status.processed_files);
834 println!("Cached: {}", status.cached_files);
835 println!("Parsed: {}", status.parsed_files);
836 println!("Failed: {}", status.failed_files);
837 println!("Started: {}", status.started_at);
838 println!("Last updated: {}", status.updated_at);
839
840 if let Some(completed_at) = &status.completed_at {
841 println!("Completed: {}", completed_at);
842 }
843
844 if let Some(error) = &status.error {
845 println!("Error: {}", error);
846 }
847
848 if status.state == crate::background_indexer::IndexerState::Running && status.total_files > 0 {
850 let progress = (status.processed_files as f64 / status.total_files as f64) * 100.0;
851 println!("\nProgress: {:.1}%", progress);
852 }
853
854 Ok(())
855 }
856 Ok(None) => {
857 println!("No background symbol indexing in progress.");
858 println!("\nRun 'rfx index' to start background symbol indexing.");
859 Ok(())
860 }
861 Err(e) => {
862 anyhow::bail!("Failed to get indexing status: {}", e);
863 }
864 }
865 }
866
867fn handle_index_compact(json: &bool, pretty: &bool) -> Result<()> {
869 log::info!("Running cache compaction");
870
871 let cache = CacheManager::new(".");
872 let report = cache.compact()?;
873
874 if *json {
876 let json_str = if *pretty {
877 serde_json::to_string_pretty(&report)?
878 } else {
879 serde_json::to_string(&report)?
880 };
881 println!("{}", json_str);
882 } else {
883 println!("Cache Compaction Complete");
884 println!("=========================");
885 println!("Files removed: {}", report.files_removed);
886 println!("Space saved: {:.2} MB", report.space_saved_bytes as f64 / 1_048_576.0);
887 println!("Duration: {}ms", report.duration_ms);
888 }
889
890 Ok(())
891}
892
893fn handle_index_build(path: &PathBuf, force: &bool, languages: &[String], quiet: &bool) -> Result<()> {
894 log::info!("Starting index build");
895
896 let cache = CacheManager::new(path);
897 let cache_path = cache.path().to_path_buf();
898
899 if *force {
900 log::info!("Force rebuild requested, clearing existing cache");
901 cache.clear()?;
902 }
903
904 let lang_filters: Vec<Language> = languages
906 .iter()
907 .filter_map(|s| {
908 Language::from_name(s).or_else(|| {
909 output::warn(&format!("Unknown language: '{}'. Supported: {}", s, Language::supported_names_help()));
910 None
911 })
912 })
913 .collect();
914
915 let config = IndexConfig {
916 languages: lang_filters,
917 ..Default::default()
918 };
919
920 let indexer = Indexer::new(cache, config);
921 let show_progress = !quiet;
923 let stats = indexer.index(path, show_progress)?;
924
925 if !quiet {
927 println!("Indexing complete!");
928 println!(" Files indexed: {}", stats.total_files);
929 println!(" Cache size: {}", format_bytes(stats.index_size_bytes));
930 println!(" Last updated: {}", stats.last_updated);
931
932 if !stats.files_by_language.is_empty() {
934 println!("\nFiles by language:");
935
936 let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
938 lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
939
940 let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
942 let lang_width = max_lang_len.max(8); println!(" {:<width$} Files Lines", "Language", width = lang_width);
946 println!(" {} ----- -------", "-".repeat(lang_width));
947
948 for (language, file_count) in lang_vec {
950 let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
951 println!(" {:<width$} {:5} {:7}",
952 language, file_count, line_count,
953 width = lang_width);
954 }
955 }
956 }
957
958 if !crate::background_indexer::BackgroundIndexer::is_running(&cache_path) {
960 if !quiet {
961 println!("\nStarting background symbol indexing...");
962 println!(" Symbols will be cached for faster queries");
963 println!(" Check status with: rfx index status");
964 }
965
966 let current_exe = std::env::current_exe()
969 .context("Failed to get current executable path")?;
970
971 #[cfg(unix)]
972 {
973 std::process::Command::new(¤t_exe)
974 .arg("index-symbols-internal")
975 .arg(path)
976 .stdin(std::process::Stdio::null())
977 .stdout(std::process::Stdio::null())
978 .stderr(std::process::Stdio::null())
979 .spawn()
980 .context("Failed to spawn background indexing process")?;
981 }
982
983 #[cfg(windows)]
984 {
985 use std::os::windows::process::CommandExt;
986 const CREATE_NO_WINDOW: u32 = 0x08000000;
987
988 std::process::Command::new(¤t_exe)
989 .arg("index-symbols-internal")
990 .arg(&path)
991 .creation_flags(CREATE_NO_WINDOW)
992 .stdin(std::process::Stdio::null())
993 .stdout(std::process::Stdio::null())
994 .stderr(std::process::Stdio::null())
995 .spawn()
996 .context("Failed to spawn background indexing process")?;
997 }
998
999 log::debug!("Spawned background symbol indexing process");
1000 } else if !quiet {
1001 println!("\n⚠️ Background symbol indexing already in progress");
1002 println!(" Check status with: rfx index status");
1003 }
1004
1005 Ok(())
1006}
1007
1008fn format_bytes(bytes: u64) -> String {
1010 const KB: u64 = 1024;
1011 const MB: u64 = KB * 1024;
1012 const GB: u64 = MB * 1024;
1013 const TB: u64 = GB * 1024;
1014
1015 if bytes >= TB {
1016 format!("{:.2} TB", bytes as f64 / TB as f64)
1017 } else if bytes >= GB {
1018 format!("{:.2} GB", bytes as f64 / GB as f64)
1019 } else if bytes >= MB {
1020 format!("{:.2} MB", bytes as f64 / MB as f64)
1021 } else if bytes >= KB {
1022 format!("{:.2} KB", bytes as f64 / KB as f64)
1023 } else {
1024 format!("{} bytes", bytes)
1025 }
1026}
1027
1028pub fn truncate_preview(preview: &str, max_length: usize) -> String {
1031 if preview.len() <= max_length {
1032 return preview.to_string();
1033 }
1034
1035 let truncate_at = preview.char_indices()
1037 .take(max_length)
1038 .filter(|(_, c)| c.is_whitespace())
1039 .last()
1040 .map(|(i, _)| i)
1041 .unwrap_or(max_length.min(preview.len()));
1042
1043 let mut truncated = preview[..truncate_at].to_string();
1044 truncated.push('…');
1045 truncated
1046}
1047
1048fn handle_query(
1050 pattern: String,
1051 symbols_flag: bool,
1052 lang: Option<String>,
1053 kind_str: Option<String>,
1054 use_ast: bool,
1055 use_regex: bool,
1056 as_json: bool,
1057 pretty_json: bool,
1058 ai_mode: bool,
1059 limit: Option<usize>,
1060 offset: Option<usize>,
1061 expand: bool,
1062 file_pattern: Option<String>,
1063 exact: bool,
1064 use_contains: bool,
1065 count_only: bool,
1066 timeout_secs: u64,
1067 plain: bool,
1068 glob_patterns: Vec<String>,
1069 exclude_patterns: Vec<String>,
1070 paths_only: bool,
1071 no_truncate: bool,
1072 all: bool,
1073 force: bool,
1074 include_dependencies: bool,
1075) -> Result<()> {
1076 log::info!("Starting query command");
1077
1078 let as_json = as_json || ai_mode;
1080
1081 let cache = CacheManager::new(".");
1082 let engine = QueryEngine::new(cache);
1083
1084 let language = if let Some(lang_str) = lang.as_deref() {
1086 match Language::from_name(lang_str) {
1087 Some(l) => Some(l),
1088 None => anyhow::bail!(
1089 "Unknown language: '{}'\n\nSupported languages:\n {}\n\nExample: rfx query \"pattern\" --lang rust",
1090 lang_str, Language::supported_names_help()
1091 ),
1092 }
1093 } else {
1094 None
1095 };
1096
1097 let kind = kind_str.as_deref().and_then(|s| {
1099 let capitalized = {
1101 let mut chars = s.chars();
1102 match chars.next() {
1103 None => String::new(),
1104 Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1105 }
1106 };
1107
1108 capitalized.parse::<crate::models::SymbolKind>()
1109 .ok()
1110 .or_else(|| {
1111 log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1113 Some(crate::models::SymbolKind::Unknown(s.to_string()))
1114 })
1115 });
1116
1117 let symbols_mode = symbols_flag || kind.is_some();
1119
1120 let final_limit = if count_only {
1128 None } else if all {
1130 None } else if limit == Some(0) {
1132 None } else if paths_only && limit.is_none() {
1134 None } else if let Some(user_limit) = limit {
1136 Some(user_limit) } else {
1138 Some(100) };
1140
1141 if use_ast && language.is_none() {
1143 anyhow::bail!(
1144 "AST pattern matching requires a language to be specified.\n\
1145 \n\
1146 Use --lang to specify the language for tree-sitter parsing.\n\
1147 \n\
1148 Supported languages for AST queries:\n\
1149 • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
1150 \n\
1151 Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
1152 \n\
1153 WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
1154 \n\
1155 Examples:\n\
1156 • rfx query \"(function_definition) @fn\" --ast --lang python\n\
1157 • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
1158 );
1159 }
1160
1161 if !as_json {
1164 let mut has_errors = false;
1165
1166 if use_regex && use_contains {
1168 eprintln!("{}", "ERROR: Cannot use --regex and --contains together.".red().bold());
1169 eprintln!(" {} --regex for pattern matching (alternation, wildcards, etc.)", "•".dimmed());
1170 eprintln!(" {} --contains for substring matching (expansive search)", "•".dimmed());
1171 eprintln!("\n {} Choose one based on your needs:", "Tip:".cyan().bold());
1172 eprintln!(" {} for OR logic: --regex", "pattern1|pattern2".yellow());
1173 eprintln!(" {} for substring: --contains", "partial_text".yellow());
1174 has_errors = true;
1175 }
1176
1177 if exact && use_contains {
1179 eprintln!("{}", "ERROR: Cannot use --exact and --contains together (contradictory).".red().bold());
1180 eprintln!(" {} --exact requires exact symbol name match", "•".dimmed());
1181 eprintln!(" {} --contains allows substring matching", "•".dimmed());
1182 has_errors = true;
1183 }
1184
1185 if file_pattern.is_some() && !glob_patterns.is_empty() {
1187 eprintln!("{}", "WARNING: Both --file and --glob specified.".yellow().bold());
1188 eprintln!(" {} --file does substring matching on file paths", "•".dimmed());
1189 eprintln!(" {} --glob does pattern matching with wildcards", "•".dimmed());
1190 eprintln!(" {} Both filters will apply (AND condition)", "Note:".dimmed());
1191 eprintln!("\n {} Usually you only need one:", "Tip:".cyan().bold());
1192 eprintln!(" {} for simple matching", "--file User.php".yellow());
1193 eprintln!(" {} for pattern matching", "--glob src/**/*.php".yellow());
1194 }
1195
1196 for pattern in &glob_patterns {
1198 if (pattern.starts_with('\'') && pattern.ends_with('\'')) ||
1200 (pattern.starts_with('"') && pattern.ends_with('"')) {
1201 eprintln!("{}",
1202 format!("WARNING: Glob pattern contains quotes: {}", pattern).yellow().bold()
1203 );
1204 eprintln!(" {} Shell quotes should not be part of the pattern", "Note:".dimmed());
1205 eprintln!(" {} --glob src/**/*.rs", "Correct:".green());
1206 eprintln!(" {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
1207 }
1208
1209 if pattern.contains("*/") && !pattern.contains("**/") {
1211 eprintln!("{}",
1212 format!("INFO: Glob '{}' uses * (matches one directory level)", pattern).cyan()
1213 );
1214 eprintln!(" {} Use ** for recursive matching across subdirectories", "Tip:".cyan().bold());
1215 eprintln!(" {} → matches files in Models/ only", "app/Models/*.php".yellow());
1216 eprintln!(" {} → matches files in Models/ and subdirs", "app/Models/**/*.php".green());
1217 }
1218 }
1219
1220 if has_errors {
1221 anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
1222 }
1223 }
1224
1225 let filter = QueryFilter {
1226 language,
1227 kind,
1228 use_ast,
1229 use_regex,
1230 limit: final_limit,
1231 symbols_mode,
1232 expand,
1233 file_pattern,
1234 exact,
1235 use_contains,
1236 timeout_secs,
1237 glob_patterns: glob_patterns.clone(),
1238 exclude_patterns,
1239 paths_only,
1240 offset,
1241 force,
1242 suppress_output: as_json, include_dependencies,
1244 ..Default::default()
1245 };
1246
1247 let start = Instant::now();
1249
1250 let (query_response, mut flat_results, total_results, has_more) = if use_ast {
1253 match engine.search_ast_all_files(&pattern, filter.clone()) {
1255 Ok(ast_results) => {
1256 let count = ast_results.len();
1257 (None, ast_results, count, false)
1258 }
1259 Err(e) => {
1260 if as_json {
1261 let error_response = serde_json::json!({
1263 "error": e.to_string(),
1264 "query_too_broad": e.to_string().contains("Query too broad")
1265 });
1266 let json_output = if pretty_json {
1267 serde_json::to_string_pretty(&error_response)?
1268 } else {
1269 serde_json::to_string(&error_response)?
1270 };
1271 println!("{}", json_output);
1272 std::process::exit(1);
1273 } else {
1274 return Err(e);
1275 }
1276 }
1277 }
1278 } else {
1279 match engine.search_with_metadata(&pattern, filter.clone()) {
1281 Ok(response) => {
1282 let total = response.pagination.total;
1283 let has_more = response.pagination.has_more;
1284
1285 let flat = response.results.iter()
1287 .flat_map(|file_group| {
1288 file_group.matches.iter().map(move |m| {
1289 crate::models::SearchResult {
1290 path: file_group.path.clone(),
1291 lang: crate::models::Language::Unknown, kind: m.kind.clone(),
1293 symbol: m.symbol.clone(),
1294 span: m.span.clone(),
1295 preview: m.preview.clone(),
1296 dependencies: file_group.dependencies.clone(),
1297 }
1298 })
1299 })
1300 .collect();
1301
1302 (Some(response), flat, total, has_more)
1303 }
1304 Err(e) => {
1305 if as_json {
1306 let error_response = serde_json::json!({
1308 "error": e.to_string(),
1309 "query_too_broad": e.to_string().contains("Query too broad")
1310 });
1311 let json_output = if pretty_json {
1312 serde_json::to_string_pretty(&error_response)?
1313 } else {
1314 serde_json::to_string(&error_response)?
1315 };
1316 println!("{}", json_output);
1317 std::process::exit(1);
1318 } else {
1319 return Err(e);
1320 }
1321 }
1322 }
1323 };
1324
1325 if !no_truncate {
1327 const MAX_PREVIEW_LENGTH: usize = 100;
1328 for result in &mut flat_results {
1329 result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
1330 }
1331 }
1332
1333 let elapsed = start.elapsed();
1334
1335 let timing_str = if elapsed.as_millis() < 1 {
1337 format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
1338 } else {
1339 format!("{}ms", elapsed.as_millis())
1340 };
1341
1342 if as_json {
1343 if count_only {
1344 let count_response = serde_json::json!({
1346 "count": total_results,
1347 "timing_ms": elapsed.as_millis()
1348 });
1349 let json_output = if pretty_json {
1350 serde_json::to_string_pretty(&count_response)?
1351 } else {
1352 serde_json::to_string(&count_response)?
1353 };
1354 println!("{}", json_output);
1355 } else if paths_only {
1356 let locations: Vec<serde_json::Value> = flat_results.iter()
1358 .map(|r| serde_json::json!({
1359 "path": r.path,
1360 "line": r.span.start_line
1361 }))
1362 .collect();
1363 let json_output = if pretty_json {
1364 serde_json::to_string_pretty(&locations)?
1365 } else {
1366 serde_json::to_string(&locations)?
1367 };
1368 println!("{}", json_output);
1369 eprintln!("Found {} unique files in {}", locations.len(), timing_str);
1370 } else {
1371 let mut response = if let Some(resp) = query_response {
1373 let mut resp = resp;
1376
1377 if !no_truncate {
1379 const MAX_PREVIEW_LENGTH: usize = 100;
1380 for file_group in resp.results.iter_mut() {
1381 for m in file_group.matches.iter_mut() {
1382 m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
1383 }
1384 }
1385 }
1386
1387 resp
1388 } else {
1389 use crate::models::{PaginationInfo, IndexStatus, FileGroupedResult, MatchResult};
1392 use std::collections::HashMap;
1393
1394 let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
1395 for result in &flat_results {
1396 grouped
1397 .entry(result.path.clone())
1398 .or_default()
1399 .push(result.clone());
1400 }
1401
1402 use crate::content_store::ContentReader;
1404 let local_cache = CacheManager::new(".");
1405 let content_path = local_cache.path().join("content.bin");
1406 let content_reader_opt = ContentReader::open(&content_path).ok();
1407
1408 let mut file_results: Vec<FileGroupedResult> = grouped
1409 .into_iter()
1410 .map(|(path, file_matches)| {
1411 let normalized_path = path.strip_prefix("./").unwrap_or(&path);
1415 let file_id_for_context = if let Some(reader) = &content_reader_opt {
1416 reader.get_file_id_by_path(normalized_path)
1417 } else {
1418 None
1419 };
1420
1421 let matches: Vec<MatchResult> = file_matches
1422 .into_iter()
1423 .map(|r| {
1424 let (context_before, context_after) = if let (Some(reader), Some(fid)) = (&content_reader_opt, file_id_for_context) {
1426 reader.get_context_by_line(fid as u32, r.span.start_line, 3)
1427 .unwrap_or_else(|_| (vec![], vec![]))
1428 } else {
1429 (vec![], vec![])
1430 };
1431
1432 MatchResult {
1433 kind: r.kind,
1434 symbol: r.symbol,
1435 span: r.span,
1436 preview: r.preview,
1437 context_before,
1438 context_after,
1439 }
1440 })
1441 .collect();
1442 FileGroupedResult {
1443 path,
1444 dependencies: None,
1445 matches,
1446 }
1447 })
1448 .collect();
1449
1450 file_results.sort_by(|a, b| a.path.cmp(&b.path));
1452
1453 crate::models::QueryResponse {
1454 ai_instruction: None, status: IndexStatus::Fresh,
1456 can_trust_results: true,
1457 warning: None,
1458 pagination: PaginationInfo {
1459 total: flat_results.len(),
1460 count: flat_results.len(),
1461 offset: offset.unwrap_or(0),
1462 limit,
1463 has_more: false, },
1465 results: file_results,
1466 }
1467 };
1468
1469 if ai_mode {
1471 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1472
1473 response.ai_instruction = crate::query::generate_ai_instruction(
1474 result_count,
1475 response.pagination.total,
1476 response.pagination.has_more,
1477 symbols_mode,
1478 paths_only,
1479 use_ast,
1480 use_regex,
1481 language.is_some(),
1482 !glob_patterns.is_empty(),
1483 exact,
1484 );
1485 }
1486
1487 let json_output = if pretty_json {
1488 serde_json::to_string_pretty(&response)?
1489 } else {
1490 serde_json::to_string(&response)?
1491 };
1492 println!("{}", json_output);
1493
1494 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1495 eprintln!("Found {} results in {}", result_count, timing_str);
1496 }
1497 } else {
1498 if count_only {
1500 println!("Found {} results in {}", flat_results.len(), timing_str);
1501 return Ok(());
1502 }
1503
1504 if paths_only {
1505 if flat_results.is_empty() {
1507 eprintln!("No results found (searched in {}).", timing_str);
1508 } else {
1509 for result in &flat_results {
1510 println!("{}", result.path);
1511 }
1512 eprintln!("Found {} unique files in {}", flat_results.len(), timing_str);
1513 }
1514 } else {
1515 if flat_results.is_empty() {
1517 println!("No results found (searched in {}).", timing_str);
1518 } else {
1519 let formatter = crate::formatter::OutputFormatter::new(plain);
1521 formatter.format_results(&flat_results, &pattern)?;
1522
1523 if total_results > flat_results.len() {
1525 println!("\nFound {} results ({} total) in {}", flat_results.len(), total_results, timing_str);
1527 if has_more {
1529 println!("Use --limit and --offset to paginate");
1530 }
1531 } else {
1532 println!("\nFound {} results in {}", flat_results.len(), timing_str);
1534 }
1535 }
1536 }
1537 }
1538
1539 Ok(())
1540}
1541
1542fn handle_serve(port: u16, host: String) -> Result<()> {
1544 log::info!("Starting HTTP server on {}:{}", host, port);
1545
1546 println!("Starting Reflex HTTP server...");
1547 println!(" Address: http://{}:{}", host, port);
1548 println!("\nEndpoints:");
1549 println!(" GET /query?q=<pattern>&lang=<lang>&kind=<kind>&limit=<n>&symbols=true®ex=true&exact=true&contains=true&expand=true&file=<pattern>&timeout=<secs>&glob=<pattern>&exclude=<pattern>&paths=true&dependencies=true");
1550 println!(" GET /stats");
1551 println!(" POST /index");
1552 println!("\nPress Ctrl+C to stop.");
1553
1554 let runtime = tokio::runtime::Runtime::new()?;
1556 runtime.block_on(async {
1557 run_server(port, host).await
1558 })
1559}
1560
1561async fn run_server(port: u16, host: String) -> Result<()> {
1563 use axum::{
1564 extract::{Query as AxumQuery, State},
1565 http::StatusCode,
1566 response::{IntoResponse, Json},
1567 routing::{get, post},
1568 Router,
1569 };
1570 use tower_http::cors::{CorsLayer, Any};
1571 use std::sync::Arc;
1572
1573 #[derive(Clone)]
1575 struct AppState {
1576 cache_path: String,
1577 }
1578
1579 #[derive(Debug, serde::Deserialize)]
1581 struct QueryParams {
1582 q: String,
1583 #[serde(default)]
1584 lang: Option<String>,
1585 #[serde(default)]
1586 kind: Option<String>,
1587 #[serde(default)]
1588 limit: Option<usize>,
1589 #[serde(default)]
1590 offset: Option<usize>,
1591 #[serde(default)]
1592 symbols: bool,
1593 #[serde(default)]
1594 regex: bool,
1595 #[serde(default)]
1596 exact: bool,
1597 #[serde(default)]
1598 contains: bool,
1599 #[serde(default)]
1600 expand: bool,
1601 #[serde(default)]
1602 file: Option<String>,
1603 #[serde(default = "default_timeout")]
1604 timeout: u64,
1605 #[serde(default)]
1606 glob: Vec<String>,
1607 #[serde(default)]
1608 exclude: Vec<String>,
1609 #[serde(default)]
1610 paths: bool,
1611 #[serde(default)]
1612 force: bool,
1613 #[serde(default)]
1614 dependencies: bool,
1615 }
1616
1617 fn default_timeout() -> u64 {
1619 30
1620 }
1621
1622 #[derive(Debug, serde::Deserialize)]
1624 struct IndexRequest {
1625 #[serde(default)]
1626 force: bool,
1627 #[serde(default)]
1628 languages: Vec<String>,
1629 }
1630
1631 async fn handle_query_endpoint(
1633 State(state): State<Arc<AppState>>,
1634 AxumQuery(params): AxumQuery<QueryParams>,
1635 ) -> Result<Json<crate::models::QueryResponse>, (StatusCode, String)> {
1636 log::info!("Query request: pattern={}", params.q);
1637
1638 let cache = CacheManager::new(&state.cache_path);
1639 let engine = QueryEngine::new(cache);
1640
1641 let language = if let Some(lang_str) = params.lang.as_deref() {
1643 match Language::from_name(lang_str) {
1644 Some(l) => Some(l),
1645 None => return Err((
1646 StatusCode::BAD_REQUEST,
1647 format!("Unknown language '{}'. Supported: {}", lang_str, Language::supported_names_help())
1648 )),
1649 }
1650 } else {
1651 None
1652 };
1653
1654 let kind = params.kind.as_deref().and_then(|s| {
1656 let capitalized = {
1657 let mut chars = s.chars();
1658 match chars.next() {
1659 None => String::new(),
1660 Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1661 }
1662 };
1663
1664 capitalized.parse::<crate::models::SymbolKind>()
1665 .ok()
1666 .or_else(|| {
1667 log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1668 Some(crate::models::SymbolKind::Unknown(s.to_string()))
1669 })
1670 });
1671
1672 let symbols_mode = params.symbols || kind.is_some();
1674
1675 let final_limit = if params.paths && params.limit.is_none() {
1677 None } else if let Some(user_limit) = params.limit {
1679 Some(user_limit) } else {
1681 Some(100) };
1683
1684 let filter = QueryFilter {
1685 language,
1686 kind,
1687 use_ast: false,
1688 use_regex: params.regex,
1689 limit: final_limit,
1690 symbols_mode,
1691 expand: params.expand,
1692 file_pattern: params.file,
1693 exact: params.exact,
1694 use_contains: params.contains,
1695 timeout_secs: params.timeout,
1696 glob_patterns: params.glob,
1697 exclude_patterns: params.exclude,
1698 paths_only: params.paths,
1699 offset: params.offset,
1700 force: params.force,
1701 suppress_output: true, include_dependencies: params.dependencies,
1703 ..Default::default()
1704 };
1705
1706 match engine.search_with_metadata(¶ms.q, filter) {
1707 Ok(response) => Ok(Json(response)),
1708 Err(e) => {
1709 log::error!("Query error: {}", e);
1710 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)))
1711 }
1712 }
1713 }
1714
1715 async fn handle_stats_endpoint(
1717 State(state): State<Arc<AppState>>,
1718 ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1719 log::info!("Stats request");
1720
1721 let cache = CacheManager::new(&state.cache_path);
1722
1723 if !cache.exists() {
1724 return Err((StatusCode::NOT_FOUND, "No index found. Run 'rfx index' first.".to_string()));
1725 }
1726
1727 match cache.stats() {
1728 Ok(stats) => Ok(Json(stats)),
1729 Err(e) => {
1730 log::error!("Stats error: {}", e);
1731 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get stats: {}", e)))
1732 }
1733 }
1734 }
1735
1736 async fn handle_index_endpoint(
1738 State(state): State<Arc<AppState>>,
1739 Json(req): Json<IndexRequest>,
1740 ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1741 log::info!("Index request: force={}, languages={:?}", req.force, req.languages);
1742
1743 let cache = CacheManager::new(&state.cache_path);
1744
1745 if req.force {
1746 log::info!("Force rebuild requested, clearing existing cache");
1747 if let Err(e) = cache.clear() {
1748 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to clear cache: {}", e)));
1749 }
1750 }
1751
1752 let lang_filters: Vec<Language> = req.languages
1754 .iter()
1755 .filter_map(|s| match s.to_lowercase().as_str() {
1756 "rust" | "rs" => Some(Language::Rust),
1757 "python" | "py" => Some(Language::Python),
1758 "javascript" | "js" => Some(Language::JavaScript),
1759 "typescript" | "ts" => Some(Language::TypeScript),
1760 "vue" => Some(Language::Vue),
1761 "svelte" => Some(Language::Svelte),
1762 "go" => Some(Language::Go),
1763 "java" => Some(Language::Java),
1764 "php" => Some(Language::PHP),
1765 "c" => Some(Language::C),
1766 "cpp" | "c++" => Some(Language::Cpp),
1767 _ => {
1768 log::warn!("Unknown language: {}", s);
1769 None
1770 }
1771 })
1772 .collect();
1773
1774 let config = IndexConfig {
1775 languages: lang_filters,
1776 ..Default::default()
1777 };
1778
1779 let indexer = Indexer::new(cache, config);
1780 let path = std::path::PathBuf::from(&state.cache_path);
1781
1782 match indexer.index(&path, false) {
1783 Ok(stats) => Ok(Json(stats)),
1784 Err(e) => {
1785 log::error!("Index error: {}", e);
1786 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Indexing failed: {}", e)))
1787 }
1788 }
1789 }
1790
1791 async fn handle_health() -> impl IntoResponse {
1793 (StatusCode::OK, "Reflex is running")
1794 }
1795
1796 let state = Arc::new(AppState {
1798 cache_path: ".".to_string(),
1799 });
1800
1801 let cors = CorsLayer::new()
1803 .allow_origin(Any)
1804 .allow_methods(Any)
1805 .allow_headers(Any);
1806
1807 let app = Router::new()
1809 .route("/query", get(handle_query_endpoint))
1810 .route("/stats", get(handle_stats_endpoint))
1811 .route("/index", post(handle_index_endpoint))
1812 .route("/health", get(handle_health))
1813 .layer(cors)
1814 .with_state(state);
1815
1816 let addr = format!("{}:{}", host, port);
1818 let listener = tokio::net::TcpListener::bind(&addr).await
1819 .map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", addr, e))?;
1820
1821 log::info!("Server listening on {}", addr);
1822
1823 axum::serve(listener, app)
1825 .await
1826 .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
1827
1828 Ok(())
1829}
1830
1831fn handle_stats(as_json: bool, pretty_json: bool) -> Result<()> {
1833 log::info!("Showing index statistics");
1834
1835 let cache = CacheManager::new(".");
1836
1837 if !cache.exists() {
1838 anyhow::bail!(
1839 "No index found in current directory.\n\
1840 \n\
1841 Run 'rfx index' to build the code search index first.\n\
1842 This will scan all files in the current directory and create a .reflex/ cache.\n\
1843 \n\
1844 Example:\n\
1845 $ rfx index # Index current directory\n\
1846 $ rfx stats # Show index statistics"
1847 );
1848 }
1849
1850 let stats = cache.stats()?;
1851
1852 if as_json {
1853 let json_output = if pretty_json {
1854 serde_json::to_string_pretty(&stats)?
1855 } else {
1856 serde_json::to_string(&stats)?
1857 };
1858 println!("{}", json_output);
1859 } else {
1860 println!("Reflex Index Statistics");
1861 println!("=======================");
1862
1863 let root = std::env::current_dir()?;
1865 if crate::git::is_git_repo(&root) {
1866 match crate::git::get_git_state(&root) {
1867 Ok(git_state) => {
1868 let dirty_indicator = if git_state.dirty { " (uncommitted changes)" } else { " (clean)" };
1869 println!("Branch: {}@{}{}",
1870 git_state.branch,
1871 &git_state.commit[..7],
1872 dirty_indicator);
1873
1874 match cache.get_branch_info(&git_state.branch) {
1876 Ok(branch_info) => {
1877 if branch_info.commit_sha != git_state.commit {
1878 println!(" ⚠️ Index commit mismatch (indexed: {})",
1879 &branch_info.commit_sha[..7]);
1880 }
1881 if git_state.dirty && !branch_info.is_dirty {
1882 println!(" ⚠️ Uncommitted changes not indexed");
1883 }
1884 }
1885 Err(_) => {
1886 println!(" ⚠️ Branch not indexed");
1887 }
1888 }
1889 }
1890 Err(e) => {
1891 log::warn!("Failed to get git state: {}", e);
1892 }
1893 }
1894 } else {
1895 println!("Branch: (None)");
1897 }
1898
1899 println!("Files indexed: {}", stats.total_files);
1900 println!("Index size: {} bytes", stats.index_size_bytes);
1901 println!("Last updated: {}", stats.last_updated);
1902
1903 if !stats.files_by_language.is_empty() {
1905 println!("\nFiles by language:");
1906
1907 let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
1909 lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
1910
1911 let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
1913 let lang_width = max_lang_len.max(8); println!(" {:<width$} Files Lines", "Language", width = lang_width);
1917 println!(" {} ----- -------", "-".repeat(lang_width));
1918
1919 for (language, file_count) in lang_vec {
1921 let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
1922 println!(" {:<width$} {:5} {:7}",
1923 language, file_count, line_count,
1924 width = lang_width);
1925 }
1926 }
1927 }
1928
1929 Ok(())
1930}
1931
1932fn handle_clear(skip_confirm: bool) -> Result<()> {
1934 let cache = CacheManager::new(".");
1935
1936 if !cache.exists() {
1937 println!("No cache to clear.");
1938 return Ok(());
1939 }
1940
1941 if !skip_confirm {
1942 println!("This will delete the local Reflex cache at: {:?}", cache.path());
1943 print!("Are you sure? [y/N] ");
1944 use std::io::{self, Write};
1945 io::stdout().flush()?;
1946
1947 let mut input = String::new();
1948 io::stdin().read_line(&mut input)?;
1949
1950 if !input.trim().eq_ignore_ascii_case("y") {
1951 println!("Cancelled.");
1952 return Ok(());
1953 }
1954 }
1955
1956 cache.clear()?;
1957 println!("Cache cleared successfully.");
1958
1959 Ok(())
1960}
1961
1962fn handle_list_files(as_json: bool, pretty_json: bool) -> Result<()> {
1964 let cache = CacheManager::new(".");
1965
1966 if !cache.exists() {
1967 anyhow::bail!(
1968 "No index found in current directory.\n\
1969 \n\
1970 Run 'rfx index' to build the code search index first.\n\
1971 This will scan all files in the current directory and create a .reflex/ cache.\n\
1972 \n\
1973 Example:\n\
1974 $ rfx index # Index current directory\n\
1975 $ rfx list-files # List indexed files"
1976 );
1977 }
1978
1979 let files = cache.list_files()?;
1980
1981 if as_json {
1982 let json_output = if pretty_json {
1983 serde_json::to_string_pretty(&files)?
1984 } else {
1985 serde_json::to_string(&files)?
1986 };
1987 println!("{}", json_output);
1988 } else if files.is_empty() {
1989 println!("No files indexed yet.");
1990 } else {
1991 println!("Indexed Files ({} total):", files.len());
1992 println!();
1993 for file in files {
1994 println!(" {} ({})",
1995 file.path,
1996 file.language);
1997 }
1998 }
1999
2000 Ok(())
2001}
2002
2003fn handle_watch(path: PathBuf, debounce_ms: u64, quiet: bool) -> Result<()> {
2005 log::info!("Starting watch mode for {:?}", path);
2006
2007 if !(5000..=30000).contains(&debounce_ms) {
2009 anyhow::bail!(
2010 "Debounce must be between 5000ms (5s) and 30000ms (30s). Got: {}ms",
2011 debounce_ms
2012 );
2013 }
2014
2015 if !quiet {
2016 println!("Starting Reflex watch mode...");
2017 println!(" Directory: {}", path.display());
2018 println!(" Debounce: {}ms ({}s)", debounce_ms, debounce_ms / 1000);
2019 println!(" Press Ctrl+C to stop.\n");
2020 }
2021
2022 let cache = CacheManager::new(&path);
2024
2025 if !cache.exists() {
2027 if !quiet {
2028 println!("No index found, running initial index...");
2029 }
2030 let config = IndexConfig::default();
2031 let indexer = Indexer::new(cache, config);
2032 indexer.index(&path, !quiet)?;
2033 if !quiet {
2034 println!("Initial index complete. Now watching for changes...\n");
2035 }
2036 }
2037
2038 let cache = CacheManager::new(&path);
2040 let config = IndexConfig::default();
2041 let indexer = Indexer::new(cache, config);
2042
2043 let watch_config = crate::watcher::WatchConfig {
2045 debounce_ms,
2046 quiet,
2047 };
2048
2049 crate::watcher::watch(&path, indexer, watch_config)?;
2050
2051 Ok(())
2052}
2053
2054fn handle_interactive() -> Result<()> {
2056 log::info!("Launching interactive mode");
2057 crate::interactive::run_interactive()
2058}
2059
2060fn handle_mcp() -> Result<()> {
2062 log::info!("Starting MCP server");
2063 crate::mcp::run_mcp_server()
2064}
2065
2066fn handle_index_symbols_internal(cache_dir: PathBuf) -> Result<()> {
2068 let mut indexer = crate::background_indexer::BackgroundIndexer::new(&cache_dir)?;
2069 indexer.run()?;
2070 Ok(())
2071}
2072
2073#[allow(clippy::too_many_arguments)]
2075fn handle_analyze(
2076 circular: bool,
2077 hotspots: bool,
2078 min_dependents: usize,
2079 unused: bool,
2080 islands: bool,
2081 min_island_size: usize,
2082 max_island_size: Option<usize>,
2083 format: String,
2084 as_json: bool,
2085 pretty_json: bool,
2086 count_only: bool,
2087 all: bool,
2088 plain: bool,
2089 _glob_patterns: Vec<String>,
2090 _exclude_patterns: Vec<String>,
2091 _force: bool,
2092 limit: Option<usize>,
2093 offset: Option<usize>,
2094 sort: Option<String>,
2095) -> Result<()> {
2096 use crate::dependency::DependencyIndex;
2097
2098 log::info!("Starting analyze command");
2099
2100 let cache = CacheManager::new(".");
2101
2102 if !cache.exists() {
2103 anyhow::bail!(
2104 "No index found in current directory.\n\
2105 \n\
2106 Run 'rfx index' to build the code search index first.\n\
2107 \n\
2108 Example:\n\
2109 $ rfx index # Index current directory\n\
2110 $ rfx analyze # Run dependency analysis"
2111 );
2112 }
2113
2114 let deps_index = DependencyIndex::new(cache);
2115
2116 let format = if as_json { "json" } else { &format };
2118
2119 let final_limit = if all {
2121 None } else if let Some(user_limit) = limit {
2123 Some(user_limit) } else {
2125 Some(200) };
2127
2128 if !circular && !hotspots && !unused && !islands {
2130 return handle_analyze_summary(&deps_index, min_dependents, count_only, as_json, pretty_json);
2131 }
2132
2133 if circular {
2135 handle_deps_circular(&deps_index, format, pretty_json, final_limit, offset, count_only, plain, sort.clone())?;
2136 }
2137
2138 if hotspots {
2139 handle_deps_hotspots(&deps_index, format, pretty_json, final_limit, offset, min_dependents, count_only, plain, sort.clone())?;
2140 }
2141
2142 if unused {
2143 handle_deps_unused(&deps_index, format, pretty_json, final_limit, offset, count_only, plain)?;
2144 }
2145
2146 if islands {
2147 handle_deps_islands(&deps_index, format, pretty_json, final_limit, offset, min_island_size, max_island_size, count_only, plain, sort.clone())?;
2148 }
2149
2150 Ok(())
2151}
2152
2153fn handle_analyze_summary(
2155 deps_index: &crate::dependency::DependencyIndex,
2156 min_dependents: usize,
2157 count_only: bool,
2158 as_json: bool,
2159 pretty_json: bool,
2160) -> Result<()> {
2161 let cycles = deps_index.detect_circular_dependencies()?;
2163 let hotspots = deps_index.find_hotspots(None, min_dependents)?;
2164 let unused = deps_index.find_unused_files()?;
2165 let all_islands = deps_index.find_islands()?;
2166
2167 if as_json {
2168 let summary = serde_json::json!({
2170 "circular_dependencies": cycles.len(),
2171 "hotspots": hotspots.len(),
2172 "unused_files": unused.len(),
2173 "islands": all_islands.len(),
2174 "min_dependents": min_dependents,
2175 });
2176
2177 let json_str = if pretty_json {
2178 serde_json::to_string_pretty(&summary)?
2179 } else {
2180 serde_json::to_string(&summary)?
2181 };
2182 println!("{}", json_str);
2183 } else if count_only {
2184 println!("{} circular dependencies", cycles.len());
2186 println!("{} hotspots ({}+ dependents)", hotspots.len(), min_dependents);
2187 println!("{} unused files", unused.len());
2188 println!("{} islands", all_islands.len());
2189 } else {
2190 println!("Dependency Analysis Summary\n");
2192
2193 println!("Circular Dependencies: {} cycle(s)", cycles.len());
2195
2196 println!("Hotspots: {} file(s) with {}+ dependents", hotspots.len(), min_dependents);
2198
2199 println!("Unused Files: {} file(s)", unused.len());
2201
2202 println!("Islands: {} disconnected component(s)", all_islands.len());
2204
2205 println!("\nUse specific flags for detailed results:");
2206 println!(" rfx analyze --circular");
2207 println!(" rfx analyze --hotspots");
2208 println!(" rfx analyze --unused");
2209 println!(" rfx analyze --islands");
2210 }
2211
2212 Ok(())
2213}
2214
2215fn handle_deps(
2217 file: PathBuf,
2218 reverse: bool,
2219 depth: usize,
2220 format: String,
2221 as_json: bool,
2222 pretty_json: bool,
2223) -> Result<()> {
2224 use crate::dependency::DependencyIndex;
2225
2226 log::info!("Starting deps command");
2227
2228 let cache = CacheManager::new(".");
2229
2230 if !cache.exists() {
2231 anyhow::bail!(
2232 "No index found in current directory.\n\
2233 \n\
2234 Run 'rfx index' to build the code search index first.\n\
2235 \n\
2236 Example:\n\
2237 $ rfx index # Index current directory\n\
2238 $ rfx deps <file> # Analyze dependencies"
2239 );
2240 }
2241
2242 let deps_index = DependencyIndex::new(cache);
2243
2244 let format = if as_json { "json" } else { &format };
2246
2247 let file_str = file.to_string_lossy().to_string();
2249
2250 let file_id = deps_index.get_file_id_by_path(&file_str)?
2252 .ok_or_else(|| anyhow::anyhow!("File '{}' not found in index", file_str))?;
2253
2254 if reverse {
2255 let dependents = deps_index.get_dependents(file_id)?;
2257 let paths = deps_index.get_file_paths(&dependents)?;
2258
2259 match format.as_ref() {
2260 "json" => {
2261 let output: Vec<_> = dependents.iter()
2262 .filter_map(|id| paths.get(id).map(|path| serde_json::json!({
2263 "file_id": id,
2264 "path": path,
2265 })))
2266 .collect();
2267
2268 let json_str = if pretty_json {
2269 serde_json::to_string_pretty(&output)?
2270 } else {
2271 serde_json::to_string(&output)?
2272 };
2273 println!("{}", json_str);
2274 eprintln!("Found {} files that import {}", dependents.len(), file_str);
2275 }
2276 "tree" => {
2277 println!("Files that import {}:", file_str);
2278 for (id, path) in &paths {
2279 if dependents.contains(id) {
2280 println!(" └─ {}", path);
2281 }
2282 }
2283 eprintln!("\nFound {} dependents", dependents.len());
2284 }
2285 "table" => {
2286 println!("ID Path");
2287 println!("----- ----");
2288 for id in &dependents {
2289 if let Some(path) = paths.get(id) {
2290 println!("{:<5} {}", id, path);
2291 }
2292 }
2293 eprintln!("\nFound {} dependents", dependents.len());
2294 }
2295 _ => {
2296 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2297 }
2298 }
2299 } else {
2300 if depth == 1 {
2302 let deps = deps_index.get_dependencies(file_id)?;
2304
2305 match format.as_ref() {
2306 "json" => {
2307 let output: Vec<_> = deps.iter()
2308 .map(|dep| serde_json::json!({
2309 "imported_path": dep.imported_path,
2310 "resolved_file_id": dep.resolved_file_id,
2311 "import_type": match dep.import_type {
2312 crate::models::ImportType::Internal => "internal",
2313 crate::models::ImportType::External => "external",
2314 crate::models::ImportType::Stdlib => "stdlib",
2315 },
2316 "line": dep.line_number,
2317 "symbols": dep.imported_symbols,
2318 }))
2319 .collect();
2320
2321 let json_str = if pretty_json {
2322 serde_json::to_string_pretty(&output)?
2323 } else {
2324 serde_json::to_string(&output)?
2325 };
2326 println!("{}", json_str);
2327 eprintln!("Found {} dependencies for {}", deps.len(), file_str);
2328 }
2329 "tree" => {
2330 println!("Dependencies of {}:", file_str);
2331 for dep in &deps {
2332 let type_label = match dep.import_type {
2333 crate::models::ImportType::Internal => "[internal]",
2334 crate::models::ImportType::External => "[external]",
2335 crate::models::ImportType::Stdlib => "[stdlib]",
2336 };
2337 println!(" └─ {} {} (line {})", dep.imported_path, type_label, dep.line_number);
2338 }
2339 eprintln!("\nFound {} dependencies", deps.len());
2340 }
2341 "table" => {
2342 println!("Path Type Line");
2343 println!("---------------------------- --------- ----");
2344 for dep in &deps {
2345 let type_str = match dep.import_type {
2346 crate::models::ImportType::Internal => "internal",
2347 crate::models::ImportType::External => "external",
2348 crate::models::ImportType::Stdlib => "stdlib",
2349 };
2350 println!("{:<28} {:<9} {}", dep.imported_path, type_str, dep.line_number);
2351 }
2352 eprintln!("\nFound {} dependencies", deps.len());
2353 }
2354 _ => {
2355 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2356 }
2357 }
2358 } else {
2359 let transitive = deps_index.get_transitive_deps(file_id, depth)?;
2361 let file_ids: Vec<_> = transitive.keys().copied().collect();
2362 let paths = deps_index.get_file_paths(&file_ids)?;
2363
2364 match format.as_ref() {
2365 "json" => {
2366 let output: Vec<_> = transitive.iter()
2367 .filter_map(|(id, d)| {
2368 paths.get(id).map(|path| serde_json::json!({
2369 "file_id": id,
2370 "path": path,
2371 "depth": d,
2372 }))
2373 })
2374 .collect();
2375
2376 let json_str = if pretty_json {
2377 serde_json::to_string_pretty(&output)?
2378 } else {
2379 serde_json::to_string(&output)?
2380 };
2381 println!("{}", json_str);
2382 eprintln!("Found {} transitive dependencies (depth {})", transitive.len(), depth);
2383 }
2384 "tree" => {
2385 println!("Transitive dependencies of {} (depth {}):", file_str, depth);
2386 let mut by_depth: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
2388 for (id, d) in &transitive {
2389 by_depth.entry(*d).or_insert_with(Vec::new).push(*id);
2390 }
2391
2392 for depth_level in 0..=depth {
2393 if let Some(ids) = by_depth.get(&depth_level) {
2394 let indent = " ".repeat(depth_level);
2395 for id in ids {
2396 if let Some(path) = paths.get(id) {
2397 if depth_level == 0 {
2398 println!("{}{} (self)", indent, path);
2399 } else {
2400 println!("{}└─ {}", indent, path);
2401 }
2402 }
2403 }
2404 }
2405 }
2406 eprintln!("\nFound {} transitive dependencies", transitive.len());
2407 }
2408 "table" => {
2409 println!("Depth File ID Path");
2410 println!("----- ------- ----");
2411 let mut sorted: Vec<_> = transitive.iter().collect();
2412 sorted.sort_by_key(|(_, d)| *d);
2413 for (id, d) in sorted {
2414 if let Some(path) = paths.get(id) {
2415 println!("{:<5} {:<7} {}", d, id, path);
2416 }
2417 }
2418 eprintln!("\nFound {} transitive dependencies", transitive.len());
2419 }
2420 _ => {
2421 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2422 }
2423 }
2424 }
2425 }
2426
2427 Ok(())
2428}
2429
2430fn handle_ask(
2432 question: Option<String>,
2433 _auto_execute: bool,
2434 provider_override: Option<String>,
2435 as_json: bool,
2436 pretty_json: bool,
2437 additional_context: Option<String>,
2438 configure: bool,
2439 agentic: bool,
2440 max_iterations: usize,
2441 no_eval: bool,
2442 show_reasoning: bool,
2443 verbose: bool,
2444 quiet: bool,
2445 answer: bool,
2446 interactive: bool,
2447 debug: bool,
2448) -> Result<()> {
2449 if configure {
2451 log::info!("Launching configuration wizard");
2452 return crate::semantic::run_configure_wizard();
2453 }
2454
2455 if !crate::semantic::is_any_api_key_configured() {
2457 anyhow::bail!(
2458 "No API key configured.\n\
2459 \n\
2460 Please run 'rfx ask --configure' to set up your API provider and key.\n\
2461 \n\
2462 Alternatively, you can set an environment variable:\n\
2463 - OPENAI_API_KEY\n\
2464 - ANTHROPIC_API_KEY\n\
2465 - GROQ_API_KEY"
2466 );
2467 }
2468
2469 if interactive || question.is_none() {
2472 log::info!("Launching interactive chat mode");
2473 let cache = CacheManager::new(".");
2474
2475 if !cache.exists() {
2476 anyhow::bail!(
2477 "No index found in current directory.\n\
2478 \n\
2479 Run 'rfx index' to build the code search index first.\n\
2480 \n\
2481 Example:\n\
2482 $ rfx index # Index current directory\n\
2483 $ rfx ask # Launch interactive chat"
2484 );
2485 }
2486
2487 return crate::semantic::run_chat_mode(cache, provider_override, None);
2488 }
2489
2490 let question = question.unwrap();
2492
2493 log::info!("Starting ask command");
2494
2495 let cache = CacheManager::new(".");
2496
2497 if !cache.exists() {
2498 anyhow::bail!(
2499 "No index found in current directory.\n\
2500 \n\
2501 Run 'rfx index' to build the code search index first.\n\
2502 \n\
2503 Example:\n\
2504 $ rfx index # Index current directory\n\
2505 $ rfx ask \"Find all TODOs\" # Ask questions"
2506 );
2507 }
2508
2509 let runtime = tokio::runtime::Runtime::new()
2511 .context("Failed to create async runtime")?;
2512
2513 let quiet = quiet || as_json;
2515
2516 let spinner = if !as_json {
2518 let s = ProgressBar::new_spinner();
2519 s.set_style(
2520 ProgressStyle::default_spinner()
2521 .template("{spinner:.cyan} {msg}")
2522 .unwrap()
2523 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2524 );
2525 s.set_message("Generating queries...".to_string());
2526 s.enable_steady_tick(std::time::Duration::from_millis(80));
2527 Some(s)
2528 } else {
2529 None
2530 };
2531
2532 let (queries, results, total_count, count_only, gathered_context) = if agentic {
2533 let spinner_shared = if !quiet {
2537 spinner.as_ref().map(|s| Arc::new(Mutex::new(s.clone())))
2538 } else {
2539 None
2540 };
2541
2542 let reporter: Box<dyn crate::semantic::AgenticReporter> = if quiet {
2544 Box::new(crate::semantic::QuietReporter)
2545 } else {
2546 Box::new(crate::semantic::ConsoleReporter::new(show_reasoning, verbose, debug, spinner_shared))
2547 };
2548
2549 if let Some(ref s) = spinner {
2551 s.set_message("Starting agentic mode...".to_string());
2552 s.enable_steady_tick(std::time::Duration::from_millis(80));
2553 }
2554
2555 let agentic_config = crate::semantic::AgenticConfig {
2556 max_iterations,
2557 max_tools_per_phase: 5,
2558 enable_evaluation: !no_eval,
2559 eval_config: Default::default(),
2560 provider_override: provider_override.clone(),
2561 model_override: None,
2562 show_reasoning,
2563 verbose,
2564 debug,
2565 };
2566
2567 let agentic_response = runtime.block_on(async {
2568 crate::semantic::run_agentic_loop(&question, &cache, agentic_config, &*reporter).await
2569 }).context("Failed to run agentic loop")?;
2570
2571 if let Some(ref s) = spinner {
2573 s.finish_and_clear();
2574 }
2575
2576 if !as_json {
2578 reporter.clear_all();
2579 }
2580
2581 log::info!("Agentic loop completed: {} queries generated", agentic_response.queries.len());
2582
2583 let count_only_mode = agentic_response.total_count.is_none();
2585 let count = agentic_response.total_count.unwrap_or(0);
2586 (agentic_response.queries, agentic_response.results, count, count_only_mode, agentic_response.gathered_context)
2587 } else {
2588 if let Some(ref s) = spinner {
2590 s.set_message("Generating queries...".to_string());
2591 s.enable_steady_tick(std::time::Duration::from_millis(80));
2592 }
2593
2594 let semantic_response = runtime.block_on(async {
2595 crate::semantic::ask_question(&question, &cache, provider_override.clone(), additional_context, debug).await
2596 }).context("Failed to generate semantic queries")?;
2597
2598 if let Some(ref s) = spinner {
2599 s.finish_and_clear();
2600 }
2601 log::info!("LLM generated {} queries", semantic_response.queries.len());
2602
2603 let (exec_results, exec_total, exec_count_only) = runtime.block_on(async {
2605 crate::semantic::execute_queries(semantic_response.queries.clone(), &cache).await
2606 }).context("Failed to execute queries")?;
2607
2608 (semantic_response.queries, exec_results, exec_total, exec_count_only, None)
2609 };
2610
2611 let generated_answer = if answer {
2613 let answer_spinner = if !as_json {
2615 let s = ProgressBar::new_spinner();
2616 s.set_style(
2617 ProgressStyle::default_spinner()
2618 .template("{spinner:.cyan} {msg}")
2619 .unwrap()
2620 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2621 );
2622 s.set_message("Generating answer...".to_string());
2623 s.enable_steady_tick(std::time::Duration::from_millis(80));
2624 Some(s)
2625 } else {
2626 None
2627 };
2628
2629 let mut config = crate::semantic::config::load_config(cache.path())?;
2631 if let Some(provider) = &provider_override {
2632 config.provider = provider.clone();
2633 }
2634 let api_key = crate::semantic::config::get_api_key(&config.provider)?;
2635 let model = if config.model.is_some() {
2636 config.model.clone()
2637 } else {
2638 crate::semantic::config::get_user_model(&config.provider)
2639 };
2640 let provider_instance = crate::semantic::providers::create_provider(
2641 &config.provider,
2642 api_key,
2643 model,
2644 )?;
2645
2646 let codebase_context_str = crate::semantic::context::CodebaseContext::extract(&cache)
2648 .ok()
2649 .map(|ctx| ctx.to_prompt_string());
2650
2651 let answer_result = runtime.block_on(async {
2653 crate::semantic::generate_answer(
2654 &question,
2655 &results,
2656 total_count,
2657 gathered_context.as_deref(),
2658 codebase_context_str.as_deref(),
2659 &*provider_instance,
2660 ).await
2661 }).context("Failed to generate answer")?;
2662
2663 if let Some(s) = answer_spinner {
2664 s.finish_and_clear();
2665 }
2666
2667 Some(answer_result)
2668 } else {
2669 None
2670 };
2671
2672 if as_json {
2674 let json_response = crate::semantic::AgenticQueryResponse {
2676 queries: queries.clone(),
2677 results: results.clone(),
2678 total_count: if count_only { None } else { Some(total_count) },
2679 gathered_context: gathered_context.clone(),
2680 tools_executed: None, answer: generated_answer,
2682 };
2683
2684 let json_str = if pretty_json {
2685 serde_json::to_string_pretty(&json_response)?
2686 } else {
2687 serde_json::to_string(&json_response)?
2688 };
2689 println!("{}", json_str);
2690 return Ok(());
2691 }
2692
2693 if !answer {
2695 println!("\n{}", "Generated Queries:".bold().cyan());
2696 println!("{}", "==================".cyan());
2697 for (idx, query_cmd) in queries.iter().enumerate() {
2698 println!(
2699 "{}. {} {} {}",
2700 (idx + 1).to_string().bright_white().bold(),
2701 format!("[order: {}, merge: {}]", query_cmd.order, query_cmd.merge).dimmed(),
2702 "rfx".bright_green().bold(),
2703 query_cmd.command.bright_white()
2704 );
2705 }
2706 println!();
2707 }
2708
2709 println!();
2715 if let Some(answer_text) = generated_answer {
2716 println!("{}", "Answer:".bold().green());
2718 println!("{}", "=======".green());
2719 println!();
2720
2721 termimad::print_text(&answer_text);
2723 println!();
2724
2725 if !results.is_empty() {
2727 println!(
2728 "{}",
2729 format!(
2730 "(Based on {} matches across {} files)",
2731 total_count,
2732 results.len()
2733 ).dimmed()
2734 );
2735 }
2736 } else {
2737 if count_only {
2739 println!("{} {}", "Found".bright_green().bold(), format!("{} results", total_count).bright_white().bold());
2741 } else if results.is_empty() {
2742 println!("{}", "No results found.".yellow());
2743 } else {
2744 println!(
2745 "{} {} {} {} {}",
2746 "Found".bright_green().bold(),
2747 total_count.to_string().bright_white().bold(),
2748 "total results across".dimmed(),
2749 results.len().to_string().bright_white().bold(),
2750 "files:".dimmed()
2751 );
2752 println!();
2753
2754 for file_group in &results {
2755 println!("{}:", file_group.path.bright_cyan().bold());
2756 for match_result in &file_group.matches {
2757 println!(
2758 " {} {}-{}: {}",
2759 "Line".dimmed(),
2760 match_result.span.start_line.to_string().bright_yellow(),
2761 match_result.span.end_line.to_string().bright_yellow(),
2762 match_result.preview.lines().next().unwrap_or("")
2763 );
2764 }
2765 println!();
2766 }
2767 }
2768 }
2769
2770 Ok(())
2771}
2772
2773fn handle_context(
2775 structure: bool,
2776 path: Option<String>,
2777 file_types: bool,
2778 project_type: bool,
2779 framework: bool,
2780 entry_points: bool,
2781 test_layout: bool,
2782 config_files: bool,
2783 depth: usize,
2784 json: bool,
2785) -> Result<()> {
2786 let cache = CacheManager::new(".");
2787
2788 if !cache.exists() {
2789 anyhow::bail!(
2790 "No index found in current directory.\n\
2791 \n\
2792 Run 'rfx index' to build the code search index first.\n\
2793 \n\
2794 Example:\n\
2795 $ rfx index # Index current directory\n\
2796 $ rfx context # Generate context"
2797 );
2798 }
2799
2800 let opts = crate::context::ContextOptions {
2802 structure,
2803 path,
2804 file_types,
2805 project_type,
2806 framework,
2807 entry_points,
2808 test_layout,
2809 config_files,
2810 depth,
2811 json,
2812 };
2813
2814 let context_output = crate::context::generate_context(&cache, &opts)
2816 .context("Failed to generate codebase context")?;
2817
2818 println!("{}", context_output);
2820
2821 Ok(())
2822}
2823
2824fn handle_deps_circular(
2826 deps_index: &crate::dependency::DependencyIndex,
2827 format: &str,
2828 pretty_json: bool,
2829 limit: Option<usize>,
2830 offset: Option<usize>,
2831 count_only: bool,
2832 _plain: bool,
2833 sort: Option<String>,
2834) -> Result<()> {
2835 let mut all_cycles = deps_index.detect_circular_dependencies()?;
2836
2837 let sort_order = sort.as_deref().unwrap_or("desc");
2839 match sort_order {
2840 "asc" => {
2841 all_cycles.sort_by_key(|cycle| cycle.len());
2843 }
2844 "desc" => {
2845 all_cycles.sort_by_key(|cycle| std::cmp::Reverse(cycle.len()));
2847 }
2848 _ => {
2849 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
2850 }
2851 }
2852
2853 let total_count = all_cycles.len();
2854
2855 if count_only {
2856 println!("Found {} circular dependencies", total_count);
2857 return Ok(());
2858 }
2859
2860 if all_cycles.is_empty() {
2861 println!("No circular dependencies found.");
2862 return Ok(());
2863 }
2864
2865 let offset_val = offset.unwrap_or(0);
2867 let mut cycles: Vec<_> = all_cycles.into_iter().skip(offset_val).collect();
2868
2869 if let Some(lim) = limit {
2871 cycles.truncate(lim);
2872 }
2873
2874 if cycles.is_empty() {
2875 println!("No circular dependencies found at offset {}.", offset_val);
2876 return Ok(());
2877 }
2878
2879 let count = cycles.len();
2880 let has_more = offset_val + count < total_count;
2881
2882 match format {
2883 "json" => {
2884 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2885 let paths = deps_index.get_file_paths(&file_ids)?;
2886
2887 let results: Vec<_> = cycles.iter()
2888 .map(|cycle| {
2889 let cycle_paths: Vec<_> = cycle.iter()
2890 .filter_map(|id| paths.get(id).cloned())
2891 .collect();
2892 serde_json::json!({
2893 "paths": cycle_paths,
2894 })
2895 })
2896 .collect();
2897
2898 let output = serde_json::json!({
2899 "pagination": {
2900 "total": total_count,
2901 "count": count,
2902 "offset": offset_val,
2903 "limit": limit,
2904 "has_more": has_more,
2905 },
2906 "results": results,
2907 });
2908
2909 let json_str = if pretty_json {
2910 serde_json::to_string_pretty(&output)?
2911 } else {
2912 serde_json::to_string(&output)?
2913 };
2914 println!("{}", json_str);
2915 if total_count > count {
2916 eprintln!("Found {} circular dependencies ({} total)", count, total_count);
2917 } else {
2918 eprintln!("Found {} circular dependencies", count);
2919 }
2920 }
2921 "tree" => {
2922 println!("Circular Dependencies Found:");
2923 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2924 let paths = deps_index.get_file_paths(&file_ids)?;
2925
2926 for (idx, cycle) in cycles.iter().enumerate() {
2927 println!("\nCycle {}:", idx + 1);
2928 for id in cycle {
2929 if let Some(path) = paths.get(id) {
2930 println!(" → {}", path);
2931 }
2932 }
2933 if let Some(first_id) = cycle.first() {
2935 if let Some(path) = paths.get(first_id) {
2936 println!(" → {} (cycle completes)", path);
2937 }
2938 }
2939 }
2940 if total_count > count {
2941 eprintln!("\nFound {} cycles ({} total)", count, total_count);
2942 if has_more {
2943 eprintln!("Use --limit and --offset to paginate");
2944 }
2945 } else {
2946 eprintln!("\nFound {} cycles", count);
2947 }
2948 }
2949 "table" => {
2950 println!("Cycle Files in Cycle");
2951 println!("----- --------------");
2952 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2953 let paths = deps_index.get_file_paths(&file_ids)?;
2954
2955 for (idx, cycle) in cycles.iter().enumerate() {
2956 let cycle_str = cycle.iter()
2957 .filter_map(|id| paths.get(id).map(|p| p.as_str()))
2958 .collect::<Vec<_>>()
2959 .join(" → ");
2960 println!("{:<5} {}", idx + 1, cycle_str);
2961 }
2962 if total_count > count {
2963 eprintln!("\nFound {} cycles ({} total)", count, total_count);
2964 if has_more {
2965 eprintln!("Use --limit and --offset to paginate");
2966 }
2967 } else {
2968 eprintln!("\nFound {} cycles", count);
2969 }
2970 }
2971 _ => {
2972 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
2973 }
2974 }
2975
2976 Ok(())
2977}
2978
2979fn handle_deps_hotspots(
2981 deps_index: &crate::dependency::DependencyIndex,
2982 format: &str,
2983 pretty_json: bool,
2984 limit: Option<usize>,
2985 offset: Option<usize>,
2986 min_dependents: usize,
2987 count_only: bool,
2988 _plain: bool,
2989 sort: Option<String>,
2990) -> Result<()> {
2991 let mut all_hotspots = deps_index.find_hotspots(None, min_dependents)?;
2993
2994 let sort_order = sort.as_deref().unwrap_or("desc");
2996 match sort_order {
2997 "asc" => {
2998 all_hotspots.sort_by(|a, b| a.1.cmp(&b.1));
3000 }
3001 "desc" => {
3002 all_hotspots.sort_by(|a, b| b.1.cmp(&a.1));
3004 }
3005 _ => {
3006 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3007 }
3008 }
3009
3010 let total_count = all_hotspots.len();
3011
3012 if count_only {
3013 println!("Found {} hotspots with {}+ dependents", total_count, min_dependents);
3014 return Ok(());
3015 }
3016
3017 if all_hotspots.is_empty() {
3018 println!("No hotspots found.");
3019 return Ok(());
3020 }
3021
3022 let offset_val = offset.unwrap_or(0);
3024 let mut hotspots: Vec<_> = all_hotspots.into_iter().skip(offset_val).collect();
3025
3026 if let Some(lim) = limit {
3028 hotspots.truncate(lim);
3029 }
3030
3031 if hotspots.is_empty() {
3032 println!("No hotspots found at offset {}.", offset_val);
3033 return Ok(());
3034 }
3035
3036 let count = hotspots.len();
3037 let has_more = offset_val + count < total_count;
3038
3039 let file_ids: Vec<i64> = hotspots.iter().map(|(id, _)| *id).collect();
3040 let paths = deps_index.get_file_paths(&file_ids)?;
3041
3042 match format {
3043 "json" => {
3044 let results: Vec<_> = hotspots.iter()
3045 .filter_map(|(id, import_count)| {
3046 paths.get(id).map(|path| serde_json::json!({
3047 "path": path,
3048 "import_count": import_count,
3049 }))
3050 })
3051 .collect();
3052
3053 let output = serde_json::json!({
3054 "pagination": {
3055 "total": total_count,
3056 "count": count,
3057 "offset": offset_val,
3058 "limit": limit,
3059 "has_more": has_more,
3060 },
3061 "results": results,
3062 });
3063
3064 let json_str = if pretty_json {
3065 serde_json::to_string_pretty(&output)?
3066 } else {
3067 serde_json::to_string(&output)?
3068 };
3069 println!("{}", json_str);
3070 if total_count > count {
3071 eprintln!("Found {} hotspots ({} total)", count, total_count);
3072 } else {
3073 eprintln!("Found {} hotspots", count);
3074 }
3075 }
3076 "tree" => {
3077 println!("Hotspots (Most-Imported Files):");
3078 for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3079 if let Some(path) = paths.get(id) {
3080 println!(" {}. {} ({} imports)", idx + 1, path, import_count);
3081 }
3082 }
3083 if total_count > count {
3084 eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3085 if has_more {
3086 eprintln!("Use --limit and --offset to paginate");
3087 }
3088 } else {
3089 eprintln!("\nFound {} hotspots", count);
3090 }
3091 }
3092 "table" => {
3093 println!("Rank Imports File");
3094 println!("---- ------- ----");
3095 for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3096 if let Some(path) = paths.get(id) {
3097 println!("{:<4} {:<7} {}", idx + 1, import_count, path);
3098 }
3099 }
3100 if total_count > count {
3101 eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3102 if has_more {
3103 eprintln!("Use --limit and --offset to paginate");
3104 }
3105 } else {
3106 eprintln!("\nFound {} hotspots", count);
3107 }
3108 }
3109 _ => {
3110 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3111 }
3112 }
3113
3114 Ok(())
3115}
3116
3117fn handle_deps_unused(
3119 deps_index: &crate::dependency::DependencyIndex,
3120 format: &str,
3121 pretty_json: bool,
3122 limit: Option<usize>,
3123 offset: Option<usize>,
3124 count_only: bool,
3125 _plain: bool,
3126) -> Result<()> {
3127 let all_unused = deps_index.find_unused_files()?;
3128 let total_count = all_unused.len();
3129
3130 if count_only {
3131 println!("Found {} unused files", total_count);
3132 return Ok(());
3133 }
3134
3135 if all_unused.is_empty() {
3136 println!("No unused files found (all files have incoming dependencies).");
3137 return Ok(());
3138 }
3139
3140 let offset_val = offset.unwrap_or(0);
3142 let mut unused: Vec<_> = all_unused.into_iter().skip(offset_val).collect();
3143
3144 if unused.is_empty() {
3145 println!("No unused files found at offset {}.", offset_val);
3146 return Ok(());
3147 }
3148
3149 if let Some(lim) = limit {
3151 unused.truncate(lim);
3152 }
3153
3154 let count = unused.len();
3155 let has_more = offset_val + count < total_count;
3156
3157 let paths = deps_index.get_file_paths(&unused)?;
3158
3159 match format {
3160 "json" => {
3161 let results: Vec<String> = unused.iter()
3163 .filter_map(|id| paths.get(id).cloned())
3164 .collect();
3165
3166 let output = serde_json::json!({
3167 "pagination": {
3168 "total": total_count,
3169 "count": count,
3170 "offset": offset_val,
3171 "limit": limit,
3172 "has_more": has_more,
3173 },
3174 "results": results,
3175 });
3176
3177 let json_str = if pretty_json {
3178 serde_json::to_string_pretty(&output)?
3179 } else {
3180 serde_json::to_string(&output)?
3181 };
3182 println!("{}", json_str);
3183 if total_count > count {
3184 eprintln!("Found {} unused files ({} total)", count, total_count);
3185 } else {
3186 eprintln!("Found {} unused files", count);
3187 }
3188 }
3189 "tree" => {
3190 println!("Unused Files (No Incoming Dependencies):");
3191 for (idx, id) in unused.iter().enumerate() {
3192 if let Some(path) = paths.get(id) {
3193 println!(" {}. {}", idx + 1, path);
3194 }
3195 }
3196 if total_count > count {
3197 eprintln!("\nFound {} unused files ({} total)", count, total_count);
3198 if has_more {
3199 eprintln!("Use --limit and --offset to paginate");
3200 }
3201 } else {
3202 eprintln!("\nFound {} unused files", count);
3203 }
3204 }
3205 "table" => {
3206 println!("Path");
3207 println!("----");
3208 for id in &unused {
3209 if let Some(path) = paths.get(id) {
3210 println!("{}", path);
3211 }
3212 }
3213 if total_count > count {
3214 eprintln!("\nFound {} unused files ({} total)", count, total_count);
3215 if has_more {
3216 eprintln!("Use --limit and --offset to paginate");
3217 }
3218 } else {
3219 eprintln!("\nFound {} unused files", count);
3220 }
3221 }
3222 _ => {
3223 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3224 }
3225 }
3226
3227 Ok(())
3228}
3229
3230fn handle_deps_islands(
3232 deps_index: &crate::dependency::DependencyIndex,
3233 format: &str,
3234 pretty_json: bool,
3235 limit: Option<usize>,
3236 offset: Option<usize>,
3237 min_island_size: usize,
3238 max_island_size: Option<usize>,
3239 count_only: bool,
3240 _plain: bool,
3241 sort: Option<String>,
3242) -> Result<()> {
3243 let all_islands = deps_index.find_islands()?;
3244 let total_components = all_islands.len();
3245
3246 let cache = deps_index.get_cache();
3248 let total_files = cache.stats()?.total_files as usize;
3249
3250 let max_size = max_island_size.unwrap_or_else(|| {
3252 let fifty_percent = (total_files as f64 * 0.5) as usize;
3253 fifty_percent.min(500)
3254 });
3255
3256 let mut islands: Vec<_> = all_islands.into_iter()
3258 .filter(|island| {
3259 let size = island.len();
3260 size >= min_island_size && size <= max_size
3261 })
3262 .collect();
3263
3264 let sort_order = sort.as_deref().unwrap_or("desc");
3266 match sort_order {
3267 "asc" => {
3268 islands.sort_by_key(|island| island.len());
3270 }
3271 "desc" => {
3272 islands.sort_by_key(|island| std::cmp::Reverse(island.len()));
3274 }
3275 _ => {
3276 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3277 }
3278 }
3279
3280 let filtered_count = total_components - islands.len();
3281
3282 if count_only {
3283 if filtered_count > 0 {
3284 println!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3285 islands.len(), filtered_count, total_components, min_island_size, max_size);
3286 } else {
3287 println!("Found {} islands", islands.len());
3288 }
3289 return Ok(());
3290 }
3291
3292 let offset_val = offset.unwrap_or(0);
3294 if offset_val > 0 && offset_val < islands.len() {
3295 islands = islands.into_iter().skip(offset_val).collect();
3296 } else if offset_val >= islands.len() {
3297 if filtered_count > 0 {
3298 println!("No islands found at offset {} (filtered {} of {} total components by size: {}-{}).",
3299 offset_val, filtered_count, total_components, min_island_size, max_size);
3300 } else {
3301 println!("No islands found at offset {}.", offset_val);
3302 }
3303 return Ok(());
3304 }
3305
3306 if let Some(lim) = limit {
3308 islands.truncate(lim);
3309 }
3310
3311 if islands.is_empty() {
3312 if filtered_count > 0 {
3313 println!("No islands found matching criteria (filtered {} of {} total components by size: {}-{}).",
3314 filtered_count, total_components, min_island_size, max_size);
3315 } else {
3316 println!("No islands found.");
3317 }
3318 return Ok(());
3319 }
3320
3321 let count = islands.len();
3323 let has_more = offset_val + count < total_components - filtered_count;
3324
3325 let file_ids: Vec<i64> = islands.iter().flat_map(|island| island.iter()).copied().collect();
3326 let paths = deps_index.get_file_paths(&file_ids)?;
3327
3328 match format {
3329 "json" => {
3330 let results: Vec<_> = islands.iter()
3331 .enumerate()
3332 .map(|(idx, island)| {
3333 let island_paths: Vec<_> = island.iter()
3334 .filter_map(|id| paths.get(id).cloned())
3335 .collect();
3336 serde_json::json!({
3337 "island_id": idx + 1,
3338 "size": island.len(),
3339 "paths": island_paths,
3340 })
3341 })
3342 .collect();
3343
3344 let output = serde_json::json!({
3345 "pagination": {
3346 "total": total_components - filtered_count,
3347 "count": count,
3348 "offset": offset_val,
3349 "limit": limit,
3350 "has_more": has_more,
3351 },
3352 "results": results,
3353 });
3354
3355 let json_str = if pretty_json {
3356 serde_json::to_string_pretty(&output)?
3357 } else {
3358 serde_json::to_string(&output)?
3359 };
3360 println!("{}", json_str);
3361 if filtered_count > 0 {
3362 eprintln!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3363 count, filtered_count, total_components, min_island_size, max_size);
3364 } else if total_components - filtered_count > count {
3365 eprintln!("Found {} islands ({} total)", count, total_components - filtered_count);
3366 } else {
3367 eprintln!("Found {} islands (disconnected components)", count);
3368 }
3369 }
3370 "tree" => {
3371 println!("Islands (Disconnected Components):");
3372 for (idx, island) in islands.iter().enumerate() {
3373 println!("\nIsland {} ({} files):", idx + 1, island.len());
3374 for id in island {
3375 if let Some(path) = paths.get(id) {
3376 println!(" ├─ {}", path);
3377 }
3378 }
3379 }
3380 if filtered_count > 0 {
3381 eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3382 count, filtered_count, total_components, min_island_size, max_size);
3383 if has_more {
3384 eprintln!("Use --limit and --offset to paginate");
3385 }
3386 } else if total_components - filtered_count > count {
3387 eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3388 if has_more {
3389 eprintln!("Use --limit and --offset to paginate");
3390 }
3391 } else {
3392 eprintln!("\nFound {} islands", count);
3393 }
3394 }
3395 "table" => {
3396 println!("Island Size Files");
3397 println!("------ ---- -----");
3398 for (idx, island) in islands.iter().enumerate() {
3399 let island_files = island.iter()
3400 .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3401 .collect::<Vec<_>>()
3402 .join(", ");
3403 println!("{:<6} {:<4} {}", idx + 1, island.len(), island_files);
3404 }
3405 if filtered_count > 0 {
3406 eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3407 count, filtered_count, total_components, min_island_size, max_size);
3408 if has_more {
3409 eprintln!("Use --limit and --offset to paginate");
3410 }
3411 } else if total_components - filtered_count > count {
3412 eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3413 if has_more {
3414 eprintln!("Use --limit and --offset to paginate");
3415 }
3416 } else {
3417 eprintln!("\nFound {} islands", count);
3418 }
3419 }
3420 _ => {
3421 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3422 }
3423 }
3424
3425 Ok(())
3426}
3427