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::pulse;
16use crate::query::{QueryEngine, QueryFilter};
17
18#[derive(Parser, Debug)]
20#[command(
21 name = "rfx",
22 version,
23 about = "A fast, deterministic code search engine built for AI",
24 long_about = "Reflex is a local-first, structure-aware code search engine that returns \
25 structured results (symbols, spans, scopes) with sub-100ms latency. \
26 Designed for AI coding agents and automation."
27)]
28pub struct Cli {
29 #[arg(short, long, action = clap::ArgAction::Count)]
31 pub verbose: u8,
32
33 #[command(subcommand)]
34 pub command: Option<Command>,
35}
36
37#[derive(Subcommand, Debug)]
38pub enum IndexSubcommand {
39 Status,
41
42 Compact {
52 #[arg(long)]
54 json: bool,
55
56 #[arg(long)]
58 pretty: bool,
59 },
60}
61
62#[derive(Subcommand, Debug)]
63pub enum Command {
64 Index {
66 #[arg(value_name = "PATH", default_value = ".")]
68 path: PathBuf,
69
70 #[arg(short, long)]
72 force: bool,
73
74 #[arg(short, long, value_delimiter = ',')]
76 languages: Vec<String>,
77
78 #[arg(short, long)]
80 quiet: bool,
81
82 #[command(subcommand)]
84 command: Option<IndexSubcommand>,
85 },
86
87 Query {
111 pattern: Option<String>,
113
114 #[arg(short, long)]
116 symbols: bool,
117
118 #[arg(short, long)]
121 lang: Option<String>,
122
123 #[arg(short, long)]
126 kind: Option<String>,
127
128 #[arg(long)]
143 ast: bool,
144
145 #[arg(short = 'r', long)]
161 regex: bool,
162
163 #[arg(long)]
165 json: bool,
166
167 #[arg(long)]
170 pretty: bool,
171
172 #[arg(long)]
176 ai: bool,
177
178 #[arg(short = 'n', long)]
180 limit: Option<usize>,
181
182 #[arg(short = 'o', long)]
185 offset: Option<usize>,
186
187 #[arg(long)]
190 expand: bool,
191
192 #[arg(short = 'f', long)]
195 file: Option<String>,
196
197 #[arg(long)]
200 exact: bool,
201
202 #[arg(long)]
217 contains: bool,
218
219 #[arg(short, long)]
221 count: bool,
222
223 #[arg(short = 't', long, default_value = "30")]
225 timeout: u64,
226
227 #[arg(long)]
229 plain: bool,
230
231 #[arg(short = 'g', long)]
245 glob: Vec<String>,
246
247 #[arg(short = 'x', long)]
256 exclude: Vec<String>,
257
258 #[arg(short = 'p', long)]
261 paths: bool,
262
263 #[arg(long)]
266 no_truncate: bool,
267
268 #[arg(short = 'a', long)]
271 all: bool,
272
273 #[arg(long)]
279 force: bool,
280
281 #[arg(long)]
284 dependencies: bool,
285 },
286
287 Serve {
289 #[arg(short, long, default_value = "7878")]
291 port: u16,
292
293 #[arg(long, default_value = "127.0.0.1")]
295 host: String,
296 },
297
298 Stats {
300 #[arg(long)]
302 json: bool,
303
304 #[arg(long)]
306 pretty: bool,
307 },
308
309 Clear {
311 #[arg(short, long)]
313 yes: bool,
314 },
315
316 ListFiles {
318 #[arg(long)]
320 json: bool,
321
322 #[arg(long)]
324 pretty: bool,
325 },
326
327 Watch {
336 #[arg(value_name = "PATH", default_value = ".")]
338 path: PathBuf,
339
340 #[arg(short, long, default_value = "15000")]
344 debounce: u64,
345
346 #[arg(short, long)]
348 quiet: bool,
349 },
350
351 Mcp,
368
369 Analyze {
385 #[arg(long)]
387 circular: bool,
388
389 #[arg(long)]
391 hotspots: bool,
392
393 #[arg(long, default_value = "2", requires = "hotspots")]
395 min_dependents: usize,
396
397 #[arg(long)]
399 unused: bool,
400
401 #[arg(long)]
403 islands: bool,
404
405 #[arg(long, default_value = "2", requires = "islands")]
407 min_island_size: usize,
408
409 #[arg(long, requires = "islands")]
411 max_island_size: Option<usize>,
412
413 #[arg(short = 'f', long, default_value = "tree")]
415 format: String,
416
417 #[arg(long)]
419 json: bool,
420
421 #[arg(long)]
423 pretty: bool,
424
425 #[arg(short, long)]
427 count: bool,
428
429 #[arg(short = 'a', long)]
432 all: bool,
433
434 #[arg(long)]
436 plain: bool,
437
438 #[arg(short = 'g', long)]
441 glob: Vec<String>,
442
443 #[arg(short = 'x', long)]
446 exclude: Vec<String>,
447
448 #[arg(long)]
451 force: bool,
452
453 #[arg(short = 'n', long)]
455 limit: Option<usize>,
456
457 #[arg(short = 'o', long)]
459 offset: Option<usize>,
460
461 #[arg(long)]
465 sort: Option<String>,
466 },
467
468 Deps {
478 file: PathBuf,
480
481 #[arg(short, long)]
483 reverse: bool,
484
485 #[arg(short, long, default_value = "1")]
487 depth: usize,
488
489 #[arg(short = 'f', long, default_value = "tree")]
491 format: String,
492
493 #[arg(long)]
495 json: bool,
496
497 #[arg(long)]
499 pretty: bool,
500 },
501
502 Ask {
528 question: Option<String>,
530
531 #[arg(short, long)]
533 execute: bool,
534
535 #[arg(short, long)]
537 provider: Option<String>,
538
539 #[arg(long)]
541 json: bool,
542
543 #[arg(long)]
545 pretty: bool,
546
547 #[arg(long)]
549 additional_context: Option<String>,
550
551 #[arg(long)]
553 configure: bool,
554
555 #[arg(long)]
557 agentic: bool,
558
559 #[arg(long, default_value = "2")]
561 max_iterations: usize,
562
563 #[arg(long)]
565 no_eval: bool,
566
567 #[arg(long)]
569 show_reasoning: bool,
570
571 #[arg(long)]
573 verbose: bool,
574
575 #[arg(long)]
577 quiet: bool,
578
579 #[arg(long)]
581 answer: bool,
582
583 #[arg(short = 'i', long)]
585 interactive: bool,
586
587 #[arg(long)]
589 debug: bool,
590 },
591
592 Context {
609 #[arg(long)]
611 structure: bool,
612
613 #[arg(short, long)]
615 path: Option<String>,
616
617 #[arg(long)]
619 file_types: bool,
620
621 #[arg(long)]
623 project_type: bool,
624
625 #[arg(long)]
627 framework: bool,
628
629 #[arg(long)]
631 entry_points: bool,
632
633 #[arg(long)]
635 test_layout: bool,
636
637 #[arg(long)]
639 config_files: bool,
640
641 #[arg(long, default_value = "1")]
643 depth: usize,
644
645 #[arg(long)]
647 json: bool,
648 },
649
650 #[command(hide = true)]
652 IndexSymbolsInternal {
653 cache_dir: PathBuf,
655 },
656
657 Snapshot {
670 #[command(subcommand)]
671 command: Option<SnapshotSubcommand>,
672 },
673
674 Pulse {
685 #[command(subcommand)]
686 command: PulseSubcommand,
687 },
688
689 Llm {
695 #[command(subcommand)]
696 command: LlmSubcommand,
697 },
698}
699
700#[derive(Subcommand, Debug)]
701pub enum SnapshotSubcommand {
702 Diff {
706 #[arg(long)]
708 baseline: Option<String>,
709
710 #[arg(long)]
712 current: Option<String>,
713
714 #[arg(long)]
716 json: bool,
717
718 #[arg(long)]
720 pretty: bool,
721 },
722
723 List {
725 #[arg(long)]
727 json: bool,
728
729 #[arg(long)]
731 pretty: bool,
732 },
733
734 Gc {
736 #[arg(long)]
738 json: bool,
739 },
740}
741
742#[derive(Subcommand, Debug)]
743pub enum PulseSubcommand {
744 Changelog {
746 #[arg(long, default_value = "20")]
748 count: usize,
749
750 #[arg(long)]
752 no_llm: bool,
753
754 #[arg(long)]
756 json: bool,
757
758 #[arg(long)]
760 pretty: bool,
761 },
762
763 Wiki {
765 #[arg(long)]
767 no_llm: bool,
768
769 #[arg(short, long)]
771 output: Option<PathBuf>,
772
773 #[arg(long)]
775 json: bool,
776 },
777
778 Map {
780 #[arg(short, long, default_value = "mermaid")]
782 format: String,
783
784 #[arg(short, long)]
786 output: Option<PathBuf>,
787
788 #[arg(short, long)]
790 zoom: Option<String>,
791 },
792
793 Generate {
799 #[arg(short, long, default_value = "pulse-site")]
801 output: PathBuf,
802
803 #[arg(long, default_value = "/")]
805 base_url: String,
806
807 #[arg(long)]
809 title: Option<String>,
810
811 #[arg(long)]
813 include: Option<String>,
814
815 #[arg(long)]
817 no_llm: bool,
818
819 #[arg(long)]
821 clean: bool,
822
823 #[arg(long)]
825 force_renarrate: bool,
826
827 #[arg(long, default_value = "0")]
829 concurrency: usize,
830
831 #[arg(long, default_value = "2")]
833 depth: u8,
834
835 #[arg(long, default_value = "1")]
837 min_files: usize,
838 },
839
840 Serve {
845 #[arg(short, long, default_value = "pulse-site")]
847 output: PathBuf,
848
849 #[arg(short, long, default_value = "1111")]
851 port: u16,
852
853 #[arg(long, default_value = "true")]
855 open: bool,
856 },
857
858 Onboard {
860 #[arg(long)]
862 no_llm: bool,
863
864 #[arg(long)]
866 json: bool,
867 },
868
869 Timeline {
871 #[arg(long)]
873 json: bool,
874 },
875
876 Glossary {
878 #[arg(long)]
880 json: bool,
881 },
882}
883
884#[derive(Subcommand, Debug)]
885pub enum LlmSubcommand {
886 Config,
888 Status,
890}
891
892fn try_background_compact(cache: &CacheManager, command: &Command) {
904 match command {
906 Command::Clear { .. } => {
907 log::debug!("Skipping compaction for Clear command");
908 return;
909 }
910 Command::Mcp => {
911 log::debug!("Skipping compaction for Mcp command");
912 return;
913 }
914 Command::Watch { .. } => {
915 log::debug!("Skipping compaction for Watch command");
916 return;
917 }
918 Command::Serve { .. } => {
919 log::debug!("Skipping compaction for Serve command");
920 return;
921 }
922 _ => {}
923 }
924
925 let should_compact = match cache.should_compact() {
927 Ok(true) => true,
928 Ok(false) => {
929 log::debug!("Compaction not needed yet (last run <24h ago)");
930 return;
931 }
932 Err(e) => {
933 log::warn!("Failed to check compaction status: {}", e);
934 return;
935 }
936 };
937
938 if !should_compact {
939 return;
940 }
941
942 log::info!("Starting background cache compaction...");
943
944 let cache_path = cache.path().to_path_buf();
946
947 std::thread::spawn(move || {
949 let cache = CacheManager::new(cache_path.parent().expect("Cache should have parent directory"));
950
951 match cache.compact() {
952 Ok(report) => {
953 log::info!(
954 "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
955 report.files_removed,
956 report.space_saved_bytes as f64 / 1_048_576.0,
957 report.duration_ms
958 );
959 }
960 Err(e) => {
961 log::warn!("Background compaction failed: {}", e);
962 }
963 }
964 });
965
966 log::debug!("Background compaction thread spawned - main command continuing");
967}
968
969impl Cli {
970 pub fn execute(self) -> Result<()> {
972 let log_level = match self.verbose {
974 0 => "warn", 1 => "info", 2 => "debug", _ => "trace", };
979 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
980 .init();
981
982 if let Some(ref command) = self.command {
984 let cache = CacheManager::new(".");
986 try_background_compact(&cache, command);
987 }
988
989 match self.command {
991 None => {
992 Cli::command().print_help()?;
994 println!(); Ok(())
996 }
997 Some(Command::Index { path, force, languages, quiet, command }) => {
998 match command {
999 None => {
1000 handle_index_build(&path, &force, &languages, &quiet)
1002 }
1003 Some(IndexSubcommand::Status) => {
1004 handle_index_status()
1005 }
1006 Some(IndexSubcommand::Compact { json, pretty }) => {
1007 handle_index_compact(&json, &pretty)
1008 }
1009 }
1010 }
1011 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 }) => {
1012 match pattern {
1014 None => handle_interactive(),
1015 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)
1016 }
1017 }
1018 Some(Command::Serve { port, host }) => {
1019 handle_serve(port, host)
1020 }
1021 Some(Command::Stats { json, pretty }) => {
1022 handle_stats(json, pretty)
1023 }
1024 Some(Command::Clear { yes }) => {
1025 handle_clear(yes)
1026 }
1027 Some(Command::ListFiles { json, pretty }) => {
1028 handle_list_files(json, pretty)
1029 }
1030 Some(Command::Watch { path, debounce, quiet }) => {
1031 handle_watch(path, debounce, quiet)
1032 }
1033 Some(Command::Mcp) => {
1034 handle_mcp()
1035 }
1036 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 }) => {
1037 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)
1038 }
1039 Some(Command::Deps { file, reverse, depth, format, json, pretty }) => {
1040 handle_deps(file, reverse, depth, format, json, pretty)
1041 }
1042 Some(Command::Ask { question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug }) => {
1043 handle_ask(question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug)
1044 }
1045 Some(Command::Context { structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json }) => {
1046 handle_context(structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json)
1047 }
1048 Some(Command::IndexSymbolsInternal { cache_dir }) => {
1049 handle_index_symbols_internal(cache_dir)
1050 }
1051 Some(Command::Snapshot { command }) => {
1052 match command {
1053 None => handle_snapshot_create(),
1054 Some(crate::cli::SnapshotSubcommand::List { json, pretty }) => {
1055 handle_snapshot_list(json, pretty)
1056 }
1057 Some(crate::cli::SnapshotSubcommand::Diff { baseline, current, json, pretty }) => {
1058 handle_snapshot_diff(baseline, current, json, pretty)
1059 }
1060 Some(crate::cli::SnapshotSubcommand::Gc { json }) => {
1061 handle_snapshot_gc(json)
1062 }
1063 }
1064 }
1065 Some(Command::Pulse { command }) => {
1066 match command {
1067 crate::cli::PulseSubcommand::Changelog { count, no_llm, json, pretty } => {
1068 handle_pulse_changelog(count, no_llm, json, pretty)
1069 }
1070 crate::cli::PulseSubcommand::Wiki { no_llm, output, json } => {
1071 handle_pulse_wiki(no_llm, output, json)
1072 }
1073 crate::cli::PulseSubcommand::Map { format, output, zoom } => {
1074 handle_pulse_map(format, output, zoom)
1075 }
1076 crate::cli::PulseSubcommand::Generate { output, base_url, title, include, no_llm, clean, force_renarrate, concurrency, depth, min_files } => {
1077 handle_pulse_generate(output, base_url, title, include, no_llm, clean, force_renarrate, concurrency, depth, min_files)
1078 }
1079 crate::cli::PulseSubcommand::Serve { output, port, open } => {
1080 handle_pulse_serve(output, port, open)
1081 }
1082 crate::cli::PulseSubcommand::Onboard { no_llm, json } => {
1083 handle_pulse_onboard(no_llm, json)
1084 }
1085 crate::cli::PulseSubcommand::Timeline { json } => {
1086 handle_pulse_timeline(json)
1087 }
1088 crate::cli::PulseSubcommand::Glossary { json } => {
1089 handle_pulse_glossary(json)
1090 }
1091 }
1092 }
1093 Some(Command::Llm { command }) => {
1094 match command {
1095 crate::cli::LlmSubcommand::Config => handle_llm_config(),
1096 crate::cli::LlmSubcommand::Status => handle_llm_status(),
1097 }
1098 }
1099 }
1100 }
1101}
1102
1103fn handle_index_status() -> Result<()> {
1105 log::info!("Checking background symbol indexing status");
1106
1107 let cache = CacheManager::new(".");
1108 let cache_path = cache.path().to_path_buf();
1109
1110 match crate::background_indexer::BackgroundIndexer::get_status(&cache_path) {
1111 Ok(Some(status)) => {
1112 println!("Background Symbol Indexing Status");
1113 println!("==================================");
1114 println!("State: {:?}", status.state);
1115 println!("Total files: {}", status.total_files);
1116 println!("Processed: {}", status.processed_files);
1117 println!("Cached: {}", status.cached_files);
1118 println!("Parsed: {}", status.parsed_files);
1119 println!("Failed: {}", status.failed_files);
1120 println!("Started: {}", status.started_at);
1121 println!("Last updated: {}", status.updated_at);
1122
1123 if let Some(completed_at) = &status.completed_at {
1124 println!("Completed: {}", completed_at);
1125 }
1126
1127 if let Some(error) = &status.error {
1128 println!("Error: {}", error);
1129 }
1130
1131 if status.state == crate::background_indexer::IndexerState::Running && status.total_files > 0 {
1133 let progress = (status.processed_files as f64 / status.total_files as f64) * 100.0;
1134 println!("\nProgress: {:.1}%", progress);
1135 }
1136
1137 Ok(())
1138 }
1139 Ok(None) => {
1140 println!("No background symbol indexing in progress.");
1141 println!("\nRun 'rfx index' to start background symbol indexing.");
1142 Ok(())
1143 }
1144 Err(e) => {
1145 anyhow::bail!("Failed to get indexing status: {}", e);
1146 }
1147 }
1148 }
1149
1150fn handle_index_compact(json: &bool, pretty: &bool) -> Result<()> {
1152 log::info!("Running cache compaction");
1153
1154 let cache = CacheManager::new(".");
1155 let report = cache.compact()?;
1156
1157 if *json {
1159 let json_str = if *pretty {
1160 serde_json::to_string_pretty(&report)?
1161 } else {
1162 serde_json::to_string(&report)?
1163 };
1164 println!("{}", json_str);
1165 } else {
1166 println!("Cache Compaction Complete");
1167 println!("=========================");
1168 println!("Files removed: {}", report.files_removed);
1169 println!("Space saved: {:.2} MB", report.space_saved_bytes as f64 / 1_048_576.0);
1170 println!("Duration: {}ms", report.duration_ms);
1171 }
1172
1173 Ok(())
1174}
1175
1176fn handle_index_build(path: &PathBuf, force: &bool, languages: &[String], quiet: &bool) -> Result<()> {
1177 log::info!("Starting index build");
1178
1179 let cache = CacheManager::new(path);
1180 let cache_path = cache.path().to_path_buf();
1181
1182 if *force {
1183 log::info!("Force rebuild requested, clearing existing cache");
1184 cache.clear()?;
1185 }
1186
1187 let lang_filters: Vec<Language> = languages
1189 .iter()
1190 .filter_map(|s| {
1191 Language::from_name(s).or_else(|| {
1192 output::warn(&format!("Unknown language: '{}'. Supported: {}", s, Language::supported_names_help()));
1193 None
1194 })
1195 })
1196 .collect();
1197
1198 let config = IndexConfig {
1199 languages: lang_filters,
1200 ..Default::default()
1201 };
1202
1203 let indexer = Indexer::new(cache, config);
1204 let show_progress = !quiet;
1206 let stats = indexer.index(path, show_progress)?;
1207
1208 if !quiet {
1210 println!("Indexing complete!");
1211 println!(" Files indexed: {}", stats.total_files);
1212 println!(" Cache size: {}", format_bytes(stats.index_size_bytes));
1213 println!(" Last updated: {}", stats.last_updated);
1214
1215 if !stats.files_by_language.is_empty() {
1217 println!("\nFiles by language:");
1218
1219 let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
1221 lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
1222
1223 let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
1225 let lang_width = max_lang_len.max(8); println!(" {:<width$} Files Lines", "Language", width = lang_width);
1229 println!(" {} ----- -------", "-".repeat(lang_width));
1230
1231 for (language, file_count) in lang_vec {
1233 let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
1234 println!(" {:<width$} {:5} {:7}",
1235 language, file_count, line_count,
1236 width = lang_width);
1237 }
1238 }
1239 }
1240
1241 if !crate::background_indexer::BackgroundIndexer::is_running(&cache_path) {
1243 if !quiet {
1244 println!("\nStarting background symbol indexing...");
1245 println!(" Symbols will be cached for faster queries");
1246 println!(" Check status with: rfx index status");
1247 }
1248
1249 let current_exe = std::env::current_exe()
1252 .context("Failed to get current executable path")?;
1253
1254 #[cfg(unix)]
1255 {
1256 std::process::Command::new(¤t_exe)
1257 .arg("index-symbols-internal")
1258 .arg(path)
1259 .stdin(std::process::Stdio::null())
1260 .stdout(std::process::Stdio::null())
1261 .stderr(std::process::Stdio::null())
1262 .spawn()
1263 .context("Failed to spawn background indexing process")?;
1264 }
1265
1266 #[cfg(windows)]
1267 {
1268 use std::os::windows::process::CommandExt;
1269 const CREATE_NO_WINDOW: u32 = 0x08000000;
1270
1271 std::process::Command::new(¤t_exe)
1272 .arg("index-symbols-internal")
1273 .arg(&path)
1274 .creation_flags(CREATE_NO_WINDOW)
1275 .stdin(std::process::Stdio::null())
1276 .stdout(std::process::Stdio::null())
1277 .stderr(std::process::Stdio::null())
1278 .spawn()
1279 .context("Failed to spawn background indexing process")?;
1280 }
1281
1282 log::debug!("Spawned background symbol indexing process");
1283 } else if !quiet {
1284 println!("\n⚠️ Background symbol indexing already in progress");
1285 println!(" Check status with: rfx index status");
1286 }
1287
1288 Ok(())
1289}
1290
1291fn format_bytes(bytes: u64) -> String {
1293 const KB: u64 = 1024;
1294 const MB: u64 = KB * 1024;
1295 const GB: u64 = MB * 1024;
1296 const TB: u64 = GB * 1024;
1297
1298 if bytes >= TB {
1299 format!("{:.2} TB", bytes as f64 / TB as f64)
1300 } else if bytes >= GB {
1301 format!("{:.2} GB", bytes as f64 / GB as f64)
1302 } else if bytes >= MB {
1303 format!("{:.2} MB", bytes as f64 / MB as f64)
1304 } else if bytes >= KB {
1305 format!("{:.2} KB", bytes as f64 / KB as f64)
1306 } else {
1307 format!("{} bytes", bytes)
1308 }
1309}
1310
1311pub fn truncate_preview(preview: &str, max_length: usize) -> String {
1314 if preview.len() <= max_length {
1315 return preview.to_string();
1316 }
1317
1318 let truncate_at = preview.char_indices()
1320 .take(max_length)
1321 .filter(|(_, c)| c.is_whitespace())
1322 .last()
1323 .map(|(i, _)| i)
1324 .unwrap_or(max_length.min(preview.len()));
1325
1326 let mut truncated = preview[..truncate_at].to_string();
1327 truncated.push('…');
1328 truncated
1329}
1330
1331fn handle_query(
1333 pattern: String,
1334 symbols_flag: bool,
1335 lang: Option<String>,
1336 kind_str: Option<String>,
1337 use_ast: bool,
1338 use_regex: bool,
1339 as_json: bool,
1340 pretty_json: bool,
1341 ai_mode: bool,
1342 limit: Option<usize>,
1343 offset: Option<usize>,
1344 expand: bool,
1345 file_pattern: Option<String>,
1346 exact: bool,
1347 use_contains: bool,
1348 count_only: bool,
1349 timeout_secs: u64,
1350 plain: bool,
1351 glob_patterns: Vec<String>,
1352 exclude_patterns: Vec<String>,
1353 paths_only: bool,
1354 no_truncate: bool,
1355 all: bool,
1356 force: bool,
1357 include_dependencies: bool,
1358) -> Result<()> {
1359 log::info!("Starting query command");
1360
1361 let as_json = as_json || ai_mode;
1363
1364 let cache = CacheManager::new(".");
1365 let engine = QueryEngine::new(cache);
1366
1367 let language = if let Some(lang_str) = lang.as_deref() {
1369 match Language::from_name(lang_str) {
1370 Some(l) => Some(l),
1371 None => anyhow::bail!(
1372 "Unknown language: '{}'\n\nSupported languages:\n {}\n\nExample: rfx query \"pattern\" --lang rust",
1373 lang_str, Language::supported_names_help()
1374 ),
1375 }
1376 } else {
1377 None
1378 };
1379
1380 let kind = kind_str.as_deref().and_then(|s| {
1382 let capitalized = {
1384 let mut chars = s.chars();
1385 match chars.next() {
1386 None => String::new(),
1387 Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1388 }
1389 };
1390
1391 capitalized.parse::<crate::models::SymbolKind>()
1392 .ok()
1393 .or_else(|| {
1394 log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1396 Some(crate::models::SymbolKind::Unknown(s.to_string()))
1397 })
1398 });
1399
1400 let symbols_mode = symbols_flag || kind.is_some();
1402
1403 let final_limit = if count_only {
1411 None } else if all {
1413 None } else if limit == Some(0) {
1415 None } else if paths_only && limit.is_none() {
1417 None } else if let Some(user_limit) = limit {
1419 Some(user_limit) } else {
1421 Some(100) };
1423
1424 if use_ast && language.is_none() {
1426 anyhow::bail!(
1427 "AST pattern matching requires a language to be specified.\n\
1428 \n\
1429 Use --lang to specify the language for tree-sitter parsing.\n\
1430 \n\
1431 Supported languages for AST queries:\n\
1432 • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
1433 \n\
1434 Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
1435 \n\
1436 WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
1437 \n\
1438 Examples:\n\
1439 • rfx query \"(function_definition) @fn\" --ast --lang python\n\
1440 • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
1441 );
1442 }
1443
1444 if !as_json {
1447 let mut has_errors = false;
1448
1449 if use_regex && use_contains {
1451 eprintln!("{}", "ERROR: Cannot use --regex and --contains together.".red().bold());
1452 eprintln!(" {} --regex for pattern matching (alternation, wildcards, etc.)", "•".dimmed());
1453 eprintln!(" {} --contains for substring matching (expansive search)", "•".dimmed());
1454 eprintln!("\n {} Choose one based on your needs:", "Tip:".cyan().bold());
1455 eprintln!(" {} for OR logic: --regex", "pattern1|pattern2".yellow());
1456 eprintln!(" {} for substring: --contains", "partial_text".yellow());
1457 has_errors = true;
1458 }
1459
1460 if exact && use_contains {
1462 eprintln!("{}", "ERROR: Cannot use --exact and --contains together (contradictory).".red().bold());
1463 eprintln!(" {} --exact requires exact symbol name match", "•".dimmed());
1464 eprintln!(" {} --contains allows substring matching", "•".dimmed());
1465 has_errors = true;
1466 }
1467
1468 if file_pattern.is_some() && !glob_patterns.is_empty() {
1470 eprintln!("{}", "WARNING: Both --file and --glob specified.".yellow().bold());
1471 eprintln!(" {} --file does substring matching on file paths", "•".dimmed());
1472 eprintln!(" {} --glob does pattern matching with wildcards", "•".dimmed());
1473 eprintln!(" {} Both filters will apply (AND condition)", "Note:".dimmed());
1474 eprintln!("\n {} Usually you only need one:", "Tip:".cyan().bold());
1475 eprintln!(" {} for simple matching", "--file User.php".yellow());
1476 eprintln!(" {} for pattern matching", "--glob src/**/*.php".yellow());
1477 }
1478
1479 for pattern in &glob_patterns {
1481 if (pattern.starts_with('\'') && pattern.ends_with('\'')) ||
1483 (pattern.starts_with('"') && pattern.ends_with('"')) {
1484 eprintln!("{}",
1485 format!("WARNING: Glob pattern contains quotes: {}", pattern).yellow().bold()
1486 );
1487 eprintln!(" {} Shell quotes should not be part of the pattern", "Note:".dimmed());
1488 eprintln!(" {} --glob src/**/*.rs", "Correct:".green());
1489 eprintln!(" {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
1490 }
1491
1492 if pattern.contains("*/") && !pattern.contains("**/") {
1494 eprintln!("{}",
1495 format!("INFO: Glob '{}' uses * (matches one directory level)", pattern).cyan()
1496 );
1497 eprintln!(" {} Use ** for recursive matching across subdirectories", "Tip:".cyan().bold());
1498 eprintln!(" {} → matches files in Models/ only", "app/Models/*.php".yellow());
1499 eprintln!(" {} → matches files in Models/ and subdirs", "app/Models/**/*.php".green());
1500 }
1501 }
1502
1503 if has_errors {
1504 anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
1505 }
1506 }
1507
1508 let filter = QueryFilter {
1509 language,
1510 kind,
1511 use_ast,
1512 use_regex,
1513 limit: final_limit,
1514 symbols_mode,
1515 expand,
1516 file_pattern,
1517 exact,
1518 use_contains,
1519 timeout_secs,
1520 glob_patterns: glob_patterns.clone(),
1521 exclude_patterns,
1522 paths_only,
1523 offset,
1524 force,
1525 suppress_output: as_json, include_dependencies,
1527 ..Default::default()
1528 };
1529
1530 let start = Instant::now();
1532
1533 let (query_response, mut flat_results, total_results, has_more) = if use_ast {
1536 match engine.search_ast_all_files(&pattern, filter.clone()) {
1538 Ok(ast_results) => {
1539 let count = ast_results.len();
1540 (None, ast_results, count, false)
1541 }
1542 Err(e) => {
1543 if as_json {
1544 let error_response = serde_json::json!({
1546 "error": e.to_string(),
1547 "query_too_broad": e.to_string().contains("Query too broad")
1548 });
1549 let json_output = if pretty_json {
1550 serde_json::to_string_pretty(&error_response)?
1551 } else {
1552 serde_json::to_string(&error_response)?
1553 };
1554 println!("{}", json_output);
1555 std::process::exit(1);
1556 } else {
1557 return Err(e);
1558 }
1559 }
1560 }
1561 } else {
1562 match engine.search_with_metadata(&pattern, filter.clone()) {
1564 Ok(response) => {
1565 let total = response.pagination.total;
1566 let has_more = response.pagination.has_more;
1567
1568 let flat = response.results.iter()
1570 .flat_map(|file_group| {
1571 file_group.matches.iter().map(move |m| {
1572 crate::models::SearchResult {
1573 path: file_group.path.clone(),
1574 lang: crate::models::Language::Unknown, kind: m.kind.clone(),
1576 symbol: m.symbol.clone(),
1577 span: m.span.clone(),
1578 preview: m.preview.clone(),
1579 dependencies: file_group.dependencies.clone(),
1580 }
1581 })
1582 })
1583 .collect();
1584
1585 (Some(response), flat, total, has_more)
1586 }
1587 Err(e) => {
1588 if as_json {
1589 let error_response = serde_json::json!({
1591 "error": e.to_string(),
1592 "query_too_broad": e.to_string().contains("Query too broad")
1593 });
1594 let json_output = if pretty_json {
1595 serde_json::to_string_pretty(&error_response)?
1596 } else {
1597 serde_json::to_string(&error_response)?
1598 };
1599 println!("{}", json_output);
1600 std::process::exit(1);
1601 } else {
1602 return Err(e);
1603 }
1604 }
1605 }
1606 };
1607
1608 if !no_truncate {
1610 const MAX_PREVIEW_LENGTH: usize = 100;
1611 for result in &mut flat_results {
1612 result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
1613 }
1614 }
1615
1616 let elapsed = start.elapsed();
1617
1618 let timing_str = if elapsed.as_millis() < 1 {
1620 format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
1621 } else {
1622 format!("{}ms", elapsed.as_millis())
1623 };
1624
1625 if as_json {
1626 if count_only {
1627 let count_response = serde_json::json!({
1629 "count": total_results,
1630 "timing_ms": elapsed.as_millis()
1631 });
1632 let json_output = if pretty_json {
1633 serde_json::to_string_pretty(&count_response)?
1634 } else {
1635 serde_json::to_string(&count_response)?
1636 };
1637 println!("{}", json_output);
1638 } else if paths_only {
1639 let locations: Vec<serde_json::Value> = flat_results.iter()
1641 .map(|r| serde_json::json!({
1642 "path": r.path,
1643 "line": r.span.start_line
1644 }))
1645 .collect();
1646 let json_output = if pretty_json {
1647 serde_json::to_string_pretty(&locations)?
1648 } else {
1649 serde_json::to_string(&locations)?
1650 };
1651 println!("{}", json_output);
1652 eprintln!("Found {} unique files in {}", locations.len(), timing_str);
1653 } else {
1654 let mut response = if let Some(resp) = query_response {
1656 let mut resp = resp;
1659
1660 if !no_truncate {
1662 const MAX_PREVIEW_LENGTH: usize = 100;
1663 for file_group in resp.results.iter_mut() {
1664 for m in file_group.matches.iter_mut() {
1665 m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
1666 }
1667 }
1668 }
1669
1670 resp
1671 } else {
1672 use crate::models::{PaginationInfo, IndexStatus, FileGroupedResult, MatchResult};
1675 use std::collections::HashMap;
1676
1677 let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
1678 for result in &flat_results {
1679 grouped
1680 .entry(result.path.clone())
1681 .or_default()
1682 .push(result.clone());
1683 }
1684
1685 use crate::content_store::ContentReader;
1687 let local_cache = CacheManager::new(".");
1688 let content_path = local_cache.path().join("content.bin");
1689 let content_reader_opt = ContentReader::open(&content_path).ok();
1690
1691 let mut file_results: Vec<FileGroupedResult> = grouped
1692 .into_iter()
1693 .map(|(path, file_matches)| {
1694 let normalized_path = path.strip_prefix("./").unwrap_or(&path);
1698 let file_id_for_context = if let Some(reader) = &content_reader_opt {
1699 reader.get_file_id_by_path(normalized_path)
1700 } else {
1701 None
1702 };
1703
1704 let matches: Vec<MatchResult> = file_matches
1705 .into_iter()
1706 .map(|r| {
1707 let (context_before, context_after) = if let (Some(reader), Some(fid)) = (&content_reader_opt, file_id_for_context) {
1709 reader.get_context_by_line(fid as u32, r.span.start_line, 3)
1710 .unwrap_or_else(|_| (vec![], vec![]))
1711 } else {
1712 (vec![], vec![])
1713 };
1714
1715 MatchResult {
1716 kind: r.kind,
1717 symbol: r.symbol,
1718 span: r.span,
1719 preview: r.preview,
1720 context_before,
1721 context_after,
1722 }
1723 })
1724 .collect();
1725 FileGroupedResult {
1726 path,
1727 dependencies: None,
1728 matches,
1729 }
1730 })
1731 .collect();
1732
1733 file_results.sort_by(|a, b| a.path.cmp(&b.path));
1735
1736 crate::models::QueryResponse {
1737 ai_instruction: None, status: IndexStatus::Fresh,
1739 can_trust_results: true,
1740 warning: None,
1741 pagination: PaginationInfo {
1742 total: flat_results.len(),
1743 count: flat_results.len(),
1744 offset: offset.unwrap_or(0),
1745 limit,
1746 has_more: false, },
1748 results: file_results,
1749 }
1750 };
1751
1752 if ai_mode {
1754 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1755
1756 response.ai_instruction = crate::query::generate_ai_instruction(
1757 result_count,
1758 response.pagination.total,
1759 response.pagination.has_more,
1760 symbols_mode,
1761 paths_only,
1762 use_ast,
1763 use_regex,
1764 language.is_some(),
1765 !glob_patterns.is_empty(),
1766 exact,
1767 );
1768 }
1769
1770 let json_output = if pretty_json {
1771 serde_json::to_string_pretty(&response)?
1772 } else {
1773 serde_json::to_string(&response)?
1774 };
1775 println!("{}", json_output);
1776
1777 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1778 eprintln!("Found {} results in {}", result_count, timing_str);
1779 }
1780 } else {
1781 if count_only {
1783 println!("Found {} results in {}", flat_results.len(), timing_str);
1784 return Ok(());
1785 }
1786
1787 if paths_only {
1788 if flat_results.is_empty() {
1790 eprintln!("No results found (searched in {}).", timing_str);
1791 } else {
1792 for result in &flat_results {
1793 println!("{}", result.path);
1794 }
1795 eprintln!("Found {} unique files in {}", flat_results.len(), timing_str);
1796 }
1797 } else {
1798 if flat_results.is_empty() {
1800 println!("No results found (searched in {}).", timing_str);
1801 } else {
1802 let formatter = crate::formatter::OutputFormatter::new(plain);
1804 formatter.format_results(&flat_results, &pattern)?;
1805
1806 if total_results > flat_results.len() {
1808 println!("\nFound {} results ({} total) in {}", flat_results.len(), total_results, timing_str);
1810 if has_more {
1812 println!("Use --limit and --offset to paginate");
1813 }
1814 } else {
1815 println!("\nFound {} results in {}", flat_results.len(), timing_str);
1817 }
1818 }
1819 }
1820 }
1821
1822 Ok(())
1823}
1824
1825fn handle_serve(port: u16, host: String) -> Result<()> {
1827 log::info!("Starting HTTP server on {}:{}", host, port);
1828
1829 println!("Starting Reflex HTTP server...");
1830 println!(" Address: http://{}:{}", host, port);
1831 println!("\nEndpoints:");
1832 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");
1833 println!(" GET /stats");
1834 println!(" POST /index");
1835 println!("\nPress Ctrl+C to stop.");
1836
1837 let runtime = tokio::runtime::Runtime::new()?;
1839 runtime.block_on(async {
1840 run_server(port, host).await
1841 })
1842}
1843
1844async fn run_server(port: u16, host: String) -> Result<()> {
1846 use axum::{
1847 extract::{Query as AxumQuery, State},
1848 http::StatusCode,
1849 response::{IntoResponse, Json},
1850 routing::{get, post},
1851 Router,
1852 };
1853 use tower_http::cors::{CorsLayer, Any};
1854 use std::sync::Arc;
1855
1856 #[derive(Clone)]
1858 struct AppState {
1859 cache_path: String,
1860 }
1861
1862 #[derive(Debug, serde::Deserialize)]
1864 struct QueryParams {
1865 q: String,
1866 #[serde(default)]
1867 lang: Option<String>,
1868 #[serde(default)]
1869 kind: Option<String>,
1870 #[serde(default)]
1871 limit: Option<usize>,
1872 #[serde(default)]
1873 offset: Option<usize>,
1874 #[serde(default)]
1875 symbols: bool,
1876 #[serde(default)]
1877 regex: bool,
1878 #[serde(default)]
1879 exact: bool,
1880 #[serde(default)]
1881 contains: bool,
1882 #[serde(default)]
1883 expand: bool,
1884 #[serde(default)]
1885 file: Option<String>,
1886 #[serde(default = "default_timeout")]
1887 timeout: u64,
1888 #[serde(default)]
1889 glob: Vec<String>,
1890 #[serde(default)]
1891 exclude: Vec<String>,
1892 #[serde(default)]
1893 paths: bool,
1894 #[serde(default)]
1895 force: bool,
1896 #[serde(default)]
1897 dependencies: bool,
1898 }
1899
1900 fn default_timeout() -> u64 {
1902 30
1903 }
1904
1905 #[derive(Debug, serde::Deserialize)]
1907 struct IndexRequest {
1908 #[serde(default)]
1909 force: bool,
1910 #[serde(default)]
1911 languages: Vec<String>,
1912 }
1913
1914 async fn handle_query_endpoint(
1916 State(state): State<Arc<AppState>>,
1917 AxumQuery(params): AxumQuery<QueryParams>,
1918 ) -> Result<Json<crate::models::QueryResponse>, (StatusCode, String)> {
1919 log::info!("Query request: pattern={}", params.q);
1920
1921 let cache = CacheManager::new(&state.cache_path);
1922 let engine = QueryEngine::new(cache);
1923
1924 let language = if let Some(lang_str) = params.lang.as_deref() {
1926 match Language::from_name(lang_str) {
1927 Some(l) => Some(l),
1928 None => return Err((
1929 StatusCode::BAD_REQUEST,
1930 format!("Unknown language '{}'. Supported: {}", lang_str, Language::supported_names_help())
1931 )),
1932 }
1933 } else {
1934 None
1935 };
1936
1937 let kind = params.kind.as_deref().and_then(|s| {
1939 let capitalized = {
1940 let mut chars = s.chars();
1941 match chars.next() {
1942 None => String::new(),
1943 Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1944 }
1945 };
1946
1947 capitalized.parse::<crate::models::SymbolKind>()
1948 .ok()
1949 .or_else(|| {
1950 log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1951 Some(crate::models::SymbolKind::Unknown(s.to_string()))
1952 })
1953 });
1954
1955 let symbols_mode = params.symbols || kind.is_some();
1957
1958 let final_limit = if params.paths && params.limit.is_none() {
1960 None } else if let Some(user_limit) = params.limit {
1962 Some(user_limit) } else {
1964 Some(100) };
1966
1967 let filter = QueryFilter {
1968 language,
1969 kind,
1970 use_ast: false,
1971 use_regex: params.regex,
1972 limit: final_limit,
1973 symbols_mode,
1974 expand: params.expand,
1975 file_pattern: params.file,
1976 exact: params.exact,
1977 use_contains: params.contains,
1978 timeout_secs: params.timeout,
1979 glob_patterns: params.glob,
1980 exclude_patterns: params.exclude,
1981 paths_only: params.paths,
1982 offset: params.offset,
1983 force: params.force,
1984 suppress_output: true, include_dependencies: params.dependencies,
1986 ..Default::default()
1987 };
1988
1989 match engine.search_with_metadata(¶ms.q, filter) {
1990 Ok(response) => Ok(Json(response)),
1991 Err(e) => {
1992 log::error!("Query error: {}", e);
1993 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)))
1994 }
1995 }
1996 }
1997
1998 async fn handle_stats_endpoint(
2000 State(state): State<Arc<AppState>>,
2001 ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
2002 log::info!("Stats request");
2003
2004 let cache = CacheManager::new(&state.cache_path);
2005
2006 if !cache.exists() {
2007 return Err((StatusCode::NOT_FOUND, "No index found. Run 'rfx index' first.".to_string()));
2008 }
2009
2010 match cache.stats() {
2011 Ok(stats) => Ok(Json(stats)),
2012 Err(e) => {
2013 log::error!("Stats error: {}", e);
2014 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get stats: {}", e)))
2015 }
2016 }
2017 }
2018
2019 async fn handle_index_endpoint(
2021 State(state): State<Arc<AppState>>,
2022 Json(req): Json<IndexRequest>,
2023 ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
2024 log::info!("Index request: force={}, languages={:?}", req.force, req.languages);
2025
2026 let cache = CacheManager::new(&state.cache_path);
2027
2028 if req.force {
2029 log::info!("Force rebuild requested, clearing existing cache");
2030 if let Err(e) = cache.clear() {
2031 return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to clear cache: {}", e)));
2032 }
2033 }
2034
2035 let lang_filters: Vec<Language> = req.languages
2037 .iter()
2038 .filter_map(|s| match s.to_lowercase().as_str() {
2039 "rust" | "rs" => Some(Language::Rust),
2040 "python" | "py" => Some(Language::Python),
2041 "javascript" | "js" => Some(Language::JavaScript),
2042 "typescript" | "ts" => Some(Language::TypeScript),
2043 "vue" => Some(Language::Vue),
2044 "svelte" => Some(Language::Svelte),
2045 "go" => Some(Language::Go),
2046 "java" => Some(Language::Java),
2047 "php" => Some(Language::PHP),
2048 "c" => Some(Language::C),
2049 "cpp" | "c++" => Some(Language::Cpp),
2050 _ => {
2051 log::warn!("Unknown language: {}", s);
2052 None
2053 }
2054 })
2055 .collect();
2056
2057 let config = IndexConfig {
2058 languages: lang_filters,
2059 ..Default::default()
2060 };
2061
2062 let indexer = Indexer::new(cache, config);
2063 let path = std::path::PathBuf::from(&state.cache_path);
2064
2065 match indexer.index(&path, false) {
2066 Ok(stats) => Ok(Json(stats)),
2067 Err(e) => {
2068 log::error!("Index error: {}", e);
2069 Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Indexing failed: {}", e)))
2070 }
2071 }
2072 }
2073
2074 async fn handle_health() -> impl IntoResponse {
2076 (StatusCode::OK, "Reflex is running")
2077 }
2078
2079 let state = Arc::new(AppState {
2081 cache_path: ".".to_string(),
2082 });
2083
2084 let cors = CorsLayer::new()
2086 .allow_origin(Any)
2087 .allow_methods(Any)
2088 .allow_headers(Any);
2089
2090 let app = Router::new()
2092 .route("/query", get(handle_query_endpoint))
2093 .route("/stats", get(handle_stats_endpoint))
2094 .route("/index", post(handle_index_endpoint))
2095 .route("/health", get(handle_health))
2096 .layer(cors)
2097 .with_state(state);
2098
2099 let addr = format!("{}:{}", host, port);
2101 let listener = tokio::net::TcpListener::bind(&addr).await
2102 .map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", addr, e))?;
2103
2104 log::info!("Server listening on {}", addr);
2105
2106 axum::serve(listener, app)
2108 .await
2109 .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
2110
2111 Ok(())
2112}
2113
2114fn handle_stats(as_json: bool, pretty_json: bool) -> Result<()> {
2116 log::info!("Showing index statistics");
2117
2118 let cache = CacheManager::new(".");
2119
2120 if !cache.exists() {
2121 anyhow::bail!(
2122 "No index found in current directory.\n\
2123 \n\
2124 Run 'rfx index' to build the code search index first.\n\
2125 This will scan all files in the current directory and create a .reflex/ cache.\n\
2126 \n\
2127 Example:\n\
2128 $ rfx index # Index current directory\n\
2129 $ rfx stats # Show index statistics"
2130 );
2131 }
2132
2133 let stats = cache.stats()?;
2134
2135 if as_json {
2136 let json_output = if pretty_json {
2137 serde_json::to_string_pretty(&stats)?
2138 } else {
2139 serde_json::to_string(&stats)?
2140 };
2141 println!("{}", json_output);
2142 } else {
2143 println!("Reflex Index Statistics");
2144 println!("=======================");
2145
2146 let root = std::env::current_dir()?;
2148 if crate::git::is_git_repo(&root) {
2149 match crate::git::get_git_state(&root) {
2150 Ok(git_state) => {
2151 let dirty_indicator = if git_state.dirty { " (uncommitted changes)" } else { " (clean)" };
2152 println!("Branch: {}@{}{}",
2153 git_state.branch,
2154 &git_state.commit[..7],
2155 dirty_indicator);
2156
2157 match cache.get_branch_info(&git_state.branch) {
2159 Ok(branch_info) => {
2160 if branch_info.commit_sha != git_state.commit {
2161 println!(" ⚠️ Index commit mismatch (indexed: {})",
2162 &branch_info.commit_sha[..7]);
2163 }
2164 if git_state.dirty && !branch_info.is_dirty {
2165 println!(" ⚠️ Uncommitted changes not indexed");
2166 }
2167 }
2168 Err(_) => {
2169 println!(" ⚠️ Branch not indexed");
2170 }
2171 }
2172 }
2173 Err(e) => {
2174 log::warn!("Failed to get git state: {}", e);
2175 }
2176 }
2177 } else {
2178 println!("Branch: (None)");
2180 }
2181
2182 println!("Files indexed: {}", stats.total_files);
2183 println!("Index size: {} bytes", stats.index_size_bytes);
2184 println!("Last updated: {}", stats.last_updated);
2185
2186 if !stats.files_by_language.is_empty() {
2188 println!("\nFiles by language:");
2189
2190 let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
2192 lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
2193
2194 let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
2196 let lang_width = max_lang_len.max(8); println!(" {:<width$} Files Lines", "Language", width = lang_width);
2200 println!(" {} ----- -------", "-".repeat(lang_width));
2201
2202 for (language, file_count) in lang_vec {
2204 let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
2205 println!(" {:<width$} {:5} {:7}",
2206 language, file_count, line_count,
2207 width = lang_width);
2208 }
2209 }
2210 }
2211
2212 Ok(())
2213}
2214
2215fn handle_clear(skip_confirm: bool) -> Result<()> {
2217 let cache = CacheManager::new(".");
2218
2219 if !cache.exists() {
2220 println!("No cache to clear.");
2221 return Ok(());
2222 }
2223
2224 if !skip_confirm {
2225 println!("This will delete the local Reflex cache at: {:?}", cache.path());
2226 print!("Are you sure? [y/N] ");
2227 use std::io::{self, Write};
2228 io::stdout().flush()?;
2229
2230 let mut input = String::new();
2231 io::stdin().read_line(&mut input)?;
2232
2233 if !input.trim().eq_ignore_ascii_case("y") {
2234 println!("Cancelled.");
2235 return Ok(());
2236 }
2237 }
2238
2239 cache.clear()?;
2240 println!("Cache cleared successfully.");
2241
2242 Ok(())
2243}
2244
2245fn handle_list_files(as_json: bool, pretty_json: bool) -> Result<()> {
2247 let cache = CacheManager::new(".");
2248
2249 if !cache.exists() {
2250 anyhow::bail!(
2251 "No index found in current directory.\n\
2252 \n\
2253 Run 'rfx index' to build the code search index first.\n\
2254 This will scan all files in the current directory and create a .reflex/ cache.\n\
2255 \n\
2256 Example:\n\
2257 $ rfx index # Index current directory\n\
2258 $ rfx list-files # List indexed files"
2259 );
2260 }
2261
2262 let files = cache.list_files()?;
2263
2264 if as_json {
2265 let json_output = if pretty_json {
2266 serde_json::to_string_pretty(&files)?
2267 } else {
2268 serde_json::to_string(&files)?
2269 };
2270 println!("{}", json_output);
2271 } else if files.is_empty() {
2272 println!("No files indexed yet.");
2273 } else {
2274 println!("Indexed Files ({} total):", files.len());
2275 println!();
2276 for file in files {
2277 println!(" {} ({})",
2278 file.path,
2279 file.language);
2280 }
2281 }
2282
2283 Ok(())
2284}
2285
2286fn handle_watch(path: PathBuf, debounce_ms: u64, quiet: bool) -> Result<()> {
2288 log::info!("Starting watch mode for {:?}", path);
2289
2290 if !(5000..=30000).contains(&debounce_ms) {
2292 anyhow::bail!(
2293 "Debounce must be between 5000ms (5s) and 30000ms (30s). Got: {}ms",
2294 debounce_ms
2295 );
2296 }
2297
2298 if !quiet {
2299 println!("Starting Reflex watch mode...");
2300 println!(" Directory: {}", path.display());
2301 println!(" Debounce: {}ms ({}s)", debounce_ms, debounce_ms / 1000);
2302 println!(" Press Ctrl+C to stop.\n");
2303 }
2304
2305 let cache = CacheManager::new(&path);
2307
2308 if !cache.exists() {
2310 if !quiet {
2311 println!("No index found, running initial index...");
2312 }
2313 let config = IndexConfig::default();
2314 let indexer = Indexer::new(cache, config);
2315 indexer.index(&path, !quiet)?;
2316 if !quiet {
2317 println!("Initial index complete. Now watching for changes...\n");
2318 }
2319 }
2320
2321 let cache = CacheManager::new(&path);
2323 let config = IndexConfig::default();
2324 let indexer = Indexer::new(cache, config);
2325
2326 let watch_config = crate::watcher::WatchConfig {
2328 debounce_ms,
2329 quiet,
2330 };
2331
2332 crate::watcher::watch(&path, indexer, watch_config)?;
2333
2334 Ok(())
2335}
2336
2337fn handle_interactive() -> Result<()> {
2339 log::info!("Launching interactive mode");
2340 crate::interactive::run_interactive()
2341}
2342
2343fn handle_mcp() -> Result<()> {
2345 log::info!("Starting MCP server");
2346 crate::mcp::run_mcp_server()
2347}
2348
2349fn handle_index_symbols_internal(cache_dir: PathBuf) -> Result<()> {
2351 let mut indexer = crate::background_indexer::BackgroundIndexer::new(&cache_dir)?;
2352 indexer.run()?;
2353 Ok(())
2354}
2355
2356#[allow(clippy::too_many_arguments)]
2358fn handle_analyze(
2359 circular: bool,
2360 hotspots: bool,
2361 min_dependents: usize,
2362 unused: bool,
2363 islands: bool,
2364 min_island_size: usize,
2365 max_island_size: Option<usize>,
2366 format: String,
2367 as_json: bool,
2368 pretty_json: bool,
2369 count_only: bool,
2370 all: bool,
2371 plain: bool,
2372 _glob_patterns: Vec<String>,
2373 _exclude_patterns: Vec<String>,
2374 _force: bool,
2375 limit: Option<usize>,
2376 offset: Option<usize>,
2377 sort: Option<String>,
2378) -> Result<()> {
2379 use crate::dependency::DependencyIndex;
2380
2381 log::info!("Starting analyze command");
2382
2383 let cache = CacheManager::new(".");
2384
2385 if !cache.exists() {
2386 anyhow::bail!(
2387 "No index found in current directory.\n\
2388 \n\
2389 Run 'rfx index' to build the code search index first.\n\
2390 \n\
2391 Example:\n\
2392 $ rfx index # Index current directory\n\
2393 $ rfx analyze # Run dependency analysis"
2394 );
2395 }
2396
2397 let deps_index = DependencyIndex::new(cache);
2398
2399 let format = if as_json { "json" } else { &format };
2401
2402 let final_limit = if all {
2404 None } else if let Some(user_limit) = limit {
2406 Some(user_limit) } else {
2408 Some(200) };
2410
2411 if !circular && !hotspots && !unused && !islands {
2413 return handle_analyze_summary(&deps_index, min_dependents, count_only, as_json, pretty_json);
2414 }
2415
2416 if circular {
2418 handle_deps_circular(&deps_index, format, pretty_json, final_limit, offset, count_only, plain, sort.clone())?;
2419 }
2420
2421 if hotspots {
2422 handle_deps_hotspots(&deps_index, format, pretty_json, final_limit, offset, min_dependents, count_only, plain, sort.clone())?;
2423 }
2424
2425 if unused {
2426 handle_deps_unused(&deps_index, format, pretty_json, final_limit, offset, count_only, plain)?;
2427 }
2428
2429 if islands {
2430 handle_deps_islands(&deps_index, format, pretty_json, final_limit, offset, min_island_size, max_island_size, count_only, plain, sort.clone())?;
2431 }
2432
2433 Ok(())
2434}
2435
2436fn handle_analyze_summary(
2438 deps_index: &crate::dependency::DependencyIndex,
2439 min_dependents: usize,
2440 count_only: bool,
2441 as_json: bool,
2442 pretty_json: bool,
2443) -> Result<()> {
2444 let cycles = deps_index.detect_circular_dependencies()?;
2446 let hotspots = deps_index.find_hotspots(None, min_dependents)?;
2447 let unused = deps_index.find_unused_files()?;
2448 let all_islands = deps_index.find_islands()?;
2449
2450 if as_json {
2451 let summary = serde_json::json!({
2453 "circular_dependencies": cycles.len(),
2454 "hotspots": hotspots.len(),
2455 "unused_files": unused.len(),
2456 "islands": all_islands.len(),
2457 "min_dependents": min_dependents,
2458 });
2459
2460 let json_str = if pretty_json {
2461 serde_json::to_string_pretty(&summary)?
2462 } else {
2463 serde_json::to_string(&summary)?
2464 };
2465 println!("{}", json_str);
2466 } else if count_only {
2467 println!("{} circular dependencies", cycles.len());
2469 println!("{} hotspots ({}+ dependents)", hotspots.len(), min_dependents);
2470 println!("{} unused files", unused.len());
2471 println!("{} islands", all_islands.len());
2472 } else {
2473 println!("Dependency Analysis Summary\n");
2475
2476 println!("Circular Dependencies: {} cycle(s)", cycles.len());
2478
2479 println!("Hotspots: {} file(s) with {}+ dependents", hotspots.len(), min_dependents);
2481
2482 println!("Unused Files: {} file(s)", unused.len());
2484
2485 println!("Islands: {} disconnected component(s)", all_islands.len());
2487
2488 println!("\nUse specific flags for detailed results:");
2489 println!(" rfx analyze --circular");
2490 println!(" rfx analyze --hotspots");
2491 println!(" rfx analyze --unused");
2492 println!(" rfx analyze --islands");
2493 }
2494
2495 Ok(())
2496}
2497
2498fn handle_deps(
2500 file: PathBuf,
2501 reverse: bool,
2502 depth: usize,
2503 format: String,
2504 as_json: bool,
2505 pretty_json: bool,
2506) -> Result<()> {
2507 use crate::dependency::DependencyIndex;
2508
2509 log::info!("Starting deps command");
2510
2511 let cache = CacheManager::new(".");
2512
2513 if !cache.exists() {
2514 anyhow::bail!(
2515 "No index found in current directory.\n\
2516 \n\
2517 Run 'rfx index' to build the code search index first.\n\
2518 \n\
2519 Example:\n\
2520 $ rfx index # Index current directory\n\
2521 $ rfx deps <file> # Analyze dependencies"
2522 );
2523 }
2524
2525 let deps_index = DependencyIndex::new(cache);
2526
2527 let format = if as_json { "json" } else { &format };
2529
2530 let file_str = file.to_string_lossy().to_string();
2532
2533 let file_id = deps_index.get_file_id_by_path(&file_str)?
2535 .ok_or_else(|| anyhow::anyhow!("File '{}' not found in index", file_str))?;
2536
2537 if reverse {
2538 let dependents = deps_index.get_dependents(file_id)?;
2540 let paths = deps_index.get_file_paths(&dependents)?;
2541
2542 match format.as_ref() {
2543 "json" => {
2544 let output: Vec<_> = dependents.iter()
2545 .filter_map(|id| paths.get(id).map(|path| serde_json::json!({
2546 "file_id": id,
2547 "path": path,
2548 })))
2549 .collect();
2550
2551 let json_str = if pretty_json {
2552 serde_json::to_string_pretty(&output)?
2553 } else {
2554 serde_json::to_string(&output)?
2555 };
2556 println!("{}", json_str);
2557 eprintln!("Found {} files that import {}", dependents.len(), file_str);
2558 }
2559 "tree" => {
2560 println!("Files that import {}:", file_str);
2561 for (id, path) in &paths {
2562 if dependents.contains(id) {
2563 println!(" └─ {}", path);
2564 }
2565 }
2566 eprintln!("\nFound {} dependents", dependents.len());
2567 }
2568 "table" => {
2569 println!("ID Path");
2570 println!("----- ----");
2571 for id in &dependents {
2572 if let Some(path) = paths.get(id) {
2573 println!("{:<5} {}", id, path);
2574 }
2575 }
2576 eprintln!("\nFound {} dependents", dependents.len());
2577 }
2578 _ => {
2579 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2580 }
2581 }
2582 } else {
2583 if depth == 1 {
2585 let deps = deps_index.get_dependencies(file_id)?;
2587
2588 match format.as_ref() {
2589 "json" => {
2590 let output: Vec<_> = deps.iter()
2591 .map(|dep| serde_json::json!({
2592 "imported_path": dep.imported_path,
2593 "resolved_file_id": dep.resolved_file_id,
2594 "import_type": match dep.import_type {
2595 crate::models::ImportType::Internal => "internal",
2596 crate::models::ImportType::External => "external",
2597 crate::models::ImportType::Stdlib => "stdlib",
2598 },
2599 "line": dep.line_number,
2600 "symbols": dep.imported_symbols,
2601 }))
2602 .collect();
2603
2604 let json_str = if pretty_json {
2605 serde_json::to_string_pretty(&output)?
2606 } else {
2607 serde_json::to_string(&output)?
2608 };
2609 println!("{}", json_str);
2610 eprintln!("Found {} dependencies for {}", deps.len(), file_str);
2611 }
2612 "tree" => {
2613 println!("Dependencies of {}:", file_str);
2614 for dep in &deps {
2615 let type_label = match dep.import_type {
2616 crate::models::ImportType::Internal => "[internal]",
2617 crate::models::ImportType::External => "[external]",
2618 crate::models::ImportType::Stdlib => "[stdlib]",
2619 };
2620 println!(" └─ {} {} (line {})", dep.imported_path, type_label, dep.line_number);
2621 }
2622 eprintln!("\nFound {} dependencies", deps.len());
2623 }
2624 "table" => {
2625 println!("Path Type Line");
2626 println!("---------------------------- --------- ----");
2627 for dep in &deps {
2628 let type_str = match dep.import_type {
2629 crate::models::ImportType::Internal => "internal",
2630 crate::models::ImportType::External => "external",
2631 crate::models::ImportType::Stdlib => "stdlib",
2632 };
2633 println!("{:<28} {:<9} {}", dep.imported_path, type_str, dep.line_number);
2634 }
2635 eprintln!("\nFound {} dependencies", deps.len());
2636 }
2637 _ => {
2638 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2639 }
2640 }
2641 } else {
2642 let transitive = deps_index.get_transitive_deps(file_id, depth)?;
2644 let file_ids: Vec<_> = transitive.keys().copied().collect();
2645 let paths = deps_index.get_file_paths(&file_ids)?;
2646
2647 match format.as_ref() {
2648 "json" => {
2649 let output: Vec<_> = transitive.iter()
2650 .filter_map(|(id, d)| {
2651 paths.get(id).map(|path| serde_json::json!({
2652 "file_id": id,
2653 "path": path,
2654 "depth": d,
2655 }))
2656 })
2657 .collect();
2658
2659 let json_str = if pretty_json {
2660 serde_json::to_string_pretty(&output)?
2661 } else {
2662 serde_json::to_string(&output)?
2663 };
2664 println!("{}", json_str);
2665 eprintln!("Found {} transitive dependencies (depth {})", transitive.len(), depth);
2666 }
2667 "tree" => {
2668 println!("Transitive dependencies of {} (depth {}):", file_str, depth);
2669 let mut by_depth: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
2671 for (id, d) in &transitive {
2672 by_depth.entry(*d).or_insert_with(Vec::new).push(*id);
2673 }
2674
2675 for depth_level in 0..=depth {
2676 if let Some(ids) = by_depth.get(&depth_level) {
2677 let indent = " ".repeat(depth_level);
2678 for id in ids {
2679 if let Some(path) = paths.get(id) {
2680 if depth_level == 0 {
2681 println!("{}{} (self)", indent, path);
2682 } else {
2683 println!("{}└─ {}", indent, path);
2684 }
2685 }
2686 }
2687 }
2688 }
2689 eprintln!("\nFound {} transitive dependencies", transitive.len());
2690 }
2691 "table" => {
2692 println!("Depth File ID Path");
2693 println!("----- ------- ----");
2694 let mut sorted: Vec<_> = transitive.iter().collect();
2695 sorted.sort_by_key(|(_, d)| *d);
2696 for (id, d) in sorted {
2697 if let Some(path) = paths.get(id) {
2698 println!("{:<5} {:<7} {}", d, id, path);
2699 }
2700 }
2701 eprintln!("\nFound {} transitive dependencies", transitive.len());
2702 }
2703 _ => {
2704 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2705 }
2706 }
2707 }
2708 }
2709
2710 Ok(())
2711}
2712
2713fn handle_ask(
2715 question: Option<String>,
2716 _auto_execute: bool,
2717 provider_override: Option<String>,
2718 as_json: bool,
2719 pretty_json: bool,
2720 additional_context: Option<String>,
2721 configure: bool,
2722 agentic: bool,
2723 max_iterations: usize,
2724 no_eval: bool,
2725 show_reasoning: bool,
2726 verbose: bool,
2727 quiet: bool,
2728 answer: bool,
2729 interactive: bool,
2730 debug: bool,
2731) -> Result<()> {
2732 if configure {
2734 eprintln!("Note: --configure is deprecated, use `rfx llm config` instead");
2735 log::info!("Launching configuration wizard");
2736 return crate::semantic::run_configure_wizard();
2737 }
2738
2739 if !crate::semantic::is_any_api_key_configured() {
2741 anyhow::bail!(
2742 "No API key configured.\n\
2743 \n\
2744 Please run 'rfx ask --configure' to set up your API provider and key.\n\
2745 \n\
2746 Alternatively, you can set an environment variable:\n\
2747 - OPENAI_API_KEY\n\
2748 - ANTHROPIC_API_KEY\n\
2749 - OPENROUTER_API_KEY"
2750 );
2751 }
2752
2753 if interactive || question.is_none() {
2756 log::info!("Launching interactive chat mode");
2757 let cache = CacheManager::new(".");
2758
2759 if !cache.exists() {
2760 anyhow::bail!(
2761 "No index found in current directory.\n\
2762 \n\
2763 Run 'rfx index' to build the code search index first.\n\
2764 \n\
2765 Example:\n\
2766 $ rfx index # Index current directory\n\
2767 $ rfx ask # Launch interactive chat"
2768 );
2769 }
2770
2771 return crate::semantic::run_chat_mode(cache, provider_override, None);
2772 }
2773
2774 let question = question.unwrap();
2776
2777 log::info!("Starting ask command");
2778
2779 let cache = CacheManager::new(".");
2780
2781 if !cache.exists() {
2782 anyhow::bail!(
2783 "No index found in current directory.\n\
2784 \n\
2785 Run 'rfx index' to build the code search index first.\n\
2786 \n\
2787 Example:\n\
2788 $ rfx index # Index current directory\n\
2789 $ rfx ask \"Find all TODOs\" # Ask questions"
2790 );
2791 }
2792
2793 let runtime = tokio::runtime::Runtime::new()
2795 .context("Failed to create async runtime")?;
2796
2797 let quiet = quiet || as_json;
2799
2800 let spinner = if !as_json {
2802 let s = ProgressBar::new_spinner();
2803 s.set_style(
2804 ProgressStyle::default_spinner()
2805 .template("{spinner:.cyan} {msg}")
2806 .unwrap()
2807 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2808 );
2809 s.set_message("Generating queries...".to_string());
2810 s.enable_steady_tick(std::time::Duration::from_millis(80));
2811 Some(s)
2812 } else {
2813 None
2814 };
2815
2816 let (queries, results, total_count, count_only, gathered_context) = if agentic {
2817 let spinner_shared = if !quiet {
2821 spinner.as_ref().map(|s| Arc::new(Mutex::new(s.clone())))
2822 } else {
2823 None
2824 };
2825
2826 let reporter: Box<dyn crate::semantic::AgenticReporter> = if quiet {
2828 Box::new(crate::semantic::QuietReporter)
2829 } else {
2830 Box::new(crate::semantic::ConsoleReporter::new(show_reasoning, verbose, debug, spinner_shared))
2831 };
2832
2833 if let Some(ref s) = spinner {
2835 s.set_message("Starting agentic mode...".to_string());
2836 s.enable_steady_tick(std::time::Duration::from_millis(80));
2837 }
2838
2839 let agentic_config = crate::semantic::AgenticConfig {
2840 max_iterations,
2841 max_tools_per_phase: 5,
2842 enable_evaluation: !no_eval,
2843 eval_config: Default::default(),
2844 provider_override: provider_override.clone(),
2845 model_override: None,
2846 show_reasoning,
2847 verbose,
2848 debug,
2849 };
2850
2851 let agentic_response = runtime.block_on(async {
2852 crate::semantic::run_agentic_loop(&question, &cache, agentic_config, &*reporter).await
2853 }).context("Failed to run agentic loop")?;
2854
2855 if let Some(ref s) = spinner {
2857 s.finish_and_clear();
2858 }
2859
2860 if !as_json {
2862 reporter.clear_all();
2863 }
2864
2865 log::info!("Agentic loop completed: {} queries generated", agentic_response.queries.len());
2866
2867 let count_only_mode = agentic_response.total_count.is_none();
2869 let count = agentic_response.total_count.unwrap_or(0);
2870 (agentic_response.queries, agentic_response.results, count, count_only_mode, agentic_response.gathered_context)
2871 } else {
2872 if let Some(ref s) = spinner {
2874 s.set_message("Generating queries...".to_string());
2875 s.enable_steady_tick(std::time::Duration::from_millis(80));
2876 }
2877
2878 let semantic_response = runtime.block_on(async {
2879 crate::semantic::ask_question(&question, &cache, provider_override.clone(), additional_context, debug).await
2880 }).context("Failed to generate semantic queries")?;
2881
2882 if let Some(ref s) = spinner {
2883 s.finish_and_clear();
2884 }
2885 log::info!("LLM generated {} queries", semantic_response.queries.len());
2886
2887 let (exec_results, exec_total, exec_count_only) = runtime.block_on(async {
2889 crate::semantic::execute_queries(semantic_response.queries.clone(), &cache).await
2890 }).context("Failed to execute queries")?;
2891
2892 (semantic_response.queries, exec_results, exec_total, exec_count_only, None)
2893 };
2894
2895 let generated_answer = if answer {
2897 let answer_spinner = if !as_json {
2899 let s = ProgressBar::new_spinner();
2900 s.set_style(
2901 ProgressStyle::default_spinner()
2902 .template("{spinner:.cyan} {msg}")
2903 .unwrap()
2904 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2905 );
2906 s.set_message("Generating answer...".to_string());
2907 s.enable_steady_tick(std::time::Duration::from_millis(80));
2908 Some(s)
2909 } else {
2910 None
2911 };
2912
2913 let mut config = crate::semantic::config::load_config(cache.path())?;
2915 if let Some(provider) = &provider_override {
2916 config.provider = provider.clone();
2917 }
2918 let api_key = crate::semantic::config::get_api_key(&config.provider)?;
2919 let model = if config.model.is_some() {
2920 config.model.clone()
2921 } else {
2922 crate::semantic::config::get_user_model(&config.provider)
2923 };
2924 let provider_instance = crate::semantic::providers::create_provider(
2925 &config.provider,
2926 api_key,
2927 model,
2928 crate::semantic::config::get_provider_options(&config.provider),
2929 )?;
2930
2931 let codebase_context_str = crate::semantic::context::CodebaseContext::extract(&cache)
2933 .ok()
2934 .map(|ctx| ctx.to_prompt_string());
2935
2936 let answer_result = runtime.block_on(async {
2938 crate::semantic::generate_answer(
2939 &question,
2940 &results,
2941 total_count,
2942 gathered_context.as_deref(),
2943 codebase_context_str.as_deref(),
2944 &*provider_instance,
2945 ).await
2946 }).context("Failed to generate answer")?;
2947
2948 if let Some(s) = answer_spinner {
2949 s.finish_and_clear();
2950 }
2951
2952 Some(answer_result)
2953 } else {
2954 None
2955 };
2956
2957 if as_json {
2959 let json_response = crate::semantic::AgenticQueryResponse {
2961 queries: queries.clone(),
2962 results: results.clone(),
2963 total_count: if count_only { None } else { Some(total_count) },
2964 gathered_context: gathered_context.clone(),
2965 tools_executed: None, answer: generated_answer,
2967 };
2968
2969 let json_str = if pretty_json {
2970 serde_json::to_string_pretty(&json_response)?
2971 } else {
2972 serde_json::to_string(&json_response)?
2973 };
2974 println!("{}", json_str);
2975 return Ok(());
2976 }
2977
2978 if !answer {
2980 println!("\n{}", "Generated Queries:".bold().cyan());
2981 println!("{}", "==================".cyan());
2982 for (idx, query_cmd) in queries.iter().enumerate() {
2983 println!(
2984 "{}. {} {} {}",
2985 (idx + 1).to_string().bright_white().bold(),
2986 format!("[order: {}, merge: {}]", query_cmd.order, query_cmd.merge).dimmed(),
2987 "rfx".bright_green().bold(),
2988 query_cmd.command.bright_white()
2989 );
2990 }
2991 println!();
2992 }
2993
2994 println!();
3000 if let Some(answer_text) = generated_answer {
3001 println!("{}", "Answer:".bold().green());
3003 println!("{}", "=======".green());
3004 println!();
3005
3006 termimad::print_text(&answer_text);
3008 println!();
3009
3010 if !results.is_empty() {
3012 println!(
3013 "{}",
3014 format!(
3015 "(Based on {} matches across {} files)",
3016 total_count,
3017 results.len()
3018 ).dimmed()
3019 );
3020 }
3021 } else {
3022 if count_only {
3024 println!("{} {}", "Found".bright_green().bold(), format!("{} results", total_count).bright_white().bold());
3026 } else if results.is_empty() {
3027 println!("{}", "No results found.".yellow());
3028 } else {
3029 println!(
3030 "{} {} {} {} {}",
3031 "Found".bright_green().bold(),
3032 total_count.to_string().bright_white().bold(),
3033 "total results across".dimmed(),
3034 results.len().to_string().bright_white().bold(),
3035 "files:".dimmed()
3036 );
3037 println!();
3038
3039 for file_group in &results {
3040 println!("{}:", file_group.path.bright_cyan().bold());
3041 for match_result in &file_group.matches {
3042 println!(
3043 " {} {}-{}: {}",
3044 "Line".dimmed(),
3045 match_result.span.start_line.to_string().bright_yellow(),
3046 match_result.span.end_line.to_string().bright_yellow(),
3047 match_result.preview.lines().next().unwrap_or("")
3048 );
3049 }
3050 println!();
3051 }
3052 }
3053 }
3054
3055 Ok(())
3056}
3057
3058fn handle_context(
3060 structure: bool,
3061 path: Option<String>,
3062 file_types: bool,
3063 project_type: bool,
3064 framework: bool,
3065 entry_points: bool,
3066 test_layout: bool,
3067 config_files: bool,
3068 depth: usize,
3069 json: bool,
3070) -> Result<()> {
3071 let cache = CacheManager::new(".");
3072
3073 if !cache.exists() {
3074 anyhow::bail!(
3075 "No index found in current directory.\n\
3076 \n\
3077 Run 'rfx index' to build the code search index first.\n\
3078 \n\
3079 Example:\n\
3080 $ rfx index # Index current directory\n\
3081 $ rfx context # Generate context"
3082 );
3083 }
3084
3085 let opts = crate::context::ContextOptions {
3087 structure,
3088 path,
3089 file_types,
3090 project_type,
3091 framework,
3092 entry_points,
3093 test_layout,
3094 config_files,
3095 depth,
3096 json,
3097 };
3098
3099 let context_output = crate::context::generate_context(&cache, &opts)
3101 .context("Failed to generate codebase context")?;
3102
3103 println!("{}", context_output);
3105
3106 Ok(())
3107}
3108
3109fn handle_deps_circular(
3111 deps_index: &crate::dependency::DependencyIndex,
3112 format: &str,
3113 pretty_json: bool,
3114 limit: Option<usize>,
3115 offset: Option<usize>,
3116 count_only: bool,
3117 _plain: bool,
3118 sort: Option<String>,
3119) -> Result<()> {
3120 let mut all_cycles = deps_index.detect_circular_dependencies()?;
3121
3122 let sort_order = sort.as_deref().unwrap_or("desc");
3124 match sort_order {
3125 "asc" => {
3126 all_cycles.sort_by_key(|cycle| cycle.len());
3128 }
3129 "desc" => {
3130 all_cycles.sort_by_key(|cycle| std::cmp::Reverse(cycle.len()));
3132 }
3133 _ => {
3134 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3135 }
3136 }
3137
3138 let total_count = all_cycles.len();
3139
3140 if count_only {
3141 println!("Found {} circular dependencies", total_count);
3142 return Ok(());
3143 }
3144
3145 if all_cycles.is_empty() {
3146 println!("No circular dependencies found.");
3147 return Ok(());
3148 }
3149
3150 let offset_val = offset.unwrap_or(0);
3152 let mut cycles: Vec<_> = all_cycles.into_iter().skip(offset_val).collect();
3153
3154 if let Some(lim) = limit {
3156 cycles.truncate(lim);
3157 }
3158
3159 if cycles.is_empty() {
3160 println!("No circular dependencies found at offset {}.", offset_val);
3161 return Ok(());
3162 }
3163
3164 let count = cycles.len();
3165 let has_more = offset_val + count < total_count;
3166
3167 match format {
3168 "json" => {
3169 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
3170 let paths = deps_index.get_file_paths(&file_ids)?;
3171
3172 let results: Vec<_> = cycles.iter()
3173 .map(|cycle| {
3174 let cycle_paths: Vec<_> = cycle.iter()
3175 .filter_map(|id| paths.get(id).cloned())
3176 .collect();
3177 serde_json::json!({
3178 "paths": cycle_paths,
3179 })
3180 })
3181 .collect();
3182
3183 let output = serde_json::json!({
3184 "pagination": {
3185 "total": total_count,
3186 "count": count,
3187 "offset": offset_val,
3188 "limit": limit,
3189 "has_more": has_more,
3190 },
3191 "results": results,
3192 });
3193
3194 let json_str = if pretty_json {
3195 serde_json::to_string_pretty(&output)?
3196 } else {
3197 serde_json::to_string(&output)?
3198 };
3199 println!("{}", json_str);
3200 if total_count > count {
3201 eprintln!("Found {} circular dependencies ({} total)", count, total_count);
3202 } else {
3203 eprintln!("Found {} circular dependencies", count);
3204 }
3205 }
3206 "tree" => {
3207 println!("Circular Dependencies Found:");
3208 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
3209 let paths = deps_index.get_file_paths(&file_ids)?;
3210
3211 for (idx, cycle) in cycles.iter().enumerate() {
3212 println!("\nCycle {}:", idx + 1);
3213 for id in cycle {
3214 if let Some(path) = paths.get(id) {
3215 println!(" → {}", path);
3216 }
3217 }
3218 if let Some(first_id) = cycle.first() {
3220 if let Some(path) = paths.get(first_id) {
3221 println!(" → {} (cycle completes)", path);
3222 }
3223 }
3224 }
3225 if total_count > count {
3226 eprintln!("\nFound {} cycles ({} total)", count, total_count);
3227 if has_more {
3228 eprintln!("Use --limit and --offset to paginate");
3229 }
3230 } else {
3231 eprintln!("\nFound {} cycles", count);
3232 }
3233 }
3234 "table" => {
3235 println!("Cycle Files in Cycle");
3236 println!("----- --------------");
3237 let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
3238 let paths = deps_index.get_file_paths(&file_ids)?;
3239
3240 for (idx, cycle) in cycles.iter().enumerate() {
3241 let cycle_str = cycle.iter()
3242 .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3243 .collect::<Vec<_>>()
3244 .join(" → ");
3245 println!("{:<5} {}", idx + 1, cycle_str);
3246 }
3247 if total_count > count {
3248 eprintln!("\nFound {} cycles ({} total)", count, total_count);
3249 if has_more {
3250 eprintln!("Use --limit and --offset to paginate");
3251 }
3252 } else {
3253 eprintln!("\nFound {} cycles", count);
3254 }
3255 }
3256 _ => {
3257 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3258 }
3259 }
3260
3261 Ok(())
3262}
3263
3264fn handle_deps_hotspots(
3266 deps_index: &crate::dependency::DependencyIndex,
3267 format: &str,
3268 pretty_json: bool,
3269 limit: Option<usize>,
3270 offset: Option<usize>,
3271 min_dependents: usize,
3272 count_only: bool,
3273 _plain: bool,
3274 sort: Option<String>,
3275) -> Result<()> {
3276 let mut all_hotspots = deps_index.find_hotspots(None, min_dependents)?;
3278
3279 let sort_order = sort.as_deref().unwrap_or("desc");
3281 match sort_order {
3282 "asc" => {
3283 all_hotspots.sort_by(|a, b| a.1.cmp(&b.1));
3285 }
3286 "desc" => {
3287 all_hotspots.sort_by(|a, b| b.1.cmp(&a.1));
3289 }
3290 _ => {
3291 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3292 }
3293 }
3294
3295 let total_count = all_hotspots.len();
3296
3297 if count_only {
3298 println!("Found {} hotspots with {}+ dependents", total_count, min_dependents);
3299 return Ok(());
3300 }
3301
3302 if all_hotspots.is_empty() {
3303 println!("No hotspots found.");
3304 return Ok(());
3305 }
3306
3307 let offset_val = offset.unwrap_or(0);
3309 let mut hotspots: Vec<_> = all_hotspots.into_iter().skip(offset_val).collect();
3310
3311 if let Some(lim) = limit {
3313 hotspots.truncate(lim);
3314 }
3315
3316 if hotspots.is_empty() {
3317 println!("No hotspots found at offset {}.", offset_val);
3318 return Ok(());
3319 }
3320
3321 let count = hotspots.len();
3322 let has_more = offset_val + count < total_count;
3323
3324 let file_ids: Vec<i64> = hotspots.iter().map(|(id, _)| *id).collect();
3325 let paths = deps_index.get_file_paths(&file_ids)?;
3326
3327 match format {
3328 "json" => {
3329 let results: Vec<_> = hotspots.iter()
3330 .filter_map(|(id, import_count)| {
3331 paths.get(id).map(|path| serde_json::json!({
3332 "path": path,
3333 "import_count": import_count,
3334 }))
3335 })
3336 .collect();
3337
3338 let output = serde_json::json!({
3339 "pagination": {
3340 "total": total_count,
3341 "count": count,
3342 "offset": offset_val,
3343 "limit": limit,
3344 "has_more": has_more,
3345 },
3346 "results": results,
3347 });
3348
3349 let json_str = if pretty_json {
3350 serde_json::to_string_pretty(&output)?
3351 } else {
3352 serde_json::to_string(&output)?
3353 };
3354 println!("{}", json_str);
3355 if total_count > count {
3356 eprintln!("Found {} hotspots ({} total)", count, total_count);
3357 } else {
3358 eprintln!("Found {} hotspots", count);
3359 }
3360 }
3361 "tree" => {
3362 println!("Hotspots (Most-Imported Files):");
3363 for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3364 if let Some(path) = paths.get(id) {
3365 println!(" {}. {} ({} imports)", idx + 1, path, import_count);
3366 }
3367 }
3368 if total_count > count {
3369 eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3370 if has_more {
3371 eprintln!("Use --limit and --offset to paginate");
3372 }
3373 } else {
3374 eprintln!("\nFound {} hotspots", count);
3375 }
3376 }
3377 "table" => {
3378 println!("Rank Imports File");
3379 println!("---- ------- ----");
3380 for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3381 if let Some(path) = paths.get(id) {
3382 println!("{:<4} {:<7} {}", idx + 1, import_count, path);
3383 }
3384 }
3385 if total_count > count {
3386 eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3387 if has_more {
3388 eprintln!("Use --limit and --offset to paginate");
3389 }
3390 } else {
3391 eprintln!("\nFound {} hotspots", count);
3392 }
3393 }
3394 _ => {
3395 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3396 }
3397 }
3398
3399 Ok(())
3400}
3401
3402fn handle_deps_unused(
3404 deps_index: &crate::dependency::DependencyIndex,
3405 format: &str,
3406 pretty_json: bool,
3407 limit: Option<usize>,
3408 offset: Option<usize>,
3409 count_only: bool,
3410 _plain: bool,
3411) -> Result<()> {
3412 let all_unused = deps_index.find_unused_files()?;
3413 let total_count = all_unused.len();
3414
3415 if count_only {
3416 println!("Found {} unused files", total_count);
3417 return Ok(());
3418 }
3419
3420 if all_unused.is_empty() {
3421 println!("No unused files found (all files have incoming dependencies).");
3422 return Ok(());
3423 }
3424
3425 let offset_val = offset.unwrap_or(0);
3427 let mut unused: Vec<_> = all_unused.into_iter().skip(offset_val).collect();
3428
3429 if unused.is_empty() {
3430 println!("No unused files found at offset {}.", offset_val);
3431 return Ok(());
3432 }
3433
3434 if let Some(lim) = limit {
3436 unused.truncate(lim);
3437 }
3438
3439 let count = unused.len();
3440 let has_more = offset_val + count < total_count;
3441
3442 let paths = deps_index.get_file_paths(&unused)?;
3443
3444 match format {
3445 "json" => {
3446 let results: Vec<String> = unused.iter()
3448 .filter_map(|id| paths.get(id).cloned())
3449 .collect();
3450
3451 let output = serde_json::json!({
3452 "pagination": {
3453 "total": total_count,
3454 "count": count,
3455 "offset": offset_val,
3456 "limit": limit,
3457 "has_more": has_more,
3458 },
3459 "results": results,
3460 });
3461
3462 let json_str = if pretty_json {
3463 serde_json::to_string_pretty(&output)?
3464 } else {
3465 serde_json::to_string(&output)?
3466 };
3467 println!("{}", json_str);
3468 if total_count > count {
3469 eprintln!("Found {} unused files ({} total)", count, total_count);
3470 } else {
3471 eprintln!("Found {} unused files", count);
3472 }
3473 }
3474 "tree" => {
3475 println!("Unused Files (No Incoming Dependencies):");
3476 for (idx, id) in unused.iter().enumerate() {
3477 if let Some(path) = paths.get(id) {
3478 println!(" {}. {}", idx + 1, path);
3479 }
3480 }
3481 if total_count > count {
3482 eprintln!("\nFound {} unused files ({} total)", count, total_count);
3483 if has_more {
3484 eprintln!("Use --limit and --offset to paginate");
3485 }
3486 } else {
3487 eprintln!("\nFound {} unused files", count);
3488 }
3489 }
3490 "table" => {
3491 println!("Path");
3492 println!("----");
3493 for id in &unused {
3494 if let Some(path) = paths.get(id) {
3495 println!("{}", path);
3496 }
3497 }
3498 if total_count > count {
3499 eprintln!("\nFound {} unused files ({} total)", count, total_count);
3500 if has_more {
3501 eprintln!("Use --limit and --offset to paginate");
3502 }
3503 } else {
3504 eprintln!("\nFound {} unused files", count);
3505 }
3506 }
3507 _ => {
3508 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3509 }
3510 }
3511
3512 Ok(())
3513}
3514
3515fn handle_deps_islands(
3517 deps_index: &crate::dependency::DependencyIndex,
3518 format: &str,
3519 pretty_json: bool,
3520 limit: Option<usize>,
3521 offset: Option<usize>,
3522 min_island_size: usize,
3523 max_island_size: Option<usize>,
3524 count_only: bool,
3525 _plain: bool,
3526 sort: Option<String>,
3527) -> Result<()> {
3528 let all_islands = deps_index.find_islands()?;
3529 let total_components = all_islands.len();
3530
3531 let cache = deps_index.get_cache();
3533 let total_files = cache.stats()?.total_files as usize;
3534
3535 let max_size = max_island_size.unwrap_or_else(|| {
3537 let fifty_percent = (total_files as f64 * 0.5) as usize;
3538 fifty_percent.min(500)
3539 });
3540
3541 let mut islands: Vec<_> = all_islands.into_iter()
3543 .filter(|island| {
3544 let size = island.len();
3545 size >= min_island_size && size <= max_size
3546 })
3547 .collect();
3548
3549 let sort_order = sort.as_deref().unwrap_or("desc");
3551 match sort_order {
3552 "asc" => {
3553 islands.sort_by_key(|island| island.len());
3555 }
3556 "desc" => {
3557 islands.sort_by_key(|island| std::cmp::Reverse(island.len()));
3559 }
3560 _ => {
3561 anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3562 }
3563 }
3564
3565 let filtered_count = total_components - islands.len();
3566
3567 if count_only {
3568 if filtered_count > 0 {
3569 println!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3570 islands.len(), filtered_count, total_components, min_island_size, max_size);
3571 } else {
3572 println!("Found {} islands", islands.len());
3573 }
3574 return Ok(());
3575 }
3576
3577 let offset_val = offset.unwrap_or(0);
3579 if offset_val > 0 && offset_val < islands.len() {
3580 islands = islands.into_iter().skip(offset_val).collect();
3581 } else if offset_val >= islands.len() {
3582 if filtered_count > 0 {
3583 println!("No islands found at offset {} (filtered {} of {} total components by size: {}-{}).",
3584 offset_val, filtered_count, total_components, min_island_size, max_size);
3585 } else {
3586 println!("No islands found at offset {}.", offset_val);
3587 }
3588 return Ok(());
3589 }
3590
3591 if let Some(lim) = limit {
3593 islands.truncate(lim);
3594 }
3595
3596 if islands.is_empty() {
3597 if filtered_count > 0 {
3598 println!("No islands found matching criteria (filtered {} of {} total components by size: {}-{}).",
3599 filtered_count, total_components, min_island_size, max_size);
3600 } else {
3601 println!("No islands found.");
3602 }
3603 return Ok(());
3604 }
3605
3606 let count = islands.len();
3608 let has_more = offset_val + count < total_components - filtered_count;
3609
3610 let file_ids: Vec<i64> = islands.iter().flat_map(|island| island.iter()).copied().collect();
3611 let paths = deps_index.get_file_paths(&file_ids)?;
3612
3613 match format {
3614 "json" => {
3615 let results: Vec<_> = islands.iter()
3616 .enumerate()
3617 .map(|(idx, island)| {
3618 let island_paths: Vec<_> = island.iter()
3619 .filter_map(|id| paths.get(id).cloned())
3620 .collect();
3621 serde_json::json!({
3622 "island_id": idx + 1,
3623 "size": island.len(),
3624 "paths": island_paths,
3625 })
3626 })
3627 .collect();
3628
3629 let output = serde_json::json!({
3630 "pagination": {
3631 "total": total_components - filtered_count,
3632 "count": count,
3633 "offset": offset_val,
3634 "limit": limit,
3635 "has_more": has_more,
3636 },
3637 "results": results,
3638 });
3639
3640 let json_str = if pretty_json {
3641 serde_json::to_string_pretty(&output)?
3642 } else {
3643 serde_json::to_string(&output)?
3644 };
3645 println!("{}", json_str);
3646 if filtered_count > 0 {
3647 eprintln!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3648 count, filtered_count, total_components, min_island_size, max_size);
3649 } else if total_components - filtered_count > count {
3650 eprintln!("Found {} islands ({} total)", count, total_components - filtered_count);
3651 } else {
3652 eprintln!("Found {} islands (disconnected components)", count);
3653 }
3654 }
3655 "tree" => {
3656 println!("Islands (Disconnected Components):");
3657 for (idx, island) in islands.iter().enumerate() {
3658 println!("\nIsland {} ({} files):", idx + 1, island.len());
3659 for id in island {
3660 if let Some(path) = paths.get(id) {
3661 println!(" ├─ {}", path);
3662 }
3663 }
3664 }
3665 if filtered_count > 0 {
3666 eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3667 count, filtered_count, total_components, min_island_size, max_size);
3668 if has_more {
3669 eprintln!("Use --limit and --offset to paginate");
3670 }
3671 } else if total_components - filtered_count > count {
3672 eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3673 if has_more {
3674 eprintln!("Use --limit and --offset to paginate");
3675 }
3676 } else {
3677 eprintln!("\nFound {} islands", count);
3678 }
3679 }
3680 "table" => {
3681 println!("Island Size Files");
3682 println!("------ ---- -----");
3683 for (idx, island) in islands.iter().enumerate() {
3684 let island_files = island.iter()
3685 .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3686 .collect::<Vec<_>>()
3687 .join(", ");
3688 println!("{:<6} {:<4} {}", idx + 1, island.len(), island_files);
3689 }
3690 if filtered_count > 0 {
3691 eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3692 count, filtered_count, total_components, min_island_size, max_size);
3693 if has_more {
3694 eprintln!("Use --limit and --offset to paginate");
3695 }
3696 } else if total_components - filtered_count > count {
3697 eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3698 if has_more {
3699 eprintln!("Use --limit and --offset to paginate");
3700 }
3701 } else {
3702 eprintln!("\nFound {} islands", count);
3703 }
3704 }
3705 _ => {
3706 anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3707 }
3708 }
3709
3710 Ok(())
3711}
3712
3713fn handle_snapshot_create() -> Result<()> {
3716 let cache = CacheManager::new(".");
3717 if !cache.path().exists() {
3718 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3719 }
3720
3721 let info = pulse::snapshot::create_snapshot(&cache)?;
3722 eprintln!("Snapshot created: {}", info.id);
3723 eprintln!(" Files: {}, Lines: {}, Edges: {}", info.file_count, info.total_lines, info.edge_count);
3724 if let Some(branch) = &info.git_branch {
3725 eprintln!(" Branch: {}", branch);
3726 }
3727
3728 let pulse_config = pulse::config::load_pulse_config(cache.path())?;
3730 let gc_report = pulse::snapshot::run_gc(&cache, &pulse_config.retention)?;
3731 if gc_report.removed > 0 {
3732 eprintln!(" GC: removed {} old snapshot(s)", gc_report.removed);
3733 }
3734
3735 Ok(())
3736}
3737
3738fn handle_snapshot_list(json: bool, pretty: bool) -> Result<()> {
3739 let cache = CacheManager::new(".");
3740 if !cache.path().exists() {
3741 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3742 }
3743
3744 let snapshots = pulse::snapshot::list_snapshots(&cache)?;
3745
3746 if json || pretty {
3747 let output = if pretty {
3748 serde_json::to_string_pretty(&snapshots)?
3749 } else {
3750 serde_json::to_string(&snapshots)?
3751 };
3752 println!("{}", output);
3753 } else {
3754 if snapshots.is_empty() {
3755 eprintln!("No snapshots found. Run `rfx snapshot` to create one.");
3756 return Ok(());
3757 }
3758 println!("{:<20} {:>6} {:>8} {:>6} {}", "ID", "Files", "Lines", "Edges", "Branch");
3759 println!("{}", "-".repeat(60));
3760 for s in &snapshots {
3761 println!("{:<20} {:>6} {:>8} {:>6} {}",
3762 s.id, s.file_count, s.total_lines, s.edge_count,
3763 s.git_branch.as_deref().unwrap_or("-"));
3764 }
3765 eprintln!("\n{} snapshot(s)", snapshots.len());
3766 }
3767
3768 Ok(())
3769}
3770
3771fn handle_snapshot_diff(
3772 baseline: Option<String>,
3773 current: Option<String>,
3774 json: bool,
3775 pretty: bool,
3776) -> Result<()> {
3777 let cache = CacheManager::new(".");
3778 if !cache.path().exists() {
3779 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3780 }
3781
3782 let snapshots = pulse::snapshot::list_snapshots(&cache)?;
3783 let pulse_config = pulse::config::load_pulse_config(cache.path())?;
3784
3785 let current_snapshot = match ¤t {
3786 Some(id) => snapshots.iter().find(|s| s.id == *id)
3787 .ok_or_else(|| anyhow::anyhow!("Snapshot '{}' not found", id))?,
3788 None => snapshots.first()
3789 .ok_or_else(|| anyhow::anyhow!("No snapshots found. Run `rfx snapshot` first."))?,
3790 };
3791
3792 let baseline_snapshot = match &baseline {
3793 Some(id) => snapshots.iter().find(|s| s.id == *id)
3794 .ok_or_else(|| anyhow::anyhow!("Snapshot '{}' not found", id))?,
3795 None => snapshots.get(1)
3796 .ok_or_else(|| anyhow::anyhow!("Need at least 2 snapshots to diff. Run `rfx snapshot` again after making changes."))?,
3797 };
3798
3799 let diff = pulse::diff::compute_diff(
3800 &baseline_snapshot.path,
3801 ¤t_snapshot.path,
3802 &pulse_config.thresholds,
3803 )?;
3804
3805 if json || pretty {
3806 let output = if pretty {
3807 serde_json::to_string_pretty(&diff)?
3808 } else {
3809 serde_json::to_string(&diff)?
3810 };
3811 println!("{}", output);
3812 } else {
3813 let s = &diff.summary;
3814 println!("Diff: {} → {}", diff.baseline_id, diff.current_id);
3815 println!(" Files: +{} -{} ~{}", s.files_added, s.files_removed, s.files_modified);
3816 println!(" Edges: +{} -{}", s.edges_added, s.edges_removed);
3817 if !diff.threshold_alerts.is_empty() {
3818 println!(" Alerts: {}", diff.threshold_alerts.len());
3819 for alert in &diff.threshold_alerts {
3820 println!(" [{:?}] {}", alert.severity, alert.message);
3821 }
3822 }
3823 }
3824
3825 Ok(())
3826}
3827
3828fn handle_snapshot_gc(json: bool) -> Result<()> {
3829 let cache = CacheManager::new(".");
3830 if !cache.path().exists() {
3831 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3832 }
3833
3834 let pulse_config = pulse::config::load_pulse_config(cache.path())?;
3835 let report = pulse::snapshot::run_gc(&cache, &pulse_config.retention)?;
3836
3837 if json {
3838 println!("{}", serde_json::to_string(&report)?);
3839 } else {
3840 println!("GC complete: before {}, after {}, removed {}", report.snapshots_before, report.snapshots_after, report.removed);
3841 }
3842
3843 Ok(())
3844}
3845
3846fn handle_pulse_changelog(
3849 count: usize,
3850 no_llm: bool,
3851 json: bool,
3852 pretty: bool,
3853) -> Result<()> {
3854 let cache = CacheManager::new(".");
3855 if !cache.path().exists() {
3856 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3857 }
3858
3859 let workspace_root = cache.path().parent().unwrap_or(std::path::Path::new("."));
3860 let mut changelog = pulse::changelog::extract_changelog(workspace_root, count)?;
3861
3862 if !no_llm && !changelog.raw_commits.is_empty() {
3863 match pulse::narrate::create_pulse_provider() {
3864 Ok(provider) => {
3865 eprintln!("LLM provider ready.");
3866 let llm_cache = pulse::llm_cache::LlmCache::new(cache.path());
3867
3868 let pulse_config = pulse::config::load_pulse_config(cache.path())?;
3869 let ensure_result = pulse::snapshot::ensure_snapshot(&cache, &pulse_config.retention)?;
3870 let snapshot_id = match &ensure_result {
3871 pulse::snapshot::EnsureSnapshotResult::Created(info) => info.id.clone(),
3872 pulse::snapshot::EnsureSnapshotResult::Reused(info) => info.id.clone(),
3873 };
3874
3875 let ctx = pulse::changelog::build_changelog_context(&changelog.raw_commits, &changelog.branch);
3876 let response = pulse::narrate::narrate_section(
3877 provider.as_ref(),
3878 pulse::narrate::changelog_system_prompt(),
3879 &ctx,
3880 &llm_cache,
3881 &snapshot_id,
3882 "changelog",
3883 );
3884
3885 if let Some(text) = response {
3886 changelog.entries = pulse::changelog::parse_changelog_response(&text, &changelog.raw_commits);
3887 changelog.narrated = true;
3888 }
3889 }
3890 Err(e) => {
3891 eprintln!("LLM unavailable: {}", e);
3892 }
3893 }
3894 }
3895
3896 if json || pretty {
3897 let output = if pretty {
3898 serde_json::to_string_pretty(&changelog)?
3899 } else {
3900 serde_json::to_string(&changelog)?
3901 };
3902 println!("{}", output);
3903 } else {
3904 println!("{}", pulse::changelog::render_markdown(&changelog));
3905 }
3906
3907 Ok(())
3908}
3909
3910fn handle_pulse_wiki(no_llm: bool, output: Option<PathBuf>, json: bool) -> Result<()> {
3911 let cache = CacheManager::new(".");
3912 if !cache.path().exists() {
3913 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3914 }
3915
3916 let pulse_config = pulse::config::load_pulse_config(cache.path())?;
3917
3918 let ensure_result = pulse::snapshot::ensure_snapshot(&cache, &pulse_config.retention)?;
3920 match &ensure_result {
3921 pulse::snapshot::EnsureSnapshotResult::Created(info) => {
3922 eprintln!("Auto-snapshot created: {} ({} files)", info.id, info.file_count);
3923 }
3924 pulse::snapshot::EnsureSnapshotResult::Reused(info) => {
3925 eprintln!("Using snapshot: {} (index unchanged)", info.id);
3926 }
3927 }
3928
3929 let snapshots = pulse::snapshot::list_snapshots(&cache)?;
3930
3931 let snapshot_diff = if snapshots.len() >= 2 {
3932 pulse::diff::compute_diff(&snapshots[1].path, &snapshots[0].path, &pulse_config.thresholds).ok()
3933 } else {
3934 None
3935 };
3936
3937 let (provider, llm_cache) = if !no_llm {
3939 match pulse::narrate::create_pulse_provider() {
3940 Ok(p) => {
3941 eprintln!("LLM provider ready.");
3942 let c = pulse::llm_cache::LlmCache::new(cache.path());
3943 (Some(p), Some(c))
3944 }
3945 Err(e) => {
3946 eprintln!("LLM unavailable: {}", e);
3947 (None, None)
3948 }
3949 }
3950 } else {
3951 (None, None)
3952 };
3953
3954 let snapshot_id = snapshots.first().map(|s| s.id.as_str()).unwrap_or("unknown");
3955 let pages = pulse::wiki::generate_all_pages(
3956 &cache,
3957 snapshot_diff.as_ref(),
3958 no_llm,
3959 snapshot_id,
3960 provider.as_ref().map(|p| p.as_ref()),
3961 llm_cache.as_ref(),
3962 &pulse::wiki::ModuleDiscoveryConfig::default(),
3963 )?;
3964
3965 if json {
3966 println!("{}", serde_json::to_string_pretty(&pages)?);
3967 } else if let Some(out_dir) = output {
3968 std::fs::create_dir_all(&out_dir)?;
3969 let rendered = pulse::wiki::render_wiki_markdown(&pages);
3970 for (filename, content) in &rendered {
3971 std::fs::write(out_dir.join(filename), content)?;
3972 }
3973 eprintln!("Wrote {} wiki pages to {}", rendered.len(), out_dir.display());
3974 } else {
3975 let rendered = pulse::wiki::render_wiki_markdown(&pages);
3976 for (filename, content) in &rendered {
3977 println!("--- {} ---\n{}\n", filename, content);
3978 }
3979 }
3980
3981 Ok(())
3982}
3983
3984fn handle_pulse_map(format: String, output: Option<PathBuf>, zoom: Option<String>) -> Result<()> {
3985 let cache = CacheManager::new(".");
3986 if !cache.path().exists() {
3987 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
3988 }
3989
3990 let map_format: pulse::map::MapFormat = format.parse()?;
3991 let map_zoom = match zoom {
3992 Some(module) => pulse::map::MapZoom::Module(module),
3993 None => pulse::map::MapZoom::Repo,
3994 };
3995
3996 let content = pulse::map::generate_map(&cache, &map_zoom, map_format)?;
3997
3998 if let Some(out_path) = output {
3999 std::fs::write(&out_path, &content)?;
4000 eprintln!("Map written to {}", out_path.display());
4001 } else {
4002 println!("{}", content);
4003 }
4004
4005 Ok(())
4006}
4007
4008fn handle_pulse_generate(
4009 output: PathBuf,
4010 base_url: String,
4011 title: Option<String>,
4012 include: Option<String>,
4013 no_llm: bool,
4014 clean: bool,
4015 force_renarrate: bool,
4016 concurrency: usize,
4017 depth: u8,
4018 min_files: usize,
4019) -> Result<()> {
4020 let cache = CacheManager::new(".");
4021 if !cache.path().exists() {
4022 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
4023 }
4024
4025 let surfaces = match include {
4026 Some(ref s) => {
4027 s.split(',')
4028 .map(|part| match part.trim().to_lowercase().as_str() {
4029 "wiki" => Ok(pulse::site::Surface::Wiki),
4030 "changelog" | "digest" => Ok(pulse::site::Surface::Changelog),
4031 "map" => Ok(pulse::site::Surface::Map),
4032 "onboard" => Ok(pulse::site::Surface::Onboard),
4033 "timeline" => Ok(pulse::site::Surface::Timeline),
4034 "glossary" => Ok(pulse::site::Surface::Glossary),
4035 "explorer" => Ok(pulse::site::Surface::Explorer),
4036 other => anyhow::bail!("Unknown surface '{}'. Supported: wiki, changelog, map, onboard, timeline, glossary, explorer", other),
4037 })
4038 .collect::<Result<Vec<_>>>()?
4039 }
4040 None => vec![
4041 pulse::site::Surface::Wiki,
4042 pulse::site::Surface::Changelog,
4043 pulse::site::Surface::Map,
4044 pulse::site::Surface::Onboard,
4045 pulse::site::Surface::Timeline,
4046 pulse::site::Surface::Glossary,
4047 pulse::site::Surface::Explorer,
4048 ],
4049 };
4050
4051 let config = pulse::site::SiteConfig {
4052 output_dir: output,
4053 base_url,
4054 title: title.unwrap_or_else(|| {
4055 let name = std::env::current_dir()
4056 .ok()
4057 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
4058 .unwrap_or_else(|| "Pulse".to_string());
4059 let mut chars = name.chars();
4060 let capitalized = match chars.next() {
4061 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
4062 None => name,
4063 };
4064 format!("{} Documentation", capitalized)
4065 }),
4066 surfaces,
4067 no_llm,
4068 clean,
4069 force_renarrate,
4070 concurrency,
4071 max_depth: depth,
4072 min_files,
4073 };
4074
4075 let report = pulse::site::generate_site(&cache, &config)?;
4076
4077 eprintln!("Zola project generated in {}/", report.output_dir);
4078 eprintln!(" Wiki pages: {}", report.pages_generated);
4079 eprintln!(" Changelog: {}", if report.changelog_generated { "yes" } else { "no" });
4080 eprintln!(" Map: {}", if report.map_generated { "yes" } else { "no" });
4081 eprintln!(" Onboard: {}", if report.onboard_generated { "yes" } else { "no" });
4082 eprintln!(" Timeline: {}", if report.timeline_generated { "yes" } else { "no" });
4083 eprintln!(" Glossary: {}", if report.glossary_generated { "yes" } else { "no" });
4084 eprintln!(" Explorer: {}", if report.explorer_generated { "yes" } else { "no" });
4085 eprintln!(" Narration: {}", report.narration_mode);
4086 if report.build_success {
4087 eprintln!(" Build: success (HTML in {}/public/)", report.output_dir);
4088 } else {
4089 eprintln!(" Build: skipped (run `cd {} && zola build` manually)", report.output_dir);
4090 }
4091
4092 Ok(())
4093}
4094
4095fn handle_pulse_serve(output: PathBuf, port: u16, open: bool) -> Result<()> {
4096 if !output.join("config.toml").exists() {
4098 anyhow::bail!(
4099 "No Zola project found at '{}'. Run `rfx pulse generate` first.",
4100 output.display()
4101 );
4102 }
4103
4104 let zola_path = pulse::zola::ensure_zola()?;
4105
4106 let url = format!("http://127.0.0.1:{}", port);
4107 eprintln!("Serving Pulse site at {}", url);
4108 eprintln!("Press Ctrl+C to stop.\n");
4109
4110 if open {
4111 open_browser(&url);
4112 }
4113
4114 let status = std::process::Command::new(&zola_path)
4115 .current_dir(&output)
4116 .arg("serve")
4117 .arg("--port")
4118 .arg(port.to_string())
4119 .arg("--interface")
4120 .arg("127.0.0.1")
4121 .status()
4122 .context("Failed to start Zola server")?;
4123
4124 if !status.success() {
4125 anyhow::bail!("Zola server exited with error");
4126 }
4127
4128 Ok(())
4129}
4130
4131fn open_browser(url: &str) {
4132 let result = if cfg!(target_os = "macos") {
4133 std::process::Command::new("open").arg(url).spawn()
4134 } else if cfg!(target_os = "windows") {
4135 std::process::Command::new("cmd")
4136 .args(["/c", "start", url])
4137 .spawn()
4138 } else {
4139 std::process::Command::new("xdg-open").arg(url).spawn()
4140 };
4141
4142 if let Err(e) = result {
4143 eprintln!("Could not open browser: {e}");
4144 }
4145}
4146
4147fn handle_pulse_onboard(no_llm: bool, json: bool) -> Result<()> {
4148 let cache = CacheManager::new(".");
4149 if !cache.path().exists() {
4150 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
4151 }
4152
4153 let modules = crate::pulse::wiki::detect_modules(&cache, &crate::pulse::wiki::ModuleDiscoveryConfig::default())?;
4154 let mut data = crate::pulse::onboard::generate_onboard_structural(&cache, modules.len())?;
4155
4156 if !no_llm {
4157 if let Ok(provider) = crate::pulse::narrate::create_pulse_provider() {
4158 let llm_cache = crate::pulse::llm_cache::LlmCache::new(cache.path());
4159 let ctx = crate::pulse::onboard::build_onboard_context(&data);
4160 let narration = crate::pulse::narrate::narrate_section(
4161 &*provider,
4162 crate::pulse::narrate::onboard_system_prompt(),
4163 &ctx,
4164 &llm_cache,
4165 "standalone",
4166 "onboard-guide",
4167 );
4168 data.narration = narration;
4169 }
4170 }
4171
4172 if json {
4173 let ctx = crate::pulse::onboard::build_onboard_context(&data);
4174 println!("{}", serde_json::to_string_pretty(&serde_json::json!({
4175 "entry_points": data.entry_points.iter().map(|ep| serde_json::json!({
4176 "path": ep.path,
4177 "kind": format!("{}", ep.kind),
4178 "key_symbols": ep.key_symbols,
4179 })).collect::<Vec<_>>(),
4180 "reading_order_layers": data.reading_order.layers.len(),
4181 "context": ctx,
4182 }))?);
4183 } else {
4184 let md = crate::pulse::onboard::render_onboard_markdown(&data);
4185 println!("{}", md);
4186 }
4187
4188 Ok(())
4189}
4190
4191fn handle_pulse_timeline(json: bool) -> Result<()> {
4192 let data = crate::pulse::git_intel::extract_git_intel(".")?;
4193
4194 if json {
4195 println!("{}", serde_json::to_string_pretty(&serde_json::json!({
4196 "commits": data.commits.len(),
4197 "contributors": data.contributors.iter().map(|c| serde_json::json!({
4198 "name": c.name,
4199 "email": c.email,
4200 "commit_count": c.commit_count,
4201 })).collect::<Vec<_>>(),
4202 "churn_files": data.churn.len(),
4203 "weekly_summaries": data.weekly_summaries.len(),
4204 }))?);
4205 } else {
4206 let md = crate::pulse::git_intel::render_timeline_markdown(&data);
4207 println!("{}", md);
4208 }
4209
4210 Ok(())
4211}
4212
4213fn handle_pulse_glossary(json: bool) -> Result<()> {
4214 use crate::pulse::glossary;
4215
4216 let cache = CacheManager::new(".");
4217 if !cache.path().exists() {
4218 anyhow::bail!("No .reflex cache found. Run `rfx index` first.");
4219 }
4220
4221 let evidence = glossary::collect_glossary_evidence(&cache)?;
4226
4227 let data = glossary::GlossaryData::default();
4228
4229 if json {
4230 let module_summaries = evidence
4231 .as_ref()
4232 .map(|ev| {
4233 ev.modules
4234 .iter()
4235 .map(|m| {
4236 serde_json::json!({
4237 "path": m.path,
4238 "file_count": m.file_count,
4239 "anchor_symbols": m.anchor_symbols,
4240 })
4241 })
4242 .collect::<Vec<_>>()
4243 })
4244 .unwrap_or_default();
4245
4246 println!(
4247 "{}",
4248 serde_json::to_string_pretty(&serde_json::json!({
4249 "total_concepts": data.concepts.len(),
4250 "concepts": data.concepts.iter().map(|c| serde_json::json!({
4251 "name": c.name,
4252 "category": c.category,
4253 })).collect::<Vec<_>>(),
4254 "evidence_modules": module_summaries,
4255 }))?
4256 );
4257 } else {
4258 let md = if let Some(ev) = evidence.as_ref() {
4259 glossary::render_glossary_no_llm(ev)
4260 } else {
4261 glossary::render_glossary_markdown(&data)
4262 };
4263 println!("{}", md);
4264 }
4265
4266 Ok(())
4267}
4268
4269fn handle_llm_config() -> Result<()> {
4270 crate::semantic::run_configure_wizard()
4271}
4272
4273fn handle_llm_status() -> Result<()> {
4274 use crate::semantic::config;
4275
4276 let semantic_config = config::load_config(std::path::Path::new("."))?;
4277 let provider = &semantic_config.provider;
4278
4279 let model = if let Some(ref m) = semantic_config.model {
4280 m.clone()
4281 } else {
4282 config::get_user_model(provider)
4283 .unwrap_or_else(|| "(provider default)".to_string())
4284 };
4285
4286 let key_status = match config::get_api_key(provider) {
4287 Ok(key) => {
4288 if key.len() > 8 {
4289 format!("configured ({}...****)", &key[..8])
4290 } else {
4291 "configured".to_string()
4292 }
4293 }
4294 Err(_) => "not configured".to_string(),
4295 };
4296
4297 println!("Provider: {}", provider);
4298 println!("Model: {}", model);
4299 println!("API key: {}", key_status);
4300
4301 Ok(())
4302}
4303