reflex/
cli.rs

1//! CLI argument parsing and command handlers
2
3use anyhow::{Context, Result};
4use clap::{CommandFactory, Parser, Subcommand};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7use std::time::Instant;
8use indicatif::{ProgressBar, ProgressStyle};
9use owo_colors::OwoColorize;
10
11use crate::cache::CacheManager;
12use crate::indexer::Indexer;
13use crate::models::{IndexConfig, Language};
14use crate::output;
15use crate::query::{QueryEngine, QueryFilter};
16
17/// Reflex: Local-first, structure-aware code search for AI agents
18#[derive(Parser, Debug)]
19#[command(
20    name = "rfx",
21    version,
22    about = "A fast, deterministic code search engine built for AI",
23    long_about = "Reflex is a local-first, structure-aware code search engine that returns \
24                  structured results (symbols, spans, scopes) with sub-100ms latency. \
25                  Designed for AI coding agents and automation."
26)]
27pub struct Cli {
28    /// Enable verbose logging (can be repeated for more verbosity)
29    #[arg(short, long, action = clap::ArgAction::Count)]
30    pub verbose: u8,
31
32    #[command(subcommand)]
33    pub command: Option<Command>,
34}
35
36#[derive(Subcommand, Debug)]
37pub enum IndexSubcommand {
38    /// Show background symbol indexing status
39    Status,
40
41    /// Compact the cache by removing deleted files
42    ///
43    /// Removes files from the cache that no longer exist on disk and reclaims
44    /// disk space using SQLite VACUUM. This operation is also performed automatically
45    /// in the background every 24 hours during normal usage.
46    ///
47    /// Examples:
48    ///   rfx index compact                # Show compaction results
49    ///   rfx index compact --json         # JSON output
50    Compact {
51        /// Output format as JSON
52        #[arg(long)]
53        json: bool,
54
55        /// Pretty-print JSON output (only with --json)
56        #[arg(long)]
57        pretty: bool,
58    },
59}
60
61#[derive(Subcommand, Debug)]
62pub enum Command {
63    /// Build or update the local code index
64    Index {
65        /// Directory to index (defaults to current directory)
66        #[arg(value_name = "PATH", default_value = ".")]
67        path: PathBuf,
68
69        /// Force full rebuild (ignore incremental cache)
70        #[arg(short, long)]
71        force: bool,
72
73        /// Languages to include (empty = all)
74        #[arg(short, long, value_delimiter = ',')]
75        languages: Vec<String>,
76
77        /// Suppress all output (no progress bar, no summary)
78        #[arg(short, long)]
79        quiet: bool,
80
81        /// Subcommand (status, compact)
82        #[command(subcommand)]
83        command: Option<IndexSubcommand>,
84    },
85
86    /// Query the code index
87    ///
88    /// If no pattern is provided, launches interactive mode (TUI).
89    ///
90    /// Search modes:
91    ///   - Default: Word-boundary matching (precise, finds complete identifiers)
92    ///     Example: rfx query "Error" → finds "Error" but not "NetworkError"
93    ///     Example: rfx query "test" → finds "test" but not "test_helper"
94    ///
95    ///   - Symbol search: Word-boundary for text, exact match for symbols
96    ///     Example: rfx query "parse" --symbols → finds only "parse" function/class
97    ///     Example: rfx query "parse" --kind function → finds only "parse" functions
98    ///
99    ///   - Substring search: Expansive matching (opt-in with --contains)
100    ///     Example: rfx query "mb" --contains → finds "mb", "kmb_dai_ops", "symbol", etc.
101    ///
102    ///   - Regex search: Pattern-controlled matching (opt-in with --regex)
103    ///     Example: rfx query "^mb_.*" --regex → finds "mb_init", "mb_start", etc.
104    ///
105    /// Interactive mode:
106    ///   - Launch with: rfx query
107    ///   - Search, filter, and navigate code results in a live TUI
108    ///   - Press '?' for help, 'q' to quit
109    Query {
110        /// Search pattern (omit to launch interactive mode)
111        pattern: Option<String>,
112
113        /// Search symbol definitions only (functions, classes, etc.)
114        #[arg(short, long)]
115        symbols: bool,
116
117        /// Filter by language
118        /// Supported: rust, python, javascript, typescript, vue, svelte, go, java, php, c, c++, c#, ruby, kotlin, zig
119        #[arg(short, long)]
120        lang: Option<String>,
121
122        /// Filter by symbol kind (implies --symbols)
123        /// Supported: function, class, struct, enum, interface, trait, constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute
124        #[arg(short, long)]
125        kind: Option<String>,
126
127        /// Use AST pattern matching (SLOW: 500ms-2s+, scans all files)
128        ///
129        /// WARNING: AST queries bypass trigram optimization and scan the entire codebase.
130        /// In 95% of cases, use --symbols instead which is 10-100x faster.
131        ///
132        /// When --ast is set, the pattern parameter is interpreted as a Tree-sitter
133        /// S-expression query instead of text search.
134        ///
135        /// RECOMMENDED: Always use --glob to limit scope for better performance.
136        ///
137        /// Examples:
138        ///   Fast (2-50ms):    rfx query "fetch" --symbols --kind function --lang python
139        ///   Slow (500ms-2s):  rfx query "(function_definition) @fn" --ast --lang python
140        ///   Faster with glob: rfx query "(class_declaration) @class" --ast --lang typescript --glob "src/**/*.ts"
141        #[arg(long)]
142        ast: bool,
143
144        /// Use regex pattern matching
145        ///
146        /// Enables standard regex syntax in the search pattern:
147        ///   |  for alternation (OR) - NO backslash needed
148        ///   .  matches any character
149        ///   .*  matches zero or more characters
150        ///   ^  anchors to start of line
151        ///   $  anchors to end of line
152        ///
153        /// Examples:
154        ///   --regex "belongsTo|hasMany"       Match belongsTo OR hasMany
155        ///   --regex "^import.*from"           Lines starting with import...from
156        ///   --regex "fn.*test"                Functions containing 'test'
157        ///
158        /// Note: Cannot be combined with --contains (mutually exclusive)
159        #[arg(short = 'r', long)]
160        regex: bool,
161
162        /// Output format as JSON
163        #[arg(long)]
164        json: bool,
165
166        /// Pretty-print JSON output (only with --json)
167        /// By default, JSON is minified to reduce token usage
168        #[arg(long)]
169        pretty: bool,
170
171        /// AI-optimized mode: returns JSON with ai_instruction field
172        /// Implies --json (minified by default, use --pretty for formatted output)
173        /// Provides context-aware guidance to AI agents on response format and next actions
174        #[arg(long)]
175        ai: bool,
176
177        /// Maximum number of results
178        #[arg(short = 'n', long)]
179        limit: Option<usize>,
180
181        /// Pagination offset (skip first N results after sorting)
182        /// Use with --limit for pagination: --offset 0 --limit 10, then --offset 10 --limit 10
183        #[arg(short = 'o', long)]
184        offset: Option<usize>,
185
186        /// Show full symbol definition (entire function/class body)
187        /// Only applicable to symbol searches
188        #[arg(long)]
189        expand: bool,
190
191        /// Filter by file path (supports substring matching)
192        /// Example: --file math.rs or --file helpers/
193        #[arg(short = 'f', long)]
194        file: Option<String>,
195
196        /// Exact symbol name match (no substring matching)
197        /// Only applicable to symbol searches
198        #[arg(long)]
199        exact: bool,
200
201        /// Use substring matching for both text and symbols (expansive search)
202        ///
203        /// Default behavior uses word-boundary matching for precision:
204        ///   "Error" matches "Error" but not "NetworkError"
205        ///
206        /// With --contains, enables substring matching (expansive):
207        ///   "Error" matches "Error", "NetworkError", "error_handler", etc.
208        ///
209        /// Use cases:
210        ///   - Finding partial matches: --contains "partial"
211        ///   - When you're unsure of exact names
212        ///   - Exploratory searches
213        ///
214        /// Note: Cannot be combined with --regex or --exact (mutually exclusive)
215        #[arg(long)]
216        contains: bool,
217
218        /// Only show count and timing, not the actual results
219        #[arg(short, long)]
220        count: bool,
221
222        /// Query timeout in seconds (0 = no timeout, default: 30)
223        #[arg(short = 't', long, default_value = "30")]
224        timeout: u64,
225
226        /// Use plain text output (disable colors and syntax highlighting)
227        #[arg(long)]
228        plain: bool,
229
230        /// Include files matching glob pattern (can be repeated)
231        ///
232        /// Pattern syntax (NO shell quotes in the pattern itself):
233        ///   ** = recursive match (all subdirectories)
234        ///   *  = single level match (one directory)
235        ///
236        /// Examples:
237        ///   --glob src/**/*.rs          All .rs files under src/ (recursive)
238        ///   --glob app/Models/*.php     PHP files directly in Models/ (not subdirs)
239        ///   --glob tests/**/*_test.go   All test files under tests/
240        ///
241        /// Tip: Use --file for simple substring matching instead:
242        ///   --file User.php             Simpler than --glob **/User.php
243        #[arg(short = 'g', long)]
244        glob: Vec<String>,
245
246        /// Exclude files matching glob pattern (can be repeated)
247        ///
248        /// Same syntax as --glob (** for recursive, * for single level)
249        ///
250        /// Examples:
251        ///   --exclude target/**         Exclude all files under target/
252        ///   --exclude **/*.gen.rs       Exclude generated Rust files
253        ///   --exclude node_modules/**   Exclude npm dependencies
254        #[arg(short = 'x', long)]
255        exclude: Vec<String>,
256
257        /// Return only unique file paths (no line numbers or content)
258        /// Compatible with --json to output ["path1", "path2", ...]
259        #[arg(short = 'p', long)]
260        paths: bool,
261
262        /// Disable smart preview truncation (show full lines)
263        /// By default, previews are truncated to ~100 chars to reduce token usage
264        #[arg(long)]
265        no_truncate: bool,
266
267        /// Return all results (no limit)
268        /// Equivalent to --limit 0, convenience flag for getting unlimited results
269        #[arg(short = 'a', long)]
270        all: bool,
271
272        /// Force execution of potentially expensive queries
273        /// Bypasses broad query detection that prevents queries with:
274        /// • Short patterns (< 3 characters)
275        /// • High candidate counts (> 5,000 files for symbol/AST queries)
276        /// • AST queries without --glob restrictions
277        #[arg(long)]
278        force: bool,
279
280        /// Include dependency information (imports) in results
281        /// Currently only available for Rust files
282        #[arg(long)]
283        dependencies: bool,
284    },
285
286    /// Start a local HTTP API server
287    Serve {
288        /// Port to listen on
289        #[arg(short, long, default_value = "7878")]
290        port: u16,
291
292        /// Host to bind to
293        #[arg(long, default_value = "127.0.0.1")]
294        host: String,
295    },
296
297    /// Show index statistics and cache information
298    Stats {
299        /// Output format as JSON
300        #[arg(long)]
301        json: bool,
302
303        /// Pretty-print JSON output (only with --json)
304        #[arg(long)]
305        pretty: bool,
306    },
307
308    /// Clear the local cache
309    Clear {
310        /// Skip confirmation prompt
311        #[arg(short, long)]
312        yes: bool,
313    },
314
315    /// List all indexed files
316    ListFiles {
317        /// Output format as JSON
318        #[arg(long)]
319        json: bool,
320
321        /// Pretty-print JSON output (only with --json)
322        #[arg(long)]
323        pretty: bool,
324    },
325
326    /// Watch for file changes and auto-reindex
327    ///
328    /// Continuously monitors the workspace for changes and automatically
329    /// triggers incremental reindexing. Useful for IDE integrations and
330    /// keeping the index always fresh during active development.
331    ///
332    /// The debounce timer resets on every file change, batching rapid edits
333    /// (e.g., multi-file refactors, format-on-save) into a single reindex.
334    Watch {
335        /// Directory to watch (defaults to current directory)
336        #[arg(value_name = "PATH", default_value = ".")]
337        path: PathBuf,
338
339        /// Debounce duration in milliseconds (default: 15000 = 15s)
340        /// Waits this long after the last change before reindexing
341        /// Valid range: 5000-30000 (5-30 seconds)
342        #[arg(short, long, default_value = "15000")]
343        debounce: u64,
344
345        /// Suppress output (only log errors)
346        #[arg(short, long)]
347        quiet: bool,
348    },
349
350    /// Start MCP server for AI agent integration
351    ///
352    /// Runs Reflex as a Model Context Protocol (MCP) server using stdio transport.
353    /// This command is automatically invoked by MCP clients like Claude Code and
354    /// should not be run manually.
355    ///
356    /// Configuration example for Claude Code (~/.claude/claude_code_config.json):
357    /// {
358    ///   "mcpServers": {
359    ///     "reflex": {
360    ///       "type": "stdio",
361    ///       "command": "rfx",
362    ///       "args": ["mcp"]
363    ///     }
364    ///   }
365    /// }
366    Mcp,
367
368    /// Analyze codebase structure and dependencies
369    ///
370    /// Perform graph-wide dependency analysis to understand code architecture.
371    /// By default, shows a summary report with counts. Use specific flags for
372    /// detailed results.
373    ///
374    /// Examples:
375    ///   rfx analyze                                # Summary report
376    ///   rfx analyze --circular                     # Find cycles
377    ///   rfx analyze --hotspots                     # Most-imported files
378    ///   rfx analyze --hotspots --min-dependents 5  # Filter by minimum
379    ///   rfx analyze --unused                       # Orphaned files
380    ///   rfx analyze --islands                      # Disconnected components
381    ///   rfx analyze --hotspots --count             # Just show count
382    ///   rfx analyze --circular --glob "src/**"     # Limit to src/
383    Analyze {
384        /// Show circular dependencies
385        #[arg(long)]
386        circular: bool,
387
388        /// Show most-imported files (hotspots)
389        #[arg(long)]
390        hotspots: bool,
391
392        /// Minimum number of dependents for hotspots (default: 2)
393        #[arg(long, default_value = "2", requires = "hotspots")]
394        min_dependents: usize,
395
396        /// Show unused/orphaned files
397        #[arg(long)]
398        unused: bool,
399
400        /// Show disconnected components (islands)
401        #[arg(long)]
402        islands: bool,
403
404        /// Minimum island size (default: 2)
405        #[arg(long, default_value = "2", requires = "islands")]
406        min_island_size: usize,
407
408        /// Maximum island size (default: 500 or 50% of total files)
409        #[arg(long, requires = "islands")]
410        max_island_size: Option<usize>,
411
412        /// Output format: tree (default), table, dot
413        #[arg(short = 'f', long, default_value = "tree")]
414        format: String,
415
416        /// Output as JSON
417        #[arg(long)]
418        json: bool,
419
420        /// Pretty-print JSON output
421        #[arg(long)]
422        pretty: bool,
423
424        /// Only show count and timing, not the actual results
425        #[arg(short, long)]
426        count: bool,
427
428        /// Return all results (no limit)
429        /// Equivalent to --limit 0, convenience flag for unlimited results
430        #[arg(short = 'a', long)]
431        all: bool,
432
433        /// Use plain text output (disable colors and syntax highlighting)
434        #[arg(long)]
435        plain: bool,
436
437        /// Include files matching glob pattern (can be repeated)
438        /// Example: --glob "src/**/*.rs" --glob "tests/**/*.rs"
439        #[arg(short = 'g', long)]
440        glob: Vec<String>,
441
442        /// Exclude files matching glob pattern (can be repeated)
443        /// Example: --exclude "target/**" --exclude "*.gen.rs"
444        #[arg(short = 'x', long)]
445        exclude: Vec<String>,
446
447        /// Force execution of potentially expensive queries
448        /// Bypasses broad query detection
449        #[arg(long)]
450        force: bool,
451
452        /// Maximum number of results
453        #[arg(short = 'n', long)]
454        limit: Option<usize>,
455
456        /// Pagination offset
457        #[arg(short = 'o', long)]
458        offset: Option<usize>,
459
460        /// Sort order for results: asc (ascending) or desc (descending)
461        /// Applies to --hotspots (by import_count), --islands (by size), --circular (by cycle length)
462        /// Default: desc (most important first)
463        #[arg(long)]
464        sort: Option<String>,
465    },
466
467    /// Analyze dependencies for a specific file
468    ///
469    /// Show dependencies and dependents for a single file.
470    /// For graph-wide analysis, use 'rfx analyze' instead.
471    ///
472    /// Examples:
473    ///   rfx deps src/main.rs                  # Show dependencies
474    ///   rfx deps src/config.rs --reverse      # Show dependents
475    ///   rfx deps src/api.rs --depth 3         # Transitive deps
476    Deps {
477        /// File path to analyze
478        file: PathBuf,
479
480        /// Show files that depend on this file (reverse lookup)
481        #[arg(short, long)]
482        reverse: bool,
483
484        /// Traversal depth for transitive dependencies (default: 1)
485        #[arg(short, long, default_value = "1")]
486        depth: usize,
487
488        /// Output format: tree (default), table, dot
489        #[arg(short = 'f', long, default_value = "tree")]
490        format: String,
491
492        /// Output as JSON
493        #[arg(long)]
494        json: bool,
495
496        /// Pretty-print JSON output
497        #[arg(long)]
498        pretty: bool,
499    },
500
501    /// Ask a natural language question and generate search queries
502    ///
503    /// Uses an LLM to translate natural language questions into `rfx query` commands.
504    /// Requires API key configuration for one of: OpenAI, Anthropic, or Groq.
505    ///
506    /// If no question is provided, launches interactive chat mode by default.
507    ///
508    /// Configuration:
509    ///   1. Run interactive setup wizard (recommended):
510    ///      rfx ask --configure
511    ///
512    ///   2. OR set API key via environment variable:
513    ///      - OPENAI_API_KEY, ANTHROPIC_API_KEY, or GROQ_API_KEY
514    ///
515    ///   3. Optional: Configure provider in .reflex/config.toml:
516    ///      [semantic]
517    ///      provider = "groq"  # or openai, anthropic
518    ///      model = "llama-3.3-70b-versatile"  # optional, defaults to provider default
519    ///
520    /// Examples:
521    ///   rfx ask --configure                           # Interactive setup wizard
522    ///   rfx ask                                       # Launch interactive chat (default)
523    ///   rfx ask "Find all TODOs in Rust files"
524    ///   rfx ask "Where is the main function defined?" --execute
525    ///   rfx ask "Show me error handling code" --provider groq
526    Ask {
527        /// Natural language question
528        question: Option<String>,
529
530        /// Execute queries immediately without confirmation
531        #[arg(short, long)]
532        execute: bool,
533
534        /// Override configured LLM provider (openai, anthropic, groq)
535        #[arg(short, long)]
536        provider: Option<String>,
537
538        /// Output format as JSON
539        #[arg(long)]
540        json: bool,
541
542        /// Pretty-print JSON output (only with --json)
543        #[arg(long)]
544        pretty: bool,
545
546        /// Additional context to inject into prompt (e.g., from `rfx context`)
547        #[arg(long)]
548        additional_context: Option<String>,
549
550        /// Launch interactive configuration wizard to set up AI provider and API key
551        #[arg(long)]
552        configure: bool,
553
554        /// Enable agentic mode (multi-step reasoning with context gathering)
555        #[arg(long)]
556        agentic: bool,
557
558        /// Maximum iterations for query refinement in agentic mode (default: 2)
559        #[arg(long, default_value = "2")]
560        max_iterations: usize,
561
562        /// Skip result evaluation in agentic mode
563        #[arg(long)]
564        no_eval: bool,
565
566        /// Show LLM reasoning blocks at each phase (agentic mode only)
567        #[arg(long)]
568        show_reasoning: bool,
569
570        /// Verbose output: show tool results and details (agentic mode only)
571        #[arg(long)]
572        verbose: bool,
573
574        /// Quiet mode: suppress progress output (agentic mode only)
575        #[arg(long)]
576        quiet: bool,
577
578        /// Generate a conversational answer based on search results
579        #[arg(long)]
580        answer: bool,
581
582        /// Launch interactive chat mode (TUI) with conversation history
583        #[arg(short = 'i', long)]
584        interactive: bool,
585
586        /// Debug mode: output full LLM prompts and retain terminal history
587        #[arg(long)]
588        debug: bool,
589    },
590
591    /// Generate codebase context for AI prompts
592    ///
593    /// Provides structural and organizational context about the project to help
594    /// LLMs understand project layout. Use with `rfx ask --additional-context`.
595    ///
596    /// By default (no flags), shows all context types. Use individual flags to
597    /// select specific context types.
598    ///
599    /// Examples:
600    ///   rfx context                                    # Full context (all types)
601    ///   rfx context --path services/backend            # Full context for monorepo subdirectory
602    ///   rfx context --framework --entry-points         # Specific context types only
603    ///   rfx context --structure --depth 5              # Deep directory tree
604    ///
605    ///   # Use with semantic queries
606    ///   rfx ask "find auth" --additional-context "$(rfx context --framework)"
607    Context {
608        /// Show directory structure (enabled by default)
609        #[arg(long)]
610        structure: bool,
611
612        /// Focus on specific directory path
613        #[arg(short, long)]
614        path: Option<String>,
615
616        /// Show file type distribution (enabled by default)
617        #[arg(long)]
618        file_types: bool,
619
620        /// Detect project type (CLI/library/webapp/monorepo)
621        #[arg(long)]
622        project_type: bool,
623
624        /// Detect frameworks and conventions
625        #[arg(long)]
626        framework: bool,
627
628        /// Show entry point files
629        #[arg(long)]
630        entry_points: bool,
631
632        /// Show test organization pattern
633        #[arg(long)]
634        test_layout: bool,
635
636        /// List important configuration files
637        #[arg(long)]
638        config_files: bool,
639
640        /// Tree depth for --structure (default: 1)
641        #[arg(long, default_value = "1")]
642        depth: usize,
643
644        /// Output as JSON
645        #[arg(long)]
646        json: bool,
647    },
648
649    /// Internal command: Run background symbol indexing (hidden from help)
650    #[command(hide = true)]
651    IndexSymbolsInternal {
652        /// Cache directory path
653        cache_dir: PathBuf,
654    },
655}
656
657/// Try to run background cache compaction if needed
658///
659/// Checks if 24+ hours have passed since last compaction.
660/// If yes, spawns a non-blocking background thread to compact the cache.
661/// Main command continues immediately without waiting for compaction.
662///
663/// Compaction is skipped for commands that don't need it:
664/// - Clear (will delete the cache anyway)
665/// - Mcp (long-running server process)
666/// - Watch (long-running watcher process)
667/// - Serve (long-running HTTP server)
668fn try_background_compact(cache: &CacheManager, command: &Command) {
669    // Skip compaction for certain commands
670    match command {
671        Command::Clear { .. } => {
672            log::debug!("Skipping compaction for Clear command");
673            return;
674        }
675        Command::Mcp => {
676            log::debug!("Skipping compaction for Mcp command");
677            return;
678        }
679        Command::Watch { .. } => {
680            log::debug!("Skipping compaction for Watch command");
681            return;
682        }
683        Command::Serve { .. } => {
684            log::debug!("Skipping compaction for Serve command");
685            return;
686        }
687        _ => {}
688    }
689
690    // Check if compaction should run
691    let should_compact = match cache.should_compact() {
692        Ok(true) => true,
693        Ok(false) => {
694            log::debug!("Compaction not needed yet (last run <24h ago)");
695            return;
696        }
697        Err(e) => {
698            log::warn!("Failed to check compaction status: {}", e);
699            return;
700        }
701    };
702
703    if !should_compact {
704        return;
705    }
706
707    log::info!("Starting background cache compaction...");
708
709    // Clone cache path for background thread
710    let cache_path = cache.path().to_path_buf();
711
712    // Spawn background thread for compaction
713    std::thread::spawn(move || {
714        let cache = CacheManager::new(cache_path.parent().expect("Cache should have parent directory"));
715
716        match cache.compact() {
717            Ok(report) => {
718                log::info!(
719                    "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
720                    report.files_removed,
721                    report.space_saved_bytes as f64 / 1_048_576.0,
722                    report.duration_ms
723                );
724            }
725            Err(e) => {
726                log::warn!("Background compaction failed: {}", e);
727            }
728        }
729    });
730
731    log::debug!("Background compaction thread spawned - main command continuing");
732}
733
734impl Cli {
735    /// Execute the CLI command
736    pub fn execute(self) -> Result<()> {
737        // Setup logging based on verbosity
738        let log_level = match self.verbose {
739            0 => "warn",   // Default: only warnings and errors
740            1 => "info",   // -v: show info messages
741            2 => "debug",  // -vv: show debug messages
742            _ => "trace",  // -vvv: show trace messages
743        };
744        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
745            .init();
746
747        // Try background compaction (non-blocking) before command execution
748        if let Some(ref command) = self.command {
749            // Use current directory as default cache location
750            let cache = CacheManager::new(".");
751            try_background_compact(&cache, command);
752        }
753
754        // Execute the subcommand, or show help if no command provided
755        match self.command {
756            None => {
757                // No subcommand: show help
758                Cli::command().print_help()?;
759                println!();  // Add newline after help
760                Ok(())
761            }
762            Some(Command::Index { path, force, languages, quiet, command }) => {
763                match command {
764                    None => {
765                        // Default: run index build
766                        handle_index_build(&path, &force, &languages, &quiet)
767                    }
768                    Some(IndexSubcommand::Status) => {
769                        handle_index_status()
770                    }
771                    Some(IndexSubcommand::Compact { json, pretty }) => {
772                        handle_index_compact(&json, &pretty)
773                    }
774                }
775            }
776            Some(Command::Query { pattern, symbols, lang, kind, ast, regex, json, pretty, ai, limit, offset, expand, file, exact, contains, count, timeout, plain, glob, exclude, paths, no_truncate, all, force, dependencies }) => {
777                // If no pattern provided, launch interactive mode
778                match pattern {
779                    None => handle_interactive(),
780                    Some(pattern) => handle_query(pattern, symbols, lang, kind, ast, regex, json, pretty, ai, limit, offset, expand, file, exact, contains, count, timeout, plain, glob, exclude, paths, no_truncate, all, force, dependencies)
781                }
782            }
783            Some(Command::Serve { port, host }) => {
784                handle_serve(port, host)
785            }
786            Some(Command::Stats { json, pretty }) => {
787                handle_stats(json, pretty)
788            }
789            Some(Command::Clear { yes }) => {
790                handle_clear(yes)
791            }
792            Some(Command::ListFiles { json, pretty }) => {
793                handle_list_files(json, pretty)
794            }
795            Some(Command::Watch { path, debounce, quiet }) => {
796                handle_watch(path, debounce, quiet)
797            }
798            Some(Command::Mcp) => {
799                handle_mcp()
800            }
801            Some(Command::Analyze { circular, hotspots, min_dependents, unused, islands, min_island_size, max_island_size, format, json, pretty, count, all, plain, glob, exclude, force, limit, offset, sort }) => {
802                handle_analyze(circular, hotspots, min_dependents, unused, islands, min_island_size, max_island_size, format, json, pretty, count, all, plain, glob, exclude, force, limit, offset, sort)
803            }
804            Some(Command::Deps { file, reverse, depth, format, json, pretty }) => {
805                handle_deps(file, reverse, depth, format, json, pretty)
806            }
807            Some(Command::Ask { question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug }) => {
808                handle_ask(question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug)
809            }
810            Some(Command::Context { structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json }) => {
811                handle_context(structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json)
812            }
813            Some(Command::IndexSymbolsInternal { cache_dir }) => {
814                handle_index_symbols_internal(cache_dir)
815            }
816        }
817    }
818}
819
820/// Handle the `index status` subcommand
821fn handle_index_status() -> Result<()> {
822    log::info!("Checking background symbol indexing status");
823
824    let cache = CacheManager::new(".");
825    let cache_path = cache.path().to_path_buf();
826
827    match crate::background_indexer::BackgroundIndexer::get_status(&cache_path) {
828            Ok(Some(status)) => {
829                println!("Background Symbol Indexing Status");
830                println!("==================================");
831                println!("State:           {:?}", status.state);
832                println!("Total files:     {}", status.total_files);
833                println!("Processed:       {}", status.processed_files);
834                println!("Cached:          {}", status.cached_files);
835                println!("Parsed:          {}", status.parsed_files);
836                println!("Failed:          {}", status.failed_files);
837                println!("Started:         {}", status.started_at);
838                println!("Last updated:    {}", status.updated_at);
839
840                if let Some(completed_at) = &status.completed_at {
841                    println!("Completed:       {}", completed_at);
842                }
843
844                if let Some(error) = &status.error {
845                    println!("Error:           {}", error);
846                }
847
848                // Show progress percentage if running
849                if status.state == crate::background_indexer::IndexerState::Running && status.total_files > 0 {
850                    let progress = (status.processed_files as f64 / status.total_files as f64) * 100.0;
851                    println!("\nProgress:        {:.1}%", progress);
852                }
853
854                Ok(())
855            }
856            Ok(None) => {
857                println!("No background symbol indexing in progress.");
858                println!("\nRun 'rfx index' to start background symbol indexing.");
859                Ok(())
860            }
861            Err(e) => {
862                anyhow::bail!("Failed to get indexing status: {}", e);
863            }
864        }
865    }
866
867/// Handle the `index compact` subcommand
868fn handle_index_compact(json: &bool, pretty: &bool) -> Result<()> {
869    log::info!("Running cache compaction");
870
871    let cache = CacheManager::new(".");
872    let report = cache.compact()?;
873
874    // Output results in requested format
875    if *json {
876        let json_str = if *pretty {
877            serde_json::to_string_pretty(&report)?
878        } else {
879            serde_json::to_string(&report)?
880        };
881        println!("{}", json_str);
882    } else {
883        println!("Cache Compaction Complete");
884        println!("=========================");
885        println!("Files removed:    {}", report.files_removed);
886        println!("Space saved:      {:.2} MB", report.space_saved_bytes as f64 / 1_048_576.0);
887        println!("Duration:         {}ms", report.duration_ms);
888    }
889
890    Ok(())
891}
892
893fn handle_index_build(path: &PathBuf, force: &bool, languages: &[String], quiet: &bool) -> Result<()> {
894    log::info!("Starting index build");
895
896    let cache = CacheManager::new(path);
897    let cache_path = cache.path().to_path_buf();
898
899    if *force {
900        log::info!("Force rebuild requested, clearing existing cache");
901        cache.clear()?;
902    }
903
904    // Parse language filters
905    let lang_filters: Vec<Language> = languages
906        .iter()
907        .filter_map(|s| match s.to_lowercase().as_str() {
908            "rust" | "rs" => Some(Language::Rust),
909            "python" | "py" => Some(Language::Python),
910            "javascript" | "js" => Some(Language::JavaScript),
911            "typescript" | "ts" => Some(Language::TypeScript),
912            "go" => Some(Language::Go),
913            "java" => Some(Language::Java),
914            "php" => Some(Language::PHP),
915            "c" => Some(Language::C),
916            "cpp" | "c++" => Some(Language::Cpp),
917            _ => {
918                output::warn(&format!("Unknown language: {}", s));
919                None
920            }
921        })
922        .collect();
923
924    let config = IndexConfig {
925        languages: lang_filters,
926        ..Default::default()
927    };
928
929    let indexer = Indexer::new(cache, config);
930    // Show progress by default, unless quiet mode is enabled
931    let show_progress = !quiet;
932    let stats = indexer.index(path, show_progress)?;
933
934    // In quiet mode, suppress all output
935    if !quiet {
936        println!("Indexing complete!");
937        println!("  Files indexed: {}", stats.total_files);
938        println!("  Cache size: {}", format_bytes(stats.index_size_bytes));
939        println!("  Last updated: {}", stats.last_updated);
940
941        // Display language breakdown if we have indexed files
942        if !stats.files_by_language.is_empty() {
943            println!("\nFiles by language:");
944
945            // Sort languages by count (descending) for consistent output
946            let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
947            lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
948
949            // Calculate column widths
950            let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
951            let lang_width = max_lang_len.max(8); // At least "Language" header width
952
953            // Print table header
954            println!("  {:<width$}  Files  Lines", "Language", width = lang_width);
955            println!("  {}  -----  -------", "-".repeat(lang_width));
956
957            // Print rows
958            for (language, file_count) in lang_vec {
959                let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
960                println!("  {:<width$}  {:5}  {:7}",
961                    language, file_count, line_count,
962                    width = lang_width);
963            }
964        }
965    }
966
967    // Start background symbol indexing (if not already running)
968    if !crate::background_indexer::BackgroundIndexer::is_running(&cache_path) {
969        if !quiet {
970            println!("\nStarting background symbol indexing...");
971            println!("  Symbols will be cached for faster queries");
972            println!("  Check status with: rfx index status");
973        }
974
975        // Spawn detached background process for symbol indexing
976        // Pass the workspace root, not the .reflex directory
977        let current_exe = std::env::current_exe()
978            .context("Failed to get current executable path")?;
979
980        #[cfg(unix)]
981        {
982            std::process::Command::new(&current_exe)
983                .arg("index-symbols-internal")
984                .arg(path)
985                .stdin(std::process::Stdio::null())
986                .stdout(std::process::Stdio::null())
987                .stderr(std::process::Stdio::null())
988                .spawn()
989                .context("Failed to spawn background indexing process")?;
990        }
991
992        #[cfg(windows)]
993        {
994            use std::os::windows::process::CommandExt;
995            const CREATE_NO_WINDOW: u32 = 0x08000000;
996
997            std::process::Command::new(&current_exe)
998                .arg("index-symbols-internal")
999                .arg(&path)
1000                .creation_flags(CREATE_NO_WINDOW)
1001                .stdin(std::process::Stdio::null())
1002                .stdout(std::process::Stdio::null())
1003                .stderr(std::process::Stdio::null())
1004                .spawn()
1005                .context("Failed to spawn background indexing process")?;
1006        }
1007
1008        log::debug!("Spawned background symbol indexing process");
1009    } else if !quiet {
1010        println!("\n⚠️  Background symbol indexing already in progress");
1011        println!("  Check status with: rfx index status");
1012    }
1013
1014    Ok(())
1015}
1016
1017/// Format bytes into human-readable size (KB, MB, GB, etc.)
1018fn format_bytes(bytes: u64) -> String {
1019    const KB: u64 = 1024;
1020    const MB: u64 = KB * 1024;
1021    const GB: u64 = MB * 1024;
1022    const TB: u64 = GB * 1024;
1023
1024    if bytes >= TB {
1025        format!("{:.2} TB", bytes as f64 / TB as f64)
1026    } else if bytes >= GB {
1027        format!("{:.2} GB", bytes as f64 / GB as f64)
1028    } else if bytes >= MB {
1029        format!("{:.2} MB", bytes as f64 / MB as f64)
1030    } else if bytes >= KB {
1031        format!("{:.2} KB", bytes as f64 / KB as f64)
1032    } else {
1033        format!("{} bytes", bytes)
1034    }
1035}
1036
1037/// Smart truncate preview to reduce token usage
1038/// Truncates at word boundary if possible, adds ellipsis if truncated
1039pub fn truncate_preview(preview: &str, max_length: usize) -> String {
1040    if preview.len() <= max_length {
1041        return preview.to_string();
1042    }
1043
1044    // Find a good break point (prefer word boundary)
1045    let truncate_at = preview.char_indices()
1046        .take(max_length)
1047        .filter(|(_, c)| c.is_whitespace())
1048        .last()
1049        .map(|(i, _)| i)
1050        .unwrap_or(max_length.min(preview.len()));
1051
1052    let mut truncated = preview[..truncate_at].to_string();
1053    truncated.push('…');
1054    truncated
1055}
1056
1057/// Handle the `query` subcommand
1058fn handle_query(
1059    pattern: String,
1060    symbols_flag: bool,
1061    lang: Option<String>,
1062    kind_str: Option<String>,
1063    use_ast: bool,
1064    use_regex: bool,
1065    as_json: bool,
1066    pretty_json: bool,
1067    ai_mode: bool,
1068    limit: Option<usize>,
1069    offset: Option<usize>,
1070    expand: bool,
1071    file_pattern: Option<String>,
1072    exact: bool,
1073    use_contains: bool,
1074    count_only: bool,
1075    timeout_secs: u64,
1076    plain: bool,
1077    glob_patterns: Vec<String>,
1078    exclude_patterns: Vec<String>,
1079    paths_only: bool,
1080    no_truncate: bool,
1081    all: bool,
1082    force: bool,
1083    include_dependencies: bool,
1084) -> Result<()> {
1085    log::info!("Starting query command");
1086
1087    // AI mode implies JSON output
1088    let as_json = as_json || ai_mode;
1089
1090    let cache = CacheManager::new(".");
1091    let engine = QueryEngine::new(cache);
1092
1093    // Parse and validate language filter
1094    let language = if let Some(lang_str) = lang.as_deref() {
1095        match lang_str.to_lowercase().as_str() {
1096            "rust" | "rs" => Some(Language::Rust),
1097            "python" | "py" => Some(Language::Python),
1098            "javascript" | "js" => Some(Language::JavaScript),
1099            "typescript" | "ts" => Some(Language::TypeScript),
1100            "vue" => Some(Language::Vue),
1101            "svelte" => Some(Language::Svelte),
1102            "go" => Some(Language::Go),
1103            "java" => Some(Language::Java),
1104            "php" => Some(Language::PHP),
1105            "c" => Some(Language::C),
1106            "cpp" | "c++" => Some(Language::Cpp),
1107            "csharp" | "cs" | "c#" => Some(Language::CSharp),
1108            "ruby" | "rb" => Some(Language::Ruby),
1109            "kotlin" | "kt" => Some(Language::Kotlin),
1110            "zig" => Some(Language::Zig),
1111            _ => {
1112                anyhow::bail!(
1113                    "Unknown language: '{}'\n\
1114                     \n\
1115                     Supported languages:\n\
1116                     • rust, rs\n\
1117                     • python, py\n\
1118                     • javascript, js\n\
1119                     • typescript, ts\n\
1120                     • vue\n\
1121                     • svelte\n\
1122                     • go\n\
1123                     • java\n\
1124                     • php\n\
1125                     • c\n\
1126                     • c++, cpp\n\
1127                     • c#, csharp, cs\n\
1128                     • ruby, rb\n\
1129                     • kotlin, kt\n\
1130                     • zig\n\
1131                     \n\
1132                     Example: rfx query \"pattern\" --lang rust",
1133                    lang_str
1134                );
1135            }
1136        }
1137    } else {
1138        None
1139    };
1140
1141    // Parse symbol kind - try exact match first (case-insensitive), then treat as Unknown
1142    let kind = kind_str.as_deref().and_then(|s| {
1143        // Try parsing with proper case (PascalCase for SymbolKind)
1144        let capitalized = {
1145            let mut chars = s.chars();
1146            match chars.next() {
1147                None => String::new(),
1148                Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1149            }
1150        };
1151
1152        capitalized.parse::<crate::models::SymbolKind>()
1153            .ok()
1154            .or_else(|| {
1155                // If not a known kind, treat as Unknown for flexibility
1156                log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1157                Some(crate::models::SymbolKind::Unknown(s.to_string()))
1158            })
1159    });
1160
1161    // Smart behavior: --kind implies --symbols
1162    let symbols_mode = symbols_flag || kind.is_some();
1163
1164    // Smart limit handling:
1165    // 1. If --count is set: no limit (count should always show total)
1166    // 2. If --all is set: no limit (None)
1167    // 3. If --limit 0 is set: no limit (None) - treat 0 as "unlimited"
1168    // 4. If --paths is set and user didn't specify --limit: no limit (None)
1169    // 5. If user specified --limit: use that value
1170    // 6. Otherwise: use default limit of 100
1171    let final_limit = if count_only {
1172        None  // --count always shows total count, no pagination
1173    } else if all {
1174        None  // --all means no limit
1175    } else if limit == Some(0) {
1176        None  // --limit 0 means no limit (unlimited results)
1177    } else if paths_only && limit.is_none() {
1178        None  // --paths without explicit --limit means no limit
1179    } else if let Some(user_limit) = limit {
1180        Some(user_limit)  // Use user-specified limit
1181    } else {
1182        Some(100)  // Default: limit to 100 results for token efficiency
1183    };
1184
1185    // Validate AST query requirements
1186    if use_ast && language.is_none() {
1187        anyhow::bail!(
1188            "AST pattern matching requires a language to be specified.\n\
1189             \n\
1190             Use --lang to specify the language for tree-sitter parsing.\n\
1191             \n\
1192             Supported languages for AST queries:\n\
1193             • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
1194             \n\
1195             Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
1196             \n\
1197             WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
1198             \n\
1199             Examples:\n\
1200             • rfx query \"(function_definition) @fn\" --ast --lang python\n\
1201             • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
1202        );
1203    }
1204
1205    // VALIDATION: Check for conflicting or problematic flag combinations
1206    // Only show warnings/errors in non-JSON mode (avoid breaking parsers)
1207    if !as_json {
1208        let mut has_errors = false;
1209
1210        // ERROR: Mutually exclusive pattern matching modes
1211        if use_regex && use_contains {
1212            eprintln!("{}", "ERROR: Cannot use --regex and --contains together.".red().bold());
1213            eprintln!("  {} --regex for pattern matching (alternation, wildcards, etc.)", "•".dimmed());
1214            eprintln!("  {} --contains for substring matching (expansive search)", "•".dimmed());
1215            eprintln!("\n  {} Choose one based on your needs:", "Tip:".cyan().bold());
1216            eprintln!("    {} for OR logic: --regex", "pattern1|pattern2".yellow());
1217            eprintln!("    {} for substring: --contains", "partial_text".yellow());
1218            has_errors = true;
1219        }
1220
1221        // ERROR: Contradictory matching requirements
1222        if exact && use_contains {
1223            eprintln!("{}", "ERROR: Cannot use --exact and --contains together (contradictory).".red().bold());
1224            eprintln!("  {} --exact requires exact symbol name match", "•".dimmed());
1225            eprintln!("  {} --contains allows substring matching", "•".dimmed());
1226            has_errors = true;
1227        }
1228
1229        // WARNING: Redundant file filtering
1230        if file_pattern.is_some() && !glob_patterns.is_empty() {
1231            eprintln!("{}", "WARNING: Both --file and --glob specified.".yellow().bold());
1232            eprintln!("  {} --file does substring matching on file paths", "•".dimmed());
1233            eprintln!("  {} --glob does pattern matching with wildcards", "•".dimmed());
1234            eprintln!("  {} Both filters will apply (AND condition)", "Note:".dimmed());
1235            eprintln!("\n  {} Usually you only need one:", "Tip:".cyan().bold());
1236            eprintln!("    {} for simple matching", "--file User.php".yellow());
1237            eprintln!("    {} for pattern matching", "--glob src/**/*.php".yellow());
1238        }
1239
1240        // INFO: Detect potentially problematic glob patterns
1241        for pattern in &glob_patterns {
1242            // Check for literal quotes in pattern
1243            if (pattern.starts_with('\'') && pattern.ends_with('\'')) ||
1244               (pattern.starts_with('"') && pattern.ends_with('"')) {
1245                eprintln!("{}",
1246                    format!("WARNING: Glob pattern contains quotes: {}", pattern).yellow().bold()
1247                );
1248                eprintln!("  {} Shell quotes should not be part of the pattern", "Note:".dimmed());
1249                eprintln!("  {} --glob src/**/*.rs", "Correct:".green());
1250                eprintln!("  {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
1251            }
1252
1253            // Suggest using ** instead of * for recursive matching
1254            if pattern.contains("*/") && !pattern.contains("**/") {
1255                eprintln!("{}",
1256                    format!("INFO: Glob '{}' uses * (matches one directory level)", pattern).cyan()
1257                );
1258                eprintln!("  {} Use ** for recursive matching across subdirectories", "Tip:".cyan().bold());
1259                eprintln!("    {} → matches files in Models/ only", "app/Models/*.php".yellow());
1260                eprintln!("    {} → matches files in Models/ and subdirs", "app/Models/**/*.php".green());
1261            }
1262        }
1263
1264        if has_errors {
1265            anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
1266        }
1267    }
1268
1269    let filter = QueryFilter {
1270        language,
1271        kind,
1272        use_ast,
1273        use_regex,
1274        limit: final_limit,
1275        symbols_mode,
1276        expand,
1277        file_pattern,
1278        exact,
1279        use_contains,
1280        timeout_secs,
1281        glob_patterns: glob_patterns.clone(),
1282        exclude_patterns,
1283        paths_only,
1284        offset,
1285        force,
1286        suppress_output: as_json,  // Suppress warnings in JSON mode
1287        include_dependencies,
1288        ..Default::default()
1289    };
1290
1291    // Measure query time
1292    let start = Instant::now();
1293
1294    // Execute query and get pagination metadata
1295    // Handle errors specially for JSON output mode
1296    let (query_response, mut flat_results, total_results, has_more) = if use_ast {
1297        // AST query: pattern is the S-expression, scan all files
1298        match engine.search_ast_all_files(&pattern, filter.clone()) {
1299            Ok(ast_results) => {
1300                let count = ast_results.len();
1301                (None, ast_results, count, false)
1302            }
1303            Err(e) => {
1304                if as_json {
1305                    // Output error as JSON
1306                    let error_response = serde_json::json!({
1307                        "error": e.to_string(),
1308                        "query_too_broad": e.to_string().contains("Query too broad")
1309                    });
1310                    let json_output = if pretty_json {
1311                        serde_json::to_string_pretty(&error_response)?
1312                    } else {
1313                        serde_json::to_string(&error_response)?
1314                    };
1315                    println!("{}", json_output);
1316                    std::process::exit(1);
1317                } else {
1318                    return Err(e);
1319                }
1320            }
1321        }
1322    } else {
1323        // Use metadata-aware search for all queries (to get pagination info)
1324        match engine.search_with_metadata(&pattern, filter.clone()) {
1325            Ok(response) => {
1326                let total = response.pagination.total;
1327                let has_more = response.pagination.has_more;
1328
1329                // Flatten grouped results to SearchResult vec for plain text formatting
1330                let flat = response.results.iter()
1331                    .flat_map(|file_group| {
1332                        file_group.matches.iter().map(move |m| {
1333                            crate::models::SearchResult {
1334                                path: file_group.path.clone(),
1335                                lang: crate::models::Language::Unknown, // Will be set by formatter if needed
1336                                kind: m.kind.clone(),
1337                                symbol: m.symbol.clone(),
1338                                span: m.span.clone(),
1339                                preview: m.preview.clone(),
1340                                dependencies: file_group.dependencies.clone(),
1341                            }
1342                        })
1343                    })
1344                    .collect();
1345
1346                (Some(response), flat, total, has_more)
1347            }
1348            Err(e) => {
1349                if as_json {
1350                    // Output error as JSON
1351                    let error_response = serde_json::json!({
1352                        "error": e.to_string(),
1353                        "query_too_broad": e.to_string().contains("Query too broad")
1354                    });
1355                    let json_output = if pretty_json {
1356                        serde_json::to_string_pretty(&error_response)?
1357                    } else {
1358                        serde_json::to_string(&error_response)?
1359                    };
1360                    println!("{}", json_output);
1361                    std::process::exit(1);
1362                } else {
1363                    return Err(e);
1364                }
1365            }
1366        }
1367    };
1368
1369    // Apply preview truncation unless --no-truncate is set
1370    if !no_truncate {
1371        const MAX_PREVIEW_LENGTH: usize = 100;
1372        for result in &mut flat_results {
1373            result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
1374        }
1375    }
1376
1377    let elapsed = start.elapsed();
1378
1379    // Format timing string
1380    let timing_str = if elapsed.as_millis() < 1 {
1381        format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
1382    } else {
1383        format!("{}ms", elapsed.as_millis())
1384    };
1385
1386    if as_json {
1387        if count_only {
1388            // Count-only JSON mode: output simple count object
1389            let count_response = serde_json::json!({
1390                "count": total_results,
1391                "timing_ms": elapsed.as_millis()
1392            });
1393            let json_output = if pretty_json {
1394                serde_json::to_string_pretty(&count_response)?
1395            } else {
1396                serde_json::to_string(&count_response)?
1397            };
1398            println!("{}", json_output);
1399        } else if paths_only {
1400            // Paths-only JSON mode: output array of {path, line} objects
1401            let locations: Vec<serde_json::Value> = flat_results.iter()
1402                .map(|r| serde_json::json!({
1403                    "path": r.path,
1404                    "line": r.span.start_line
1405                }))
1406                .collect();
1407            let json_output = if pretty_json {
1408                serde_json::to_string_pretty(&locations)?
1409            } else {
1410                serde_json::to_string(&locations)?
1411            };
1412            println!("{}", json_output);
1413            eprintln!("Found {} unique files in {}", locations.len(), timing_str);
1414        } else {
1415            // Get or build QueryResponse for JSON output
1416            let mut response = if let Some(resp) = query_response {
1417                // We already have a response from search_with_metadata
1418                // Apply truncation to the response (the flat_results were already truncated)
1419                let mut resp = resp;
1420
1421                // Apply truncation to results
1422                if !no_truncate {
1423                    const MAX_PREVIEW_LENGTH: usize = 100;
1424                    for file_group in resp.results.iter_mut() {
1425                        for m in file_group.matches.iter_mut() {
1426                            m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
1427                        }
1428                    }
1429                }
1430
1431                resp
1432            } else {
1433                // For AST queries, build a response with minimal metadata
1434                // Group flat results by file path
1435                use crate::models::{PaginationInfo, IndexStatus, FileGroupedResult, MatchResult};
1436                use std::collections::HashMap;
1437
1438                let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
1439                for result in &flat_results {
1440                    grouped
1441                        .entry(result.path.clone())
1442                        .or_default()
1443                        .push(result.clone());
1444                }
1445
1446                // Load ContentReader for extracting context lines
1447                use crate::content_store::ContentReader;
1448                let local_cache = CacheManager::new(".");
1449                let content_path = local_cache.path().join("content.bin");
1450                let content_reader_opt = ContentReader::open(&content_path).ok();
1451
1452                let mut file_results: Vec<FileGroupedResult> = grouped
1453                    .into_iter()
1454                    .map(|(path, file_matches)| {
1455                        // Get file_id for context extraction
1456                        // Note: We use ContentReader's get_file_id_by_path() which returns array indices,
1457                        // not database file_ids (which are AUTO INCREMENT values)
1458                        let normalized_path = path.strip_prefix("./").unwrap_or(&path);
1459                        let file_id_for_context = if let Some(reader) = &content_reader_opt {
1460                            reader.get_file_id_by_path(normalized_path)
1461                        } else {
1462                            None
1463                        };
1464
1465                        let matches: Vec<MatchResult> = file_matches
1466                            .into_iter()
1467                            .map(|r| {
1468                                // Extract context lines (default: 3 lines before and after)
1469                                let (context_before, context_after) = if let (Some(reader), Some(fid)) = (&content_reader_opt, file_id_for_context) {
1470                                    reader.get_context_by_line(fid as u32, r.span.start_line, 3)
1471                                        .unwrap_or_else(|_| (vec![], vec![]))
1472                                } else {
1473                                    (vec![], vec![])
1474                                };
1475
1476                                MatchResult {
1477                                    kind: r.kind,
1478                                    symbol: r.symbol,
1479                                    span: r.span,
1480                                    preview: r.preview,
1481                                    context_before,
1482                                    context_after,
1483                                }
1484                            })
1485                            .collect();
1486                        FileGroupedResult {
1487                            path,
1488                            dependencies: None,
1489                            matches,
1490                        }
1491                    })
1492                    .collect();
1493
1494                // Sort by path for deterministic output
1495                file_results.sort_by(|a, b| a.path.cmp(&b.path));
1496
1497                crate::models::QueryResponse {
1498                    ai_instruction: None,  // Will be populated below if ai_mode is true
1499                    status: IndexStatus::Fresh,
1500                    can_trust_results: true,
1501                    warning: None,
1502                    pagination: PaginationInfo {
1503                        total: flat_results.len(),
1504                        count: flat_results.len(),
1505                        offset: offset.unwrap_or(0),
1506                        limit,
1507                        has_more: false, // AST already applied pagination
1508                    },
1509                    results: file_results,
1510                }
1511            };
1512
1513            // Generate AI instruction if in AI mode
1514            if ai_mode {
1515                let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1516
1517                response.ai_instruction = crate::query::generate_ai_instruction(
1518                    result_count,
1519                    response.pagination.total,
1520                    response.pagination.has_more,
1521                    symbols_mode,
1522                    paths_only,
1523                    use_ast,
1524                    use_regex,
1525                    language.is_some(),
1526                    !glob_patterns.is_empty(),
1527                    exact,
1528                );
1529            }
1530
1531            let json_output = if pretty_json {
1532                serde_json::to_string_pretty(&response)?
1533            } else {
1534                serde_json::to_string(&response)?
1535            };
1536            println!("{}", json_output);
1537
1538            let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1539            eprintln!("Found {} results in {}", result_count, timing_str);
1540        }
1541    } else {
1542        // Standard output with formatting
1543        if count_only {
1544            println!("Found {} results in {}", flat_results.len(), timing_str);
1545            return Ok(());
1546        }
1547
1548        if paths_only {
1549            // Paths-only plain text mode: output one path per line
1550            if flat_results.is_empty() {
1551                eprintln!("No results found (searched in {}).", timing_str);
1552            } else {
1553                for result in &flat_results {
1554                    println!("{}", result.path);
1555                }
1556                eprintln!("Found {} unique files in {}", flat_results.len(), timing_str);
1557            }
1558        } else {
1559            // Standard result formatting
1560            if flat_results.is_empty() {
1561                println!("No results found (searched in {}).", timing_str);
1562            } else {
1563                // Use formatter for pretty output
1564                let formatter = crate::formatter::OutputFormatter::new(plain);
1565                formatter.format_results(&flat_results, &pattern)?;
1566
1567                // Print summary at the bottom with pagination details
1568                if total_results > flat_results.len() {
1569                    // Results were paginated - show detailed count
1570                    println!("\nFound {} results ({} total) in {}", flat_results.len(), total_results, timing_str);
1571                    // Show pagination hint if there are more results available
1572                    if has_more {
1573                        println!("Use --limit and --offset to paginate");
1574                    }
1575                } else {
1576                    // All results shown - simple count
1577                    println!("\nFound {} results in {}", flat_results.len(), timing_str);
1578                }
1579            }
1580        }
1581    }
1582
1583    Ok(())
1584}
1585
1586/// Handle the `serve` subcommand
1587fn handle_serve(port: u16, host: String) -> Result<()> {
1588    log::info!("Starting HTTP server on {}:{}", host, port);
1589
1590    println!("Starting Reflex HTTP server...");
1591    println!("  Address: http://{}:{}", host, port);
1592    println!("\nEndpoints:");
1593    println!("  GET  /query?q=<pattern>&lang=<lang>&kind=<kind>&limit=<n>&symbols=true&regex=true&exact=true&contains=true&expand=true&file=<pattern>&timeout=<secs>&glob=<pattern>&exclude=<pattern>&paths=true&dependencies=true");
1594    println!("  GET  /stats");
1595    println!("  POST /index");
1596    println!("\nPress Ctrl+C to stop.");
1597
1598    // Start the server using tokio runtime
1599    let runtime = tokio::runtime::Runtime::new()?;
1600    runtime.block_on(async {
1601        run_server(port, host).await
1602    })
1603}
1604
1605/// Run the HTTP server
1606async fn run_server(port: u16, host: String) -> Result<()> {
1607    use axum::{
1608        extract::{Query as AxumQuery, State},
1609        http::StatusCode,
1610        response::{IntoResponse, Json},
1611        routing::{get, post},
1612        Router,
1613    };
1614    use tower_http::cors::{CorsLayer, Any};
1615    use std::sync::Arc;
1616
1617    // Server state shared across requests
1618    #[derive(Clone)]
1619    struct AppState {
1620        cache_path: String,
1621    }
1622
1623    // Query parameters for GET /query
1624    #[derive(Debug, serde::Deserialize)]
1625    struct QueryParams {
1626        q: String,
1627        #[serde(default)]
1628        lang: Option<String>,
1629        #[serde(default)]
1630        kind: Option<String>,
1631        #[serde(default)]
1632        limit: Option<usize>,
1633        #[serde(default)]
1634        offset: Option<usize>,
1635        #[serde(default)]
1636        symbols: bool,
1637        #[serde(default)]
1638        regex: bool,
1639        #[serde(default)]
1640        exact: bool,
1641        #[serde(default)]
1642        contains: bool,
1643        #[serde(default)]
1644        expand: bool,
1645        #[serde(default)]
1646        file: Option<String>,
1647        #[serde(default = "default_timeout")]
1648        timeout: u64,
1649        #[serde(default)]
1650        glob: Vec<String>,
1651        #[serde(default)]
1652        exclude: Vec<String>,
1653        #[serde(default)]
1654        paths: bool,
1655        #[serde(default)]
1656        force: bool,
1657        #[serde(default)]
1658        dependencies: bool,
1659    }
1660
1661    // Default timeout for HTTP queries (30 seconds)
1662    fn default_timeout() -> u64 {
1663        30
1664    }
1665
1666    // Request body for POST /index
1667    #[derive(Debug, serde::Deserialize)]
1668    struct IndexRequest {
1669        #[serde(default)]
1670        force: bool,
1671        #[serde(default)]
1672        languages: Vec<String>,
1673    }
1674
1675    // GET /query endpoint
1676    async fn handle_query_endpoint(
1677        State(state): State<Arc<AppState>>,
1678        AxumQuery(params): AxumQuery<QueryParams>,
1679    ) -> Result<Json<crate::models::QueryResponse>, (StatusCode, String)> {
1680        log::info!("Query request: pattern={}", params.q);
1681
1682        let cache = CacheManager::new(&state.cache_path);
1683        let engine = QueryEngine::new(cache);
1684
1685        // Parse language filter
1686        let language = if let Some(lang_str) = params.lang.as_deref() {
1687            match lang_str.to_lowercase().as_str() {
1688                "rust" | "rs" => Some(Language::Rust),
1689                "javascript" | "js" => Some(Language::JavaScript),
1690                "typescript" | "ts" => Some(Language::TypeScript),
1691                "vue" => Some(Language::Vue),
1692                "svelte" => Some(Language::Svelte),
1693                "php" => Some(Language::PHP),
1694                "python" | "py" => Some(Language::Python),
1695                "go" => Some(Language::Go),
1696                "java" => Some(Language::Java),
1697                "c" => Some(Language::C),
1698                "cpp" | "c++" => Some(Language::Cpp),
1699                _ => {
1700                    return Err((
1701                        StatusCode::BAD_REQUEST,
1702                        format!("Unknown language '{}'. Supported languages: rust, javascript (js), typescript (ts), vue, svelte, php, python (py), go, java, c, cpp (c++)", lang_str)
1703                    ));
1704                }
1705            }
1706        } else {
1707            None
1708        };
1709
1710        // Parse symbol kind
1711        let kind = params.kind.as_deref().and_then(|s| {
1712            let capitalized = {
1713                let mut chars = s.chars();
1714                match chars.next() {
1715                    None => String::new(),
1716                    Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1717                }
1718            };
1719
1720            capitalized.parse::<crate::models::SymbolKind>()
1721                .ok()
1722                .or_else(|| {
1723                    log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1724                    Some(crate::models::SymbolKind::Unknown(s.to_string()))
1725                })
1726        });
1727
1728        // Smart behavior: --kind implies --symbols
1729        let symbols_mode = params.symbols || kind.is_some();
1730
1731        // Smart limit handling (same as CLI and MCP)
1732        let final_limit = if params.paths && params.limit.is_none() {
1733            None  // --paths without explicit limit means no limit
1734        } else if let Some(user_limit) = params.limit {
1735            Some(user_limit)  // Use user-specified limit
1736        } else {
1737            Some(100)  // Default: limit to 100 results for token efficiency
1738        };
1739
1740        let filter = QueryFilter {
1741            language,
1742            kind,
1743            use_ast: false,
1744            use_regex: params.regex,
1745            limit: final_limit,
1746            symbols_mode,
1747            expand: params.expand,
1748            file_pattern: params.file,
1749            exact: params.exact,
1750            use_contains: params.contains,
1751            timeout_secs: params.timeout,
1752            glob_patterns: params.glob,
1753            exclude_patterns: params.exclude,
1754            paths_only: params.paths,
1755            offset: params.offset,
1756            force: params.force,
1757            suppress_output: true,  // HTTP API always returns JSON, suppress warnings
1758            include_dependencies: params.dependencies,
1759            ..Default::default()
1760        };
1761
1762        match engine.search_with_metadata(&params.q, filter) {
1763            Ok(response) => Ok(Json(response)),
1764            Err(e) => {
1765                log::error!("Query error: {}", e);
1766                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)))
1767            }
1768        }
1769    }
1770
1771    // GET /stats endpoint
1772    async fn handle_stats_endpoint(
1773        State(state): State<Arc<AppState>>,
1774    ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1775        log::info!("Stats request");
1776
1777        let cache = CacheManager::new(&state.cache_path);
1778
1779        if !cache.exists() {
1780            return Err((StatusCode::NOT_FOUND, "No index found. Run 'rfx index' first.".to_string()));
1781        }
1782
1783        match cache.stats() {
1784            Ok(stats) => Ok(Json(stats)),
1785            Err(e) => {
1786                log::error!("Stats error: {}", e);
1787                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get stats: {}", e)))
1788            }
1789        }
1790    }
1791
1792    // POST /index endpoint
1793    async fn handle_index_endpoint(
1794        State(state): State<Arc<AppState>>,
1795        Json(req): Json<IndexRequest>,
1796    ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1797        log::info!("Index request: force={}, languages={:?}", req.force, req.languages);
1798
1799        let cache = CacheManager::new(&state.cache_path);
1800
1801        if req.force {
1802            log::info!("Force rebuild requested, clearing existing cache");
1803            if let Err(e) = cache.clear() {
1804                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to clear cache: {}", e)));
1805            }
1806        }
1807
1808        // Parse language filters
1809        let lang_filters: Vec<Language> = req.languages
1810            .iter()
1811            .filter_map(|s| match s.to_lowercase().as_str() {
1812                "rust" | "rs" => Some(Language::Rust),
1813                "python" | "py" => Some(Language::Python),
1814                "javascript" | "js" => Some(Language::JavaScript),
1815                "typescript" | "ts" => Some(Language::TypeScript),
1816                "vue" => Some(Language::Vue),
1817                "svelte" => Some(Language::Svelte),
1818                "go" => Some(Language::Go),
1819                "java" => Some(Language::Java),
1820                "php" => Some(Language::PHP),
1821                "c" => Some(Language::C),
1822                "cpp" | "c++" => Some(Language::Cpp),
1823                _ => {
1824                    log::warn!("Unknown language: {}", s);
1825                    None
1826                }
1827            })
1828            .collect();
1829
1830        let config = IndexConfig {
1831            languages: lang_filters,
1832            ..Default::default()
1833        };
1834
1835        let indexer = Indexer::new(cache, config);
1836        let path = std::path::PathBuf::from(&state.cache_path);
1837
1838        match indexer.index(&path, false) {
1839            Ok(stats) => Ok(Json(stats)),
1840            Err(e) => {
1841                log::error!("Index error: {}", e);
1842                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Indexing failed: {}", e)))
1843            }
1844        }
1845    }
1846
1847    // Health check endpoint
1848    async fn handle_health() -> impl IntoResponse {
1849        (StatusCode::OK, "Reflex is running")
1850    }
1851
1852    // Create shared state
1853    let state = Arc::new(AppState {
1854        cache_path: ".".to_string(),
1855    });
1856
1857    // Configure CORS
1858    let cors = CorsLayer::new()
1859        .allow_origin(Any)
1860        .allow_methods(Any)
1861        .allow_headers(Any);
1862
1863    // Build the router
1864    let app = Router::new()
1865        .route("/query", get(handle_query_endpoint))
1866        .route("/stats", get(handle_stats_endpoint))
1867        .route("/index", post(handle_index_endpoint))
1868        .route("/health", get(handle_health))
1869        .layer(cors)
1870        .with_state(state);
1871
1872    // Bind to the specified address
1873    let addr = format!("{}:{}", host, port);
1874    let listener = tokio::net::TcpListener::bind(&addr).await
1875        .map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", addr, e))?;
1876
1877    log::info!("Server listening on {}", addr);
1878
1879    // Run the server
1880    axum::serve(listener, app)
1881        .await
1882        .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
1883
1884    Ok(())
1885}
1886
1887/// Handle the `stats` subcommand
1888fn handle_stats(as_json: bool, pretty_json: bool) -> Result<()> {
1889    log::info!("Showing index statistics");
1890
1891    let cache = CacheManager::new(".");
1892
1893    if !cache.exists() {
1894        anyhow::bail!(
1895            "No index found in current directory.\n\
1896             \n\
1897             Run 'rfx index' to build the code search index first.\n\
1898             This will scan all files in the current directory and create a .reflex/ cache.\n\
1899             \n\
1900             Example:\n\
1901             $ rfx index          # Index current directory\n\
1902             $ rfx stats          # Show index statistics"
1903        );
1904    }
1905
1906    let stats = cache.stats()?;
1907
1908    if as_json {
1909        let json_output = if pretty_json {
1910            serde_json::to_string_pretty(&stats)?
1911        } else {
1912            serde_json::to_string(&stats)?
1913        };
1914        println!("{}", json_output);
1915    } else {
1916        println!("Reflex Index Statistics");
1917        println!("=======================");
1918
1919        // Show git branch info if in git repo, or (None) if not
1920        let root = std::env::current_dir()?;
1921        if crate::git::is_git_repo(&root) {
1922            match crate::git::get_git_state(&root) {
1923                Ok(git_state) => {
1924                    let dirty_indicator = if git_state.dirty { " (uncommitted changes)" } else { " (clean)" };
1925                    println!("Branch:         {}@{}{}",
1926                             git_state.branch,
1927                             &git_state.commit[..7],
1928                             dirty_indicator);
1929
1930                    // Check if current branch is indexed
1931                    match cache.get_branch_info(&git_state.branch) {
1932                        Ok(branch_info) => {
1933                            if branch_info.commit_sha != git_state.commit {
1934                                println!("                ⚠️  Index commit mismatch (indexed: {})",
1935                                         &branch_info.commit_sha[..7]);
1936                            }
1937                            if git_state.dirty && !branch_info.is_dirty {
1938                                println!("                ⚠️  Uncommitted changes not indexed");
1939                            }
1940                        }
1941                        Err(_) => {
1942                            println!("                ⚠️  Branch not indexed");
1943                        }
1944                    }
1945                }
1946                Err(e) => {
1947                    log::warn!("Failed to get git state: {}", e);
1948                }
1949            }
1950        } else {
1951            // Not a git repository - show (None)
1952            println!("Branch:         (None)");
1953        }
1954
1955        println!("Files indexed:  {}", stats.total_files);
1956        println!("Index size:     {} bytes", stats.index_size_bytes);
1957        println!("Last updated:   {}", stats.last_updated);
1958
1959        // Display language breakdown if we have indexed files
1960        if !stats.files_by_language.is_empty() {
1961            println!("\nFiles by language:");
1962
1963            // Sort languages by count (descending) for consistent output
1964            let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
1965            lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
1966
1967            // Calculate column widths
1968            let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
1969            let lang_width = max_lang_len.max(8); // At least "Language" header width
1970
1971            // Print table header
1972            println!("  {:<width$}  Files  Lines", "Language", width = lang_width);
1973            println!("  {}  -----  -------", "-".repeat(lang_width));
1974
1975            // Print rows
1976            for (language, file_count) in lang_vec {
1977                let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
1978                println!("  {:<width$}  {:5}  {:7}",
1979                    language, file_count, line_count,
1980                    width = lang_width);
1981            }
1982        }
1983    }
1984
1985    Ok(())
1986}
1987
1988/// Handle the `clear` subcommand
1989fn handle_clear(skip_confirm: bool) -> Result<()> {
1990    let cache = CacheManager::new(".");
1991
1992    if !cache.exists() {
1993        println!("No cache to clear.");
1994        return Ok(());
1995    }
1996
1997    if !skip_confirm {
1998        println!("This will delete the local Reflex cache at: {:?}", cache.path());
1999        print!("Are you sure? [y/N] ");
2000        use std::io::{self, Write};
2001        io::stdout().flush()?;
2002
2003        let mut input = String::new();
2004        io::stdin().read_line(&mut input)?;
2005
2006        if !input.trim().eq_ignore_ascii_case("y") {
2007            println!("Cancelled.");
2008            return Ok(());
2009        }
2010    }
2011
2012    cache.clear()?;
2013    println!("Cache cleared successfully.");
2014
2015    Ok(())
2016}
2017
2018/// Handle the `list-files` subcommand
2019fn handle_list_files(as_json: bool, pretty_json: bool) -> Result<()> {
2020    let cache = CacheManager::new(".");
2021
2022    if !cache.exists() {
2023        anyhow::bail!(
2024            "No index found in current directory.\n\
2025             \n\
2026             Run 'rfx index' to build the code search index first.\n\
2027             This will scan all files in the current directory and create a .reflex/ cache.\n\
2028             \n\
2029             Example:\n\
2030             $ rfx index            # Index current directory\n\
2031             $ rfx list-files       # List indexed files"
2032        );
2033    }
2034
2035    let files = cache.list_files()?;
2036
2037    if as_json {
2038        let json_output = if pretty_json {
2039            serde_json::to_string_pretty(&files)?
2040        } else {
2041            serde_json::to_string(&files)?
2042        };
2043        println!("{}", json_output);
2044    } else if files.is_empty() {
2045        println!("No files indexed yet.");
2046    } else {
2047        println!("Indexed Files ({} total):", files.len());
2048        println!();
2049        for file in files {
2050            println!("  {} ({})",
2051                     file.path,
2052                     file.language);
2053        }
2054    }
2055
2056    Ok(())
2057}
2058
2059/// Handle the `watch` subcommand
2060fn handle_watch(path: PathBuf, debounce_ms: u64, quiet: bool) -> Result<()> {
2061    log::info!("Starting watch mode for {:?}", path);
2062
2063    // Validate debounce range (5s - 30s)
2064    if !(5000..=30000).contains(&debounce_ms) {
2065        anyhow::bail!(
2066            "Debounce must be between 5000ms (5s) and 30000ms (30s). Got: {}ms",
2067            debounce_ms
2068        );
2069    }
2070
2071    if !quiet {
2072        println!("Starting Reflex watch mode...");
2073        println!("  Directory: {}", path.display());
2074        println!("  Debounce: {}ms ({}s)", debounce_ms, debounce_ms / 1000);
2075        println!("  Press Ctrl+C to stop.\n");
2076    }
2077
2078    // Setup cache
2079    let cache = CacheManager::new(&path);
2080
2081    // Initial index if cache doesn't exist
2082    if !cache.exists() {
2083        if !quiet {
2084            println!("No index found, running initial index...");
2085        }
2086        let config = IndexConfig::default();
2087        let indexer = Indexer::new(cache, config);
2088        indexer.index(&path, !quiet)?;
2089        if !quiet {
2090            println!("Initial index complete. Now watching for changes...\n");
2091        }
2092    }
2093
2094    // Create indexer for watcher
2095    let cache = CacheManager::new(&path);
2096    let config = IndexConfig::default();
2097    let indexer = Indexer::new(cache, config);
2098
2099    // Start watcher
2100    let watch_config = crate::watcher::WatchConfig {
2101        debounce_ms,
2102        quiet,
2103    };
2104
2105    crate::watcher::watch(&path, indexer, watch_config)?;
2106
2107    Ok(())
2108}
2109
2110/// Handle interactive mode (default when no command is given)
2111fn handle_interactive() -> Result<()> {
2112    log::info!("Launching interactive mode");
2113    crate::interactive::run_interactive()
2114}
2115
2116/// Handle the `mcp` subcommand
2117fn handle_mcp() -> Result<()> {
2118    log::info!("Starting MCP server");
2119    crate::mcp::run_mcp_server()
2120}
2121
2122/// Handle the internal `index-symbols-internal` command
2123fn handle_index_symbols_internal(cache_dir: PathBuf) -> Result<()> {
2124    let mut indexer = crate::background_indexer::BackgroundIndexer::new(&cache_dir)?;
2125    indexer.run()?;
2126    Ok(())
2127}
2128
2129/// Handle the `analyze` subcommand
2130#[allow(clippy::too_many_arguments)]
2131fn handle_analyze(
2132    circular: bool,
2133    hotspots: bool,
2134    min_dependents: usize,
2135    unused: bool,
2136    islands: bool,
2137    min_island_size: usize,
2138    max_island_size: Option<usize>,
2139    format: String,
2140    as_json: bool,
2141    pretty_json: bool,
2142    count_only: bool,
2143    all: bool,
2144    plain: bool,
2145    _glob_patterns: Vec<String>,
2146    _exclude_patterns: Vec<String>,
2147    _force: bool,
2148    limit: Option<usize>,
2149    offset: Option<usize>,
2150    sort: Option<String>,
2151) -> Result<()> {
2152    use crate::dependency::DependencyIndex;
2153
2154    log::info!("Starting analyze command");
2155
2156    let cache = CacheManager::new(".");
2157
2158    if !cache.exists() {
2159        anyhow::bail!(
2160            "No index found in current directory.\n\
2161             \n\
2162             Run 'rfx index' to build the code search index first.\n\
2163             \n\
2164             Example:\n\
2165             $ rfx index             # Index current directory\n\
2166             $ rfx analyze           # Run dependency analysis"
2167        );
2168    }
2169
2170    let deps_index = DependencyIndex::new(cache);
2171
2172    // JSON mode overrides format
2173    let format = if as_json { "json" } else { &format };
2174
2175    // Smart limit handling for analyze commands (default: 200 per page)
2176    let final_limit = if all {
2177        None  // --all means no limit
2178    } else if let Some(user_limit) = limit {
2179        Some(user_limit)  // Use user-specified limit
2180    } else {
2181        Some(200)  // Default: limit to 200 results per page for token efficiency
2182    };
2183
2184    // If no specific flags, show summary
2185    if !circular && !hotspots && !unused && !islands {
2186        return handle_analyze_summary(&deps_index, min_dependents, count_only, as_json, pretty_json);
2187    }
2188
2189    // Run specific analyses based on flags
2190    if circular {
2191        handle_deps_circular(&deps_index, format, pretty_json, final_limit, offset, count_only, plain, sort.clone())?;
2192    }
2193
2194    if hotspots {
2195        handle_deps_hotspots(&deps_index, format, pretty_json, final_limit, offset, min_dependents, count_only, plain, sort.clone())?;
2196    }
2197
2198    if unused {
2199        handle_deps_unused(&deps_index, format, pretty_json, final_limit, offset, count_only, plain)?;
2200    }
2201
2202    if islands {
2203        handle_deps_islands(&deps_index, format, pretty_json, final_limit, offset, min_island_size, max_island_size, count_only, plain, sort.clone())?;
2204    }
2205
2206    Ok(())
2207}
2208
2209/// Handle analyze summary (default --analyze behavior)
2210fn handle_analyze_summary(
2211    deps_index: &crate::dependency::DependencyIndex,
2212    min_dependents: usize,
2213    count_only: bool,
2214    as_json: bool,
2215    pretty_json: bool,
2216) -> Result<()> {
2217    // Gather counts
2218    let cycles = deps_index.detect_circular_dependencies()?;
2219    let hotspots = deps_index.find_hotspots(None, min_dependents)?;
2220    let unused = deps_index.find_unused_files()?;
2221    let all_islands = deps_index.find_islands()?;
2222
2223    if as_json {
2224        // JSON output
2225        let summary = serde_json::json!({
2226            "circular_dependencies": cycles.len(),
2227            "hotspots": hotspots.len(),
2228            "unused_files": unused.len(),
2229            "islands": all_islands.len(),
2230            "min_dependents": min_dependents,
2231        });
2232
2233        let json_str = if pretty_json {
2234            serde_json::to_string_pretty(&summary)?
2235        } else {
2236            serde_json::to_string(&summary)?
2237        };
2238        println!("{}", json_str);
2239    } else if count_only {
2240        // Just show counts without any extra formatting
2241        println!("{} circular dependencies", cycles.len());
2242        println!("{} hotspots ({}+ dependents)", hotspots.len(), min_dependents);
2243        println!("{} unused files", unused.len());
2244        println!("{} islands", all_islands.len());
2245    } else {
2246        // Full summary with headers and suggestions
2247        println!("Dependency Analysis Summary\n");
2248
2249        // Circular dependencies
2250        println!("Circular Dependencies: {} cycle(s)", cycles.len());
2251
2252        // Hotspots
2253        println!("Hotspots: {} file(s) with {}+ dependents", hotspots.len(), min_dependents);
2254
2255        // Unused
2256        println!("Unused Files: {} file(s)", unused.len());
2257
2258        // Islands
2259        println!("Islands: {} disconnected component(s)", all_islands.len());
2260
2261        println!("\nUse specific flags for detailed results:");
2262        println!("  rfx analyze --circular");
2263        println!("  rfx analyze --hotspots");
2264        println!("  rfx analyze --unused");
2265        println!("  rfx analyze --islands");
2266    }
2267
2268    Ok(())
2269}
2270
2271/// Handle the `deps` subcommand
2272fn handle_deps(
2273    file: PathBuf,
2274    reverse: bool,
2275    depth: usize,
2276    format: String,
2277    as_json: bool,
2278    pretty_json: bool,
2279) -> Result<()> {
2280    use crate::dependency::DependencyIndex;
2281
2282    log::info!("Starting deps command");
2283
2284    let cache = CacheManager::new(".");
2285
2286    if !cache.exists() {
2287        anyhow::bail!(
2288            "No index found in current directory.\n\
2289             \n\
2290             Run 'rfx index' to build the code search index first.\n\
2291             \n\
2292             Example:\n\
2293             $ rfx index          # Index current directory\n\
2294             $ rfx deps <file>    # Analyze dependencies"
2295        );
2296    }
2297
2298    let deps_index = DependencyIndex::new(cache);
2299
2300    // JSON mode overrides format
2301    let format = if as_json { "json" } else { &format };
2302
2303    // Convert file path to string
2304    let file_str = file.to_string_lossy().to_string();
2305
2306    // Get file ID
2307    let file_id = deps_index.get_file_id_by_path(&file_str)?
2308        .ok_or_else(|| anyhow::anyhow!("File '{}' not found in index", file_str))?;
2309
2310    if reverse {
2311        // Show dependents (who imports this file)
2312        let dependents = deps_index.get_dependents(file_id)?;
2313        let paths = deps_index.get_file_paths(&dependents)?;
2314
2315        match format.as_ref() {
2316            "json" => {
2317                let output: Vec<_> = dependents.iter()
2318                    .filter_map(|id| paths.get(id).map(|path| serde_json::json!({
2319                        "file_id": id,
2320                        "path": path,
2321                    })))
2322                    .collect();
2323
2324                let json_str = if pretty_json {
2325                    serde_json::to_string_pretty(&output)?
2326                } else {
2327                    serde_json::to_string(&output)?
2328                };
2329                println!("{}", json_str);
2330                eprintln!("Found {} files that import {}", dependents.len(), file_str);
2331            }
2332            "tree" => {
2333                println!("Files that import {}:", file_str);
2334                for (id, path) in &paths {
2335                    if dependents.contains(id) {
2336                        println!("  └─ {}", path);
2337                    }
2338                }
2339                eprintln!("\nFound {} dependents", dependents.len());
2340            }
2341            "table" => {
2342                println!("ID     Path");
2343                println!("-----  ----");
2344                for id in &dependents {
2345                    if let Some(path) = paths.get(id) {
2346                        println!("{:<5}  {}", id, path);
2347                    }
2348                }
2349                eprintln!("\nFound {} dependents", dependents.len());
2350            }
2351            _ => {
2352                anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2353            }
2354        }
2355    } else {
2356        // Show dependencies (what this file imports)
2357        if depth == 1 {
2358            // Direct dependencies only
2359            let deps = deps_index.get_dependencies(file_id)?;
2360
2361            match format.as_ref() {
2362                "json" => {
2363                    let output: Vec<_> = deps.iter()
2364                        .map(|dep| serde_json::json!({
2365                            "imported_path": dep.imported_path,
2366                            "resolved_file_id": dep.resolved_file_id,
2367                            "import_type": match dep.import_type {
2368                                crate::models::ImportType::Internal => "internal",
2369                                crate::models::ImportType::External => "external",
2370                                crate::models::ImportType::Stdlib => "stdlib",
2371                            },
2372                            "line": dep.line_number,
2373                            "symbols": dep.imported_symbols,
2374                        }))
2375                        .collect();
2376
2377                    let json_str = if pretty_json {
2378                        serde_json::to_string_pretty(&output)?
2379                    } else {
2380                        serde_json::to_string(&output)?
2381                    };
2382                    println!("{}", json_str);
2383                    eprintln!("Found {} dependencies for {}", deps.len(), file_str);
2384                }
2385                "tree" => {
2386                    println!("Dependencies of {}:", file_str);
2387                    for dep in &deps {
2388                        let type_label = match dep.import_type {
2389                            crate::models::ImportType::Internal => "[internal]",
2390                            crate::models::ImportType::External => "[external]",
2391                            crate::models::ImportType::Stdlib => "[stdlib]",
2392                        };
2393                        println!("  └─ {} {} (line {})", dep.imported_path, type_label, dep.line_number);
2394                    }
2395                    eprintln!("\nFound {} dependencies", deps.len());
2396                }
2397                "table" => {
2398                    println!("Path                          Type       Line");
2399                    println!("----------------------------  ---------  ----");
2400                    for dep in &deps {
2401                        let type_str = match dep.import_type {
2402                            crate::models::ImportType::Internal => "internal",
2403                            crate::models::ImportType::External => "external",
2404                            crate::models::ImportType::Stdlib => "stdlib",
2405                        };
2406                        println!("{:<28}  {:<9}  {}", dep.imported_path, type_str, dep.line_number);
2407                    }
2408                    eprintln!("\nFound {} dependencies", deps.len());
2409                }
2410                _ => {
2411                    anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2412                }
2413            }
2414        } else {
2415            // Transitive dependencies (depth > 1)
2416            let transitive = deps_index.get_transitive_deps(file_id, depth)?;
2417            let file_ids: Vec<_> = transitive.keys().copied().collect();
2418            let paths = deps_index.get_file_paths(&file_ids)?;
2419
2420            match format.as_ref() {
2421                "json" => {
2422                    let output: Vec<_> = transitive.iter()
2423                        .filter_map(|(id, d)| {
2424                            paths.get(id).map(|path| serde_json::json!({
2425                                "file_id": id,
2426                                "path": path,
2427                                "depth": d,
2428                            }))
2429                        })
2430                        .collect();
2431
2432                    let json_str = if pretty_json {
2433                        serde_json::to_string_pretty(&output)?
2434                    } else {
2435                        serde_json::to_string(&output)?
2436                    };
2437                    println!("{}", json_str);
2438                    eprintln!("Found {} transitive dependencies (depth {})", transitive.len(), depth);
2439                }
2440                "tree" => {
2441                    println!("Transitive dependencies of {} (depth {}):", file_str, depth);
2442                    // Group by depth for tree display
2443                    let mut by_depth: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
2444                    for (id, d) in &transitive {
2445                        by_depth.entry(*d).or_insert_with(Vec::new).push(*id);
2446                    }
2447
2448                    for depth_level in 0..=depth {
2449                        if let Some(ids) = by_depth.get(&depth_level) {
2450                            let indent = "  ".repeat(depth_level);
2451                            for id in ids {
2452                                if let Some(path) = paths.get(id) {
2453                                    if depth_level == 0 {
2454                                        println!("{}{} (self)", indent, path);
2455                                    } else {
2456                                        println!("{}└─ {}", indent, path);
2457                                    }
2458                                }
2459                            }
2460                        }
2461                    }
2462                    eprintln!("\nFound {} transitive dependencies", transitive.len());
2463                }
2464                "table" => {
2465                    println!("Depth  File ID  Path");
2466                    println!("-----  -------  ----");
2467                    let mut sorted: Vec<_> = transitive.iter().collect();
2468                    sorted.sort_by_key(|(_, d)| *d);
2469                    for (id, d) in sorted {
2470                        if let Some(path) = paths.get(id) {
2471                            println!("{:<5}  {:<7}  {}", d, id, path);
2472                        }
2473                    }
2474                    eprintln!("\nFound {} transitive dependencies", transitive.len());
2475                }
2476                _ => {
2477                    anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2478                }
2479            }
2480        }
2481    }
2482
2483    Ok(())
2484}
2485
2486/// Handle the `ask` command
2487fn handle_ask(
2488    question: Option<String>,
2489    _auto_execute: bool,
2490    provider_override: Option<String>,
2491    as_json: bool,
2492    pretty_json: bool,
2493    additional_context: Option<String>,
2494    configure: bool,
2495    agentic: bool,
2496    max_iterations: usize,
2497    no_eval: bool,
2498    show_reasoning: bool,
2499    verbose: bool,
2500    quiet: bool,
2501    answer: bool,
2502    interactive: bool,
2503    debug: bool,
2504) -> Result<()> {
2505    // If --configure flag is set, launch the configuration wizard
2506    if configure {
2507        log::info!("Launching configuration wizard");
2508        return crate::semantic::run_configure_wizard();
2509    }
2510
2511    // Check if any API key is configured before allowing rfx ask to run
2512    if !crate::semantic::is_any_api_key_configured() {
2513        anyhow::bail!(
2514            "No API key configured.\n\
2515             \n\
2516             Please run 'rfx ask --configure' to set up your API provider and key.\n\
2517             \n\
2518             Alternatively, you can set an environment variable:\n\
2519             - OPENAI_API_KEY\n\
2520             - ANTHROPIC_API_KEY\n\
2521             - GROQ_API_KEY"
2522        );
2523    }
2524
2525    // If no question provided and not in configure mode, default to interactive mode
2526    // If --interactive flag is set, launch interactive chat mode (TUI)
2527    if interactive || question.is_none() {
2528        log::info!("Launching interactive chat mode");
2529        let cache = CacheManager::new(".");
2530
2531        if !cache.exists() {
2532            anyhow::bail!(
2533                "No index found in current directory.\n\
2534                 \n\
2535                 Run 'rfx index' to build the code search index first.\n\
2536                 \n\
2537                 Example:\n\
2538                 $ rfx index                          # Index current directory\n\
2539                 $ rfx ask                            # Launch interactive chat"
2540            );
2541        }
2542
2543        return crate::semantic::run_chat_mode(cache, provider_override, None);
2544    }
2545
2546    // At this point, question must be Some
2547    let question = question.unwrap();
2548
2549    log::info!("Starting ask command");
2550
2551    let cache = CacheManager::new(".");
2552
2553    if !cache.exists() {
2554        anyhow::bail!(
2555            "No index found in current directory.\n\
2556             \n\
2557             Run 'rfx index' to build the code search index first.\n\
2558             \n\
2559             Example:\n\
2560             $ rfx index                          # Index current directory\n\
2561             $ rfx ask \"Find all TODOs\"          # Ask questions"
2562        );
2563    }
2564
2565    // Create a tokio runtime for async operations
2566    let runtime = tokio::runtime::Runtime::new()
2567        .context("Failed to create async runtime")?;
2568
2569    // Force quiet mode for JSON output (machine-readable, no UI output)
2570    let quiet = quiet || as_json;
2571
2572    // Create optional spinner (skip entirely in JSON mode for clean machine-readable output)
2573    let spinner = if !as_json {
2574        let s = ProgressBar::new_spinner();
2575        s.set_style(
2576            ProgressStyle::default_spinner()
2577                .template("{spinner:.cyan} {msg}")
2578                .unwrap()
2579                .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2580        );
2581        s.set_message("Generating queries...".to_string());
2582        s.enable_steady_tick(std::time::Duration::from_millis(80));
2583        Some(s)
2584    } else {
2585        None
2586    };
2587
2588    let (queries, results, total_count, count_only, gathered_context) = if agentic {
2589        // Agentic mode: multi-step reasoning with context gathering
2590
2591        // Wrap spinner in Arc<Mutex<>> for sharing with reporter (non-quiet mode)
2592        let spinner_shared = if !quiet {
2593            spinner.as_ref().map(|s| Arc::new(Mutex::new(s.clone())))
2594        } else {
2595            None
2596        };
2597
2598        // Create reporter based on flags
2599        let reporter: Box<dyn crate::semantic::AgenticReporter> = if quiet {
2600            Box::new(crate::semantic::QuietReporter)
2601        } else {
2602            Box::new(crate::semantic::ConsoleReporter::new(show_reasoning, verbose, debug, spinner_shared))
2603        };
2604
2605        // Set initial spinner message and enable ticking
2606        if let Some(ref s) = spinner {
2607            s.set_message("Starting agentic mode...".to_string());
2608            s.enable_steady_tick(std::time::Duration::from_millis(80));
2609        }
2610
2611        let agentic_config = crate::semantic::AgenticConfig {
2612            max_iterations,
2613            max_tools_per_phase: 5,
2614            enable_evaluation: !no_eval,
2615            eval_config: Default::default(),
2616            provider_override: provider_override.clone(),
2617            model_override: None,
2618            show_reasoning,
2619            verbose,
2620            debug,
2621        };
2622
2623        let agentic_response = runtime.block_on(async {
2624            crate::semantic::run_agentic_loop(&question, &cache, agentic_config, &*reporter).await
2625        }).context("Failed to run agentic loop")?;
2626
2627        // Clear spinner after agentic loop completes
2628        if let Some(ref s) = spinner {
2629            s.finish_and_clear();
2630        }
2631
2632        // Clear ephemeral output (Phase 5 evaluation) before showing final results
2633        if !as_json {
2634            reporter.clear_all();
2635        }
2636
2637        log::info!("Agentic loop completed: {} queries generated", agentic_response.queries.len());
2638
2639        // Destructure AgenticQueryResponse into tuple (preserve gathered_context)
2640        let count_only_mode = agentic_response.total_count.is_none();
2641        let count = agentic_response.total_count.unwrap_or(0);
2642        (agentic_response.queries, agentic_response.results, count, count_only_mode, agentic_response.gathered_context)
2643    } else {
2644        // Standard mode: single LLM call + execution
2645        if let Some(ref s) = spinner {
2646            s.set_message("Generating queries...".to_string());
2647            s.enable_steady_tick(std::time::Duration::from_millis(80));
2648        }
2649
2650        let semantic_response = runtime.block_on(async {
2651            crate::semantic::ask_question(&question, &cache, provider_override.clone(), additional_context, debug).await
2652        }).context("Failed to generate semantic queries")?;
2653
2654        if let Some(ref s) = spinner {
2655            s.finish_and_clear();
2656        }
2657        log::info!("LLM generated {} queries", semantic_response.queries.len());
2658
2659        // Execute queries for standard mode
2660        let (exec_results, exec_total, exec_count_only) = runtime.block_on(async {
2661            crate::semantic::execute_queries(semantic_response.queries.clone(), &cache).await
2662        }).context("Failed to execute queries")?;
2663
2664        (semantic_response.queries, exec_results, exec_total, exec_count_only, None)
2665    };
2666
2667    // Generate conversational answer if --answer flag is set
2668    let generated_answer = if answer {
2669        // Show spinner while generating answer
2670        let answer_spinner = if !as_json {
2671            let s = ProgressBar::new_spinner();
2672            s.set_style(
2673                ProgressStyle::default_spinner()
2674                    .template("{spinner:.cyan} {msg}")
2675                    .unwrap()
2676                    .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2677            );
2678            s.set_message("Generating answer...".to_string());
2679            s.enable_steady_tick(std::time::Duration::from_millis(80));
2680            Some(s)
2681        } else {
2682            None
2683        };
2684
2685        // Initialize provider for answer generation
2686        let mut config = crate::semantic::config::load_config(cache.path())?;
2687        if let Some(provider) = &provider_override {
2688            config.provider = provider.clone();
2689        }
2690        let api_key = crate::semantic::config::get_api_key(&config.provider)?;
2691        let model = if config.model.is_some() {
2692            config.model.clone()
2693        } else {
2694            crate::semantic::config::get_user_model(&config.provider)
2695        };
2696        let provider_instance = crate::semantic::providers::create_provider(
2697            &config.provider,
2698            api_key,
2699            model,
2700        )?;
2701
2702        // Extract codebase context (always available metadata: languages, file counts, directories)
2703        let codebase_context_str = crate::semantic::context::CodebaseContext::extract(&cache)
2704            .ok()
2705            .map(|ctx| ctx.to_prompt_string());
2706
2707        // Generate answer (with optional gathered context from agentic mode + codebase context)
2708        let answer_result = runtime.block_on(async {
2709            crate::semantic::generate_answer(
2710                &question,
2711                &results,
2712                total_count,
2713                gathered_context.as_deref(),
2714                codebase_context_str.as_deref(),
2715                &*provider_instance,
2716            ).await
2717        }).context("Failed to generate answer")?;
2718
2719        if let Some(s) = answer_spinner {
2720            s.finish_and_clear();
2721        }
2722
2723        Some(answer_result)
2724    } else {
2725        None
2726    };
2727
2728    // Output in JSON format if requested
2729    if as_json {
2730        // Build AgenticQueryResponse for JSON output (includes both queries and results)
2731        let json_response = crate::semantic::AgenticQueryResponse {
2732            queries: queries.clone(),
2733            results: results.clone(),
2734            total_count: if count_only { None } else { Some(total_count) },
2735            gathered_context: gathered_context.clone(),
2736            tools_executed: None, // No tools in non-agentic mode
2737            answer: generated_answer,
2738        };
2739
2740        let json_str = if pretty_json {
2741            serde_json::to_string_pretty(&json_response)?
2742        } else {
2743            serde_json::to_string(&json_response)?
2744        };
2745        println!("{}", json_str);
2746        return Ok(());
2747    }
2748
2749    // Display generated queries with color (unless in answer mode)
2750    if !answer {
2751        println!("\n{}", "Generated Queries:".bold().cyan());
2752        println!("{}", "==================".cyan());
2753        for (idx, query_cmd) in queries.iter().enumerate() {
2754            println!(
2755                "{}. {} {} {}",
2756                (idx + 1).to_string().bright_white().bold(),
2757                format!("[order: {}, merge: {}]", query_cmd.order, query_cmd.merge).dimmed(),
2758                "rfx".bright_green().bold(),
2759                query_cmd.command.bright_white()
2760            );
2761        }
2762        println!();
2763    }
2764
2765    // Note: queries already executed in both modes above
2766    // Agentic mode: executed during run_agentic_loop
2767    // Standard mode: executed after ask_question
2768
2769    // Display answer or results
2770    println!();
2771    if let Some(answer_text) = generated_answer {
2772        // Answer mode: show the conversational answer
2773        println!("{}", "Answer:".bold().green());
2774        println!("{}", "=======".green());
2775        println!();
2776
2777        // Render markdown if it looks like markdown, otherwise print as-is
2778        termimad::print_text(&answer_text);
2779        println!();
2780
2781        // Show summary of results used
2782        if !results.is_empty() {
2783            println!(
2784                "{}",
2785                format!(
2786                    "(Based on {} matches across {} files)",
2787                    total_count,
2788                    results.len()
2789                ).dimmed()
2790            );
2791        }
2792    } else {
2793        // Standard mode: show raw results
2794        if count_only {
2795            // Count-only mode: just show the total count (matching direct CLI behavior)
2796            println!("{} {}", "Found".bright_green().bold(), format!("{} results", total_count).bright_white().bold());
2797        } else if results.is_empty() {
2798            println!("{}", "No results found.".yellow());
2799        } else {
2800            println!(
2801                "{} {} {} {} {}",
2802                "Found".bright_green().bold(),
2803                total_count.to_string().bright_white().bold(),
2804                "total results across".dimmed(),
2805                results.len().to_string().bright_white().bold(),
2806                "files:".dimmed()
2807            );
2808            println!();
2809
2810            for file_group in &results {
2811                println!("{}:", file_group.path.bright_cyan().bold());
2812                for match_result in &file_group.matches {
2813                    println!(
2814                        "  {} {}-{}: {}",
2815                        "Line".dimmed(),
2816                        match_result.span.start_line.to_string().bright_yellow(),
2817                        match_result.span.end_line.to_string().bright_yellow(),
2818                        match_result.preview.lines().next().unwrap_or("")
2819                    );
2820                }
2821                println!();
2822            }
2823        }
2824    }
2825
2826    Ok(())
2827}
2828
2829/// Handle the `context` command
2830fn handle_context(
2831    structure: bool,
2832    path: Option<String>,
2833    file_types: bool,
2834    project_type: bool,
2835    framework: bool,
2836    entry_points: bool,
2837    test_layout: bool,
2838    config_files: bool,
2839    depth: usize,
2840    json: bool,
2841) -> Result<()> {
2842    let cache = CacheManager::new(".");
2843
2844    if !cache.exists() {
2845        anyhow::bail!(
2846            "No index found in current directory.\n\
2847             \n\
2848             Run 'rfx index' to build the code search index first.\n\
2849             \n\
2850             Example:\n\
2851             $ rfx index                  # Index current directory\n\
2852             $ rfx context                # Generate context"
2853        );
2854    }
2855
2856    // Build context options
2857    let opts = crate::context::ContextOptions {
2858        structure,
2859        path,
2860        file_types,
2861        project_type,
2862        framework,
2863        entry_points,
2864        test_layout,
2865        config_files,
2866        depth,
2867        json,
2868    };
2869
2870    // Generate context
2871    let context_output = crate::context::generate_context(&cache, &opts)
2872        .context("Failed to generate codebase context")?;
2873
2874    // Print output
2875    println!("{}", context_output);
2876
2877    Ok(())
2878}
2879
2880/// Handle --circular flag (detect cycles)
2881fn handle_deps_circular(
2882    deps_index: &crate::dependency::DependencyIndex,
2883    format: &str,
2884    pretty_json: bool,
2885    limit: Option<usize>,
2886    offset: Option<usize>,
2887    count_only: bool,
2888    _plain: bool,
2889    sort: Option<String>,
2890) -> Result<()> {
2891    let mut all_cycles = deps_index.detect_circular_dependencies()?;
2892
2893    // Apply sorting (default: descending - longest cycles first)
2894    let sort_order = sort.as_deref().unwrap_or("desc");
2895    match sort_order {
2896        "asc" => {
2897            // Ascending: shortest cycles first
2898            all_cycles.sort_by_key(|cycle| cycle.len());
2899        }
2900        "desc" => {
2901            // Descending: longest cycles first (default)
2902            all_cycles.sort_by_key(|cycle| std::cmp::Reverse(cycle.len()));
2903        }
2904        _ => {
2905            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
2906        }
2907    }
2908
2909    let total_count = all_cycles.len();
2910
2911    if count_only {
2912        println!("Found {} circular dependencies", total_count);
2913        return Ok(());
2914    }
2915
2916    if all_cycles.is_empty() {
2917        println!("No circular dependencies found.");
2918        return Ok(());
2919    }
2920
2921    // Apply offset pagination
2922    let offset_val = offset.unwrap_or(0);
2923    let mut cycles: Vec<_> = all_cycles.into_iter().skip(offset_val).collect();
2924
2925    // Apply limit
2926    if let Some(lim) = limit {
2927        cycles.truncate(lim);
2928    }
2929
2930    if cycles.is_empty() {
2931        println!("No circular dependencies found at offset {}.", offset_val);
2932        return Ok(());
2933    }
2934
2935    let count = cycles.len();
2936    let has_more = offset_val + count < total_count;
2937
2938    match format {
2939        "json" => {
2940            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2941            let paths = deps_index.get_file_paths(&file_ids)?;
2942
2943            let results: Vec<_> = cycles.iter()
2944                .map(|cycle| {
2945                    let cycle_paths: Vec<_> = cycle.iter()
2946                        .filter_map(|id| paths.get(id).cloned())
2947                        .collect();
2948                    serde_json::json!({
2949                        "paths": cycle_paths,
2950                    })
2951                })
2952                .collect();
2953
2954            let output = serde_json::json!({
2955                "pagination": {
2956                    "total": total_count,
2957                    "count": count,
2958                    "offset": offset_val,
2959                    "limit": limit,
2960                    "has_more": has_more,
2961                },
2962                "results": results,
2963            });
2964
2965            let json_str = if pretty_json {
2966                serde_json::to_string_pretty(&output)?
2967            } else {
2968                serde_json::to_string(&output)?
2969            };
2970            println!("{}", json_str);
2971            if total_count > count {
2972                eprintln!("Found {} circular dependencies ({} total)", count, total_count);
2973            } else {
2974                eprintln!("Found {} circular dependencies", count);
2975            }
2976        }
2977        "tree" => {
2978            println!("Circular Dependencies Found:");
2979            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2980            let paths = deps_index.get_file_paths(&file_ids)?;
2981
2982            for (idx, cycle) in cycles.iter().enumerate() {
2983                println!("\nCycle {}:", idx + 1);
2984                for id in cycle {
2985                    if let Some(path) = paths.get(id) {
2986                        println!("  → {}", path);
2987                    }
2988                }
2989                // Show cycle completion
2990                if let Some(first_id) = cycle.first() {
2991                    if let Some(path) = paths.get(first_id) {
2992                        println!("  → {} (cycle completes)", path);
2993                    }
2994                }
2995            }
2996            if total_count > count {
2997                eprintln!("\nFound {} cycles ({} total)", count, total_count);
2998                if has_more {
2999                    eprintln!("Use --limit and --offset to paginate");
3000                }
3001            } else {
3002                eprintln!("\nFound {} cycles", count);
3003            }
3004        }
3005        "table" => {
3006            println!("Cycle  Files in Cycle");
3007            println!("-----  --------------");
3008            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
3009            let paths = deps_index.get_file_paths(&file_ids)?;
3010
3011            for (idx, cycle) in cycles.iter().enumerate() {
3012                let cycle_str = cycle.iter()
3013                    .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3014                    .collect::<Vec<_>>()
3015                    .join(" → ");
3016                println!("{:<5}  {}", idx + 1, cycle_str);
3017            }
3018            if total_count > count {
3019                eprintln!("\nFound {} cycles ({} total)", count, total_count);
3020                if has_more {
3021                    eprintln!("Use --limit and --offset to paginate");
3022                }
3023            } else {
3024                eprintln!("\nFound {} cycles", count);
3025            }
3026        }
3027        _ => {
3028            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3029        }
3030    }
3031
3032    Ok(())
3033}
3034
3035/// Handle --hotspots flag (most-imported files)
3036fn handle_deps_hotspots(
3037    deps_index: &crate::dependency::DependencyIndex,
3038    format: &str,
3039    pretty_json: bool,
3040    limit: Option<usize>,
3041    offset: Option<usize>,
3042    min_dependents: usize,
3043    count_only: bool,
3044    _plain: bool,
3045    sort: Option<String>,
3046) -> Result<()> {
3047    // Get all hotspots without limit first to track total count
3048    let mut all_hotspots = deps_index.find_hotspots(None, min_dependents)?;
3049
3050    // Apply sorting (default: descending - most imports first)
3051    let sort_order = sort.as_deref().unwrap_or("desc");
3052    match sort_order {
3053        "asc" => {
3054            // Ascending: least imports first
3055            all_hotspots.sort_by(|a, b| a.1.cmp(&b.1));
3056        }
3057        "desc" => {
3058            // Descending: most imports first (default)
3059            all_hotspots.sort_by(|a, b| b.1.cmp(&a.1));
3060        }
3061        _ => {
3062            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3063        }
3064    }
3065
3066    let total_count = all_hotspots.len();
3067
3068    if count_only {
3069        println!("Found {} hotspots with {}+ dependents", total_count, min_dependents);
3070        return Ok(());
3071    }
3072
3073    if all_hotspots.is_empty() {
3074        println!("No hotspots found.");
3075        return Ok(());
3076    }
3077
3078    // Apply offset pagination
3079    let offset_val = offset.unwrap_or(0);
3080    let mut hotspots: Vec<_> = all_hotspots.into_iter().skip(offset_val).collect();
3081
3082    // Apply limit
3083    if let Some(lim) = limit {
3084        hotspots.truncate(lim);
3085    }
3086
3087    if hotspots.is_empty() {
3088        println!("No hotspots found at offset {}.", offset_val);
3089        return Ok(());
3090    }
3091
3092    let count = hotspots.len();
3093    let has_more = offset_val + count < total_count;
3094
3095    let file_ids: Vec<i64> = hotspots.iter().map(|(id, _)| *id).collect();
3096    let paths = deps_index.get_file_paths(&file_ids)?;
3097
3098    match format {
3099        "json" => {
3100            let results: Vec<_> = hotspots.iter()
3101                .filter_map(|(id, import_count)| {
3102                    paths.get(id).map(|path| serde_json::json!({
3103                        "path": path,
3104                        "import_count": import_count,
3105                    }))
3106                })
3107                .collect();
3108
3109            let output = serde_json::json!({
3110                "pagination": {
3111                    "total": total_count,
3112                    "count": count,
3113                    "offset": offset_val,
3114                    "limit": limit,
3115                    "has_more": has_more,
3116                },
3117                "results": results,
3118            });
3119
3120            let json_str = if pretty_json {
3121                serde_json::to_string_pretty(&output)?
3122            } else {
3123                serde_json::to_string(&output)?
3124            };
3125            println!("{}", json_str);
3126            if total_count > count {
3127                eprintln!("Found {} hotspots ({} total)", count, total_count);
3128            } else {
3129                eprintln!("Found {} hotspots", count);
3130            }
3131        }
3132        "tree" => {
3133            println!("Hotspots (Most-Imported Files):");
3134            for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3135                if let Some(path) = paths.get(id) {
3136                    println!("  {}. {} ({} imports)", idx + 1, path, import_count);
3137                }
3138            }
3139            if total_count > count {
3140                eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3141                if has_more {
3142                    eprintln!("Use --limit and --offset to paginate");
3143                }
3144            } else {
3145                eprintln!("\nFound {} hotspots", count);
3146            }
3147        }
3148        "table" => {
3149            println!("Rank  Imports  File");
3150            println!("----  -------  ----");
3151            for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3152                if let Some(path) = paths.get(id) {
3153                    println!("{:<4}  {:<7}  {}", idx + 1, import_count, path);
3154                }
3155            }
3156            if total_count > count {
3157                eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3158                if has_more {
3159                    eprintln!("Use --limit and --offset to paginate");
3160                }
3161            } else {
3162                eprintln!("\nFound {} hotspots", count);
3163            }
3164        }
3165        _ => {
3166            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3167        }
3168    }
3169
3170    Ok(())
3171}
3172
3173/// Handle --unused flag (orphaned files)
3174fn handle_deps_unused(
3175    deps_index: &crate::dependency::DependencyIndex,
3176    format: &str,
3177    pretty_json: bool,
3178    limit: Option<usize>,
3179    offset: Option<usize>,
3180    count_only: bool,
3181    _plain: bool,
3182) -> Result<()> {
3183    let all_unused = deps_index.find_unused_files()?;
3184    let total_count = all_unused.len();
3185
3186    if count_only {
3187        println!("Found {} unused files", total_count);
3188        return Ok(());
3189    }
3190
3191    if all_unused.is_empty() {
3192        println!("No unused files found (all files have incoming dependencies).");
3193        return Ok(());
3194    }
3195
3196    // Apply offset pagination
3197    let offset_val = offset.unwrap_or(0);
3198    let mut unused: Vec<_> = all_unused.into_iter().skip(offset_val).collect();
3199
3200    if unused.is_empty() {
3201        println!("No unused files found at offset {}.", offset_val);
3202        return Ok(());
3203    }
3204
3205    // Apply limit
3206    if let Some(lim) = limit {
3207        unused.truncate(lim);
3208    }
3209
3210    let count = unused.len();
3211    let has_more = offset_val + count < total_count;
3212
3213    let paths = deps_index.get_file_paths(&unused)?;
3214
3215    match format {
3216        "json" => {
3217            // Return flat array of path strings (no "path" key wrapper)
3218            let results: Vec<String> = unused.iter()
3219                .filter_map(|id| paths.get(id).cloned())
3220                .collect();
3221
3222            let output = serde_json::json!({
3223                "pagination": {
3224                    "total": total_count,
3225                    "count": count,
3226                    "offset": offset_val,
3227                    "limit": limit,
3228                    "has_more": has_more,
3229                },
3230                "results": results,
3231            });
3232
3233            let json_str = if pretty_json {
3234                serde_json::to_string_pretty(&output)?
3235            } else {
3236                serde_json::to_string(&output)?
3237            };
3238            println!("{}", json_str);
3239            if total_count > count {
3240                eprintln!("Found {} unused files ({} total)", count, total_count);
3241            } else {
3242                eprintln!("Found {} unused files", count);
3243            }
3244        }
3245        "tree" => {
3246            println!("Unused Files (No Incoming Dependencies):");
3247            for (idx, id) in unused.iter().enumerate() {
3248                if let Some(path) = paths.get(id) {
3249                    println!("  {}. {}", idx + 1, path);
3250                }
3251            }
3252            if total_count > count {
3253                eprintln!("\nFound {} unused files ({} total)", count, total_count);
3254                if has_more {
3255                    eprintln!("Use --limit and --offset to paginate");
3256                }
3257            } else {
3258                eprintln!("\nFound {} unused files", count);
3259            }
3260        }
3261        "table" => {
3262            println!("Path");
3263            println!("----");
3264            for id in &unused {
3265                if let Some(path) = paths.get(id) {
3266                    println!("{}", path);
3267                }
3268            }
3269            if total_count > count {
3270                eprintln!("\nFound {} unused files ({} total)", count, total_count);
3271                if has_more {
3272                    eprintln!("Use --limit and --offset to paginate");
3273                }
3274            } else {
3275                eprintln!("\nFound {} unused files", count);
3276            }
3277        }
3278        _ => {
3279            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3280        }
3281    }
3282
3283    Ok(())
3284}
3285
3286/// Handle --islands flag (disconnected components)
3287fn handle_deps_islands(
3288    deps_index: &crate::dependency::DependencyIndex,
3289    format: &str,
3290    pretty_json: bool,
3291    limit: Option<usize>,
3292    offset: Option<usize>,
3293    min_island_size: usize,
3294    max_island_size: Option<usize>,
3295    count_only: bool,
3296    _plain: bool,
3297    sort: Option<String>,
3298) -> Result<()> {
3299    let all_islands = deps_index.find_islands()?;
3300    let total_components = all_islands.len();
3301
3302    // Get total file count from the cache for percentage calculation
3303    let cache = deps_index.get_cache();
3304    let total_files = cache.stats()?.total_files as usize;
3305
3306    // Calculate max_island_size default: min of 500 or 50% of total files
3307    let max_size = max_island_size.unwrap_or_else(|| {
3308        let fifty_percent = (total_files as f64 * 0.5) as usize;
3309        fifty_percent.min(500)
3310    });
3311
3312    // Filter islands by size
3313    let mut islands: Vec<_> = all_islands.into_iter()
3314        .filter(|island| {
3315            let size = island.len();
3316            size >= min_island_size && size <= max_size
3317        })
3318        .collect();
3319
3320    // Apply sorting (default: descending - largest islands first)
3321    let sort_order = sort.as_deref().unwrap_or("desc");
3322    match sort_order {
3323        "asc" => {
3324            // Ascending: smallest islands first
3325            islands.sort_by_key(|island| island.len());
3326        }
3327        "desc" => {
3328            // Descending: largest islands first (default)
3329            islands.sort_by_key(|island| std::cmp::Reverse(island.len()));
3330        }
3331        _ => {
3332            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3333        }
3334    }
3335
3336    let filtered_count = total_components - islands.len();
3337
3338    if count_only {
3339        if filtered_count > 0 {
3340            println!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3341                islands.len(), filtered_count, total_components, min_island_size, max_size);
3342        } else {
3343            println!("Found {} islands", islands.len());
3344        }
3345        return Ok(());
3346    }
3347
3348    // Apply offset pagination first
3349    let offset_val = offset.unwrap_or(0);
3350    if offset_val > 0 && offset_val < islands.len() {
3351        islands = islands.into_iter().skip(offset_val).collect();
3352    } else if offset_val >= islands.len() {
3353        if filtered_count > 0 {
3354            println!("No islands found at offset {} (filtered {} of {} total components by size: {}-{}).",
3355                offset_val, filtered_count, total_components, min_island_size, max_size);
3356        } else {
3357            println!("No islands found at offset {}.", offset_val);
3358        }
3359        return Ok(());
3360    }
3361
3362    // Apply limit to number of islands
3363    if let Some(lim) = limit {
3364        islands.truncate(lim);
3365    }
3366
3367    if islands.is_empty() {
3368        if filtered_count > 0 {
3369            println!("No islands found matching criteria (filtered {} of {} total components by size: {}-{}).",
3370                filtered_count, total_components, min_island_size, max_size);
3371        } else {
3372            println!("No islands found.");
3373        }
3374        return Ok(());
3375    }
3376
3377    // Get all file IDs from all islands and track pagination
3378    let count = islands.len();
3379    let has_more = offset_val + count < total_components - filtered_count;
3380
3381    let file_ids: Vec<i64> = islands.iter().flat_map(|island| island.iter()).copied().collect();
3382    let paths = deps_index.get_file_paths(&file_ids)?;
3383
3384    match format {
3385        "json" => {
3386            let results: Vec<_> = islands.iter()
3387                .enumerate()
3388                .map(|(idx, island)| {
3389                    let island_paths: Vec<_> = island.iter()
3390                        .filter_map(|id| paths.get(id).cloned())
3391                        .collect();
3392                    serde_json::json!({
3393                        "island_id": idx + 1,
3394                        "size": island.len(),
3395                        "paths": island_paths,
3396                    })
3397                })
3398                .collect();
3399
3400            let output = serde_json::json!({
3401                "pagination": {
3402                    "total": total_components - filtered_count,
3403                    "count": count,
3404                    "offset": offset_val,
3405                    "limit": limit,
3406                    "has_more": has_more,
3407                },
3408                "results": results,
3409            });
3410
3411            let json_str = if pretty_json {
3412                serde_json::to_string_pretty(&output)?
3413            } else {
3414                serde_json::to_string(&output)?
3415            };
3416            println!("{}", json_str);
3417            if filtered_count > 0 {
3418                eprintln!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3419                    count, filtered_count, total_components, min_island_size, max_size);
3420            } else if total_components - filtered_count > count {
3421                eprintln!("Found {} islands ({} total)", count, total_components - filtered_count);
3422            } else {
3423                eprintln!("Found {} islands (disconnected components)", count);
3424            }
3425        }
3426        "tree" => {
3427            println!("Islands (Disconnected Components):");
3428            for (idx, island) in islands.iter().enumerate() {
3429                println!("\nIsland {} ({} files):", idx + 1, island.len());
3430                for id in island {
3431                    if let Some(path) = paths.get(id) {
3432                        println!("  ├─ {}", path);
3433                    }
3434                }
3435            }
3436            if filtered_count > 0 {
3437                eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3438                    count, filtered_count, total_components, min_island_size, max_size);
3439                if has_more {
3440                    eprintln!("Use --limit and --offset to paginate");
3441                }
3442            } else if total_components - filtered_count > count {
3443                eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3444                if has_more {
3445                    eprintln!("Use --limit and --offset to paginate");
3446                }
3447            } else {
3448                eprintln!("\nFound {} islands", count);
3449            }
3450        }
3451        "table" => {
3452            println!("Island  Size  Files");
3453            println!("------  ----  -----");
3454            for (idx, island) in islands.iter().enumerate() {
3455                let island_files = island.iter()
3456                    .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3457                    .collect::<Vec<_>>()
3458                    .join(", ");
3459                println!("{:<6}  {:<4}  {}", idx + 1, island.len(), island_files);
3460            }
3461            if filtered_count > 0 {
3462                eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3463                    count, filtered_count, total_components, min_island_size, max_size);
3464                if has_more {
3465                    eprintln!("Use --limit and --offset to paginate");
3466                }
3467            } else if total_components - filtered_count > count {
3468                eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3469                if has_more {
3470                    eprintln!("Use --limit and --offset to paginate");
3471                }
3472            } else {
3473                eprintln!("\nFound {} islands", count);
3474            }
3475        }
3476        _ => {
3477            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3478        }
3479    }
3480
3481    Ok(())
3482}
3483