Skip to main content

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| {
908            Language::from_name(s).or_else(|| {
909                output::warn(&format!("Unknown language: '{}'. Supported: {}", s, Language::supported_names_help()));
910                None
911            })
912        })
913        .collect();
914
915    let config = IndexConfig {
916        languages: lang_filters,
917        ..Default::default()
918    };
919
920    let indexer = Indexer::new(cache, config);
921    // Show progress by default, unless quiet mode is enabled
922    let show_progress = !quiet;
923    let stats = indexer.index(path, show_progress)?;
924
925    // In quiet mode, suppress all output
926    if !quiet {
927        println!("Indexing complete!");
928        println!("  Files indexed: {}", stats.total_files);
929        println!("  Cache size: {}", format_bytes(stats.index_size_bytes));
930        println!("  Last updated: {}", stats.last_updated);
931
932        // Display language breakdown if we have indexed files
933        if !stats.files_by_language.is_empty() {
934            println!("\nFiles by language:");
935
936            // Sort languages by count (descending) for consistent output
937            let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
938            lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
939
940            // Calculate column widths
941            let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
942            let lang_width = max_lang_len.max(8); // At least "Language" header width
943
944            // Print table header
945            println!("  {:<width$}  Files  Lines", "Language", width = lang_width);
946            println!("  {}  -----  -------", "-".repeat(lang_width));
947
948            // Print rows
949            for (language, file_count) in lang_vec {
950                let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
951                println!("  {:<width$}  {:5}  {:7}",
952                    language, file_count, line_count,
953                    width = lang_width);
954            }
955        }
956    }
957
958    // Start background symbol indexing (if not already running)
959    if !crate::background_indexer::BackgroundIndexer::is_running(&cache_path) {
960        if !quiet {
961            println!("\nStarting background symbol indexing...");
962            println!("  Symbols will be cached for faster queries");
963            println!("  Check status with: rfx index status");
964        }
965
966        // Spawn detached background process for symbol indexing
967        // Pass the workspace root, not the .reflex directory
968        let current_exe = std::env::current_exe()
969            .context("Failed to get current executable path")?;
970
971        #[cfg(unix)]
972        {
973            std::process::Command::new(&current_exe)
974                .arg("index-symbols-internal")
975                .arg(path)
976                .stdin(std::process::Stdio::null())
977                .stdout(std::process::Stdio::null())
978                .stderr(std::process::Stdio::null())
979                .spawn()
980                .context("Failed to spawn background indexing process")?;
981        }
982
983        #[cfg(windows)]
984        {
985            use std::os::windows::process::CommandExt;
986            const CREATE_NO_WINDOW: u32 = 0x08000000;
987
988            std::process::Command::new(&current_exe)
989                .arg("index-symbols-internal")
990                .arg(&path)
991                .creation_flags(CREATE_NO_WINDOW)
992                .stdin(std::process::Stdio::null())
993                .stdout(std::process::Stdio::null())
994                .stderr(std::process::Stdio::null())
995                .spawn()
996                .context("Failed to spawn background indexing process")?;
997        }
998
999        log::debug!("Spawned background symbol indexing process");
1000    } else if !quiet {
1001        println!("\n⚠️  Background symbol indexing already in progress");
1002        println!("  Check status with: rfx index status");
1003    }
1004
1005    Ok(())
1006}
1007
1008/// Format bytes into human-readable size (KB, MB, GB, etc.)
1009fn format_bytes(bytes: u64) -> String {
1010    const KB: u64 = 1024;
1011    const MB: u64 = KB * 1024;
1012    const GB: u64 = MB * 1024;
1013    const TB: u64 = GB * 1024;
1014
1015    if bytes >= TB {
1016        format!("{:.2} TB", bytes as f64 / TB as f64)
1017    } else if bytes >= GB {
1018        format!("{:.2} GB", bytes as f64 / GB as f64)
1019    } else if bytes >= MB {
1020        format!("{:.2} MB", bytes as f64 / MB as f64)
1021    } else if bytes >= KB {
1022        format!("{:.2} KB", bytes as f64 / KB as f64)
1023    } else {
1024        format!("{} bytes", bytes)
1025    }
1026}
1027
1028/// Smart truncate preview to reduce token usage
1029/// Truncates at word boundary if possible, adds ellipsis if truncated
1030pub fn truncate_preview(preview: &str, max_length: usize) -> String {
1031    if preview.len() <= max_length {
1032        return preview.to_string();
1033    }
1034
1035    // Find a good break point (prefer word boundary)
1036    let truncate_at = preview.char_indices()
1037        .take(max_length)
1038        .filter(|(_, c)| c.is_whitespace())
1039        .last()
1040        .map(|(i, _)| i)
1041        .unwrap_or(max_length.min(preview.len()));
1042
1043    let mut truncated = preview[..truncate_at].to_string();
1044    truncated.push('…');
1045    truncated
1046}
1047
1048/// Handle the `query` subcommand
1049fn handle_query(
1050    pattern: String,
1051    symbols_flag: bool,
1052    lang: Option<String>,
1053    kind_str: Option<String>,
1054    use_ast: bool,
1055    use_regex: bool,
1056    as_json: bool,
1057    pretty_json: bool,
1058    ai_mode: bool,
1059    limit: Option<usize>,
1060    offset: Option<usize>,
1061    expand: bool,
1062    file_pattern: Option<String>,
1063    exact: bool,
1064    use_contains: bool,
1065    count_only: bool,
1066    timeout_secs: u64,
1067    plain: bool,
1068    glob_patterns: Vec<String>,
1069    exclude_patterns: Vec<String>,
1070    paths_only: bool,
1071    no_truncate: bool,
1072    all: bool,
1073    force: bool,
1074    include_dependencies: bool,
1075) -> Result<()> {
1076    log::info!("Starting query command");
1077
1078    // AI mode implies JSON output
1079    let as_json = as_json || ai_mode;
1080
1081    let cache = CacheManager::new(".");
1082    let engine = QueryEngine::new(cache);
1083
1084    // Parse and validate language filter
1085    let language = if let Some(lang_str) = lang.as_deref() {
1086        match Language::from_name(lang_str) {
1087            Some(l) => Some(l),
1088            None => anyhow::bail!(
1089                "Unknown language: '{}'\n\nSupported languages:\n  {}\n\nExample: rfx query \"pattern\" --lang rust",
1090                lang_str, Language::supported_names_help()
1091            ),
1092        }
1093    } else {
1094        None
1095    };
1096
1097    // Parse symbol kind - try exact match first (case-insensitive), then treat as Unknown
1098    let kind = kind_str.as_deref().and_then(|s| {
1099        // Try parsing with proper case (PascalCase for SymbolKind)
1100        let capitalized = {
1101            let mut chars = s.chars();
1102            match chars.next() {
1103                None => String::new(),
1104                Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1105            }
1106        };
1107
1108        capitalized.parse::<crate::models::SymbolKind>()
1109            .ok()
1110            .or_else(|| {
1111                // If not a known kind, treat as Unknown for flexibility
1112                log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1113                Some(crate::models::SymbolKind::Unknown(s.to_string()))
1114            })
1115    });
1116
1117    // Smart behavior: --kind implies --symbols
1118    let symbols_mode = symbols_flag || kind.is_some();
1119
1120    // Smart limit handling:
1121    // 1. If --count is set: no limit (count should always show total)
1122    // 2. If --all is set: no limit (None)
1123    // 3. If --limit 0 is set: no limit (None) - treat 0 as "unlimited"
1124    // 4. If --paths is set and user didn't specify --limit: no limit (None)
1125    // 5. If user specified --limit: use that value
1126    // 6. Otherwise: use default limit of 100
1127    let final_limit = if count_only {
1128        None  // --count always shows total count, no pagination
1129    } else if all {
1130        None  // --all means no limit
1131    } else if limit == Some(0) {
1132        None  // --limit 0 means no limit (unlimited results)
1133    } else if paths_only && limit.is_none() {
1134        None  // --paths without explicit --limit means no limit
1135    } else if let Some(user_limit) = limit {
1136        Some(user_limit)  // Use user-specified limit
1137    } else {
1138        Some(100)  // Default: limit to 100 results for token efficiency
1139    };
1140
1141    // Validate AST query requirements
1142    if use_ast && language.is_none() {
1143        anyhow::bail!(
1144            "AST pattern matching requires a language to be specified.\n\
1145             \n\
1146             Use --lang to specify the language for tree-sitter parsing.\n\
1147             \n\
1148             Supported languages for AST queries:\n\
1149             • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
1150             \n\
1151             Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
1152             \n\
1153             WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
1154             \n\
1155             Examples:\n\
1156             • rfx query \"(function_definition) @fn\" --ast --lang python\n\
1157             • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
1158        );
1159    }
1160
1161    // VALIDATION: Check for conflicting or problematic flag combinations
1162    // Only show warnings/errors in non-JSON mode (avoid breaking parsers)
1163    if !as_json {
1164        let mut has_errors = false;
1165
1166        // ERROR: Mutually exclusive pattern matching modes
1167        if use_regex && use_contains {
1168            eprintln!("{}", "ERROR: Cannot use --regex and --contains together.".red().bold());
1169            eprintln!("  {} --regex for pattern matching (alternation, wildcards, etc.)", "•".dimmed());
1170            eprintln!("  {} --contains for substring matching (expansive search)", "•".dimmed());
1171            eprintln!("\n  {} Choose one based on your needs:", "Tip:".cyan().bold());
1172            eprintln!("    {} for OR logic: --regex", "pattern1|pattern2".yellow());
1173            eprintln!("    {} for substring: --contains", "partial_text".yellow());
1174            has_errors = true;
1175        }
1176
1177        // ERROR: Contradictory matching requirements
1178        if exact && use_contains {
1179            eprintln!("{}", "ERROR: Cannot use --exact and --contains together (contradictory).".red().bold());
1180            eprintln!("  {} --exact requires exact symbol name match", "•".dimmed());
1181            eprintln!("  {} --contains allows substring matching", "•".dimmed());
1182            has_errors = true;
1183        }
1184
1185        // WARNING: Redundant file filtering
1186        if file_pattern.is_some() && !glob_patterns.is_empty() {
1187            eprintln!("{}", "WARNING: Both --file and --glob specified.".yellow().bold());
1188            eprintln!("  {} --file does substring matching on file paths", "•".dimmed());
1189            eprintln!("  {} --glob does pattern matching with wildcards", "•".dimmed());
1190            eprintln!("  {} Both filters will apply (AND condition)", "Note:".dimmed());
1191            eprintln!("\n  {} Usually you only need one:", "Tip:".cyan().bold());
1192            eprintln!("    {} for simple matching", "--file User.php".yellow());
1193            eprintln!("    {} for pattern matching", "--glob src/**/*.php".yellow());
1194        }
1195
1196        // INFO: Detect potentially problematic glob patterns
1197        for pattern in &glob_patterns {
1198            // Check for literal quotes in pattern
1199            if (pattern.starts_with('\'') && pattern.ends_with('\'')) ||
1200               (pattern.starts_with('"') && pattern.ends_with('"')) {
1201                eprintln!("{}",
1202                    format!("WARNING: Glob pattern contains quotes: {}", pattern).yellow().bold()
1203                );
1204                eprintln!("  {} Shell quotes should not be part of the pattern", "Note:".dimmed());
1205                eprintln!("  {} --glob src/**/*.rs", "Correct:".green());
1206                eprintln!("  {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
1207            }
1208
1209            // Suggest using ** instead of * for recursive matching
1210            if pattern.contains("*/") && !pattern.contains("**/") {
1211                eprintln!("{}",
1212                    format!("INFO: Glob '{}' uses * (matches one directory level)", pattern).cyan()
1213                );
1214                eprintln!("  {} Use ** for recursive matching across subdirectories", "Tip:".cyan().bold());
1215                eprintln!("    {} → matches files in Models/ only", "app/Models/*.php".yellow());
1216                eprintln!("    {} → matches files in Models/ and subdirs", "app/Models/**/*.php".green());
1217            }
1218        }
1219
1220        if has_errors {
1221            anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
1222        }
1223    }
1224
1225    let filter = QueryFilter {
1226        language,
1227        kind,
1228        use_ast,
1229        use_regex,
1230        limit: final_limit,
1231        symbols_mode,
1232        expand,
1233        file_pattern,
1234        exact,
1235        use_contains,
1236        timeout_secs,
1237        glob_patterns: glob_patterns.clone(),
1238        exclude_patterns,
1239        paths_only,
1240        offset,
1241        force,
1242        suppress_output: as_json,  // Suppress warnings in JSON mode
1243        include_dependencies,
1244        ..Default::default()
1245    };
1246
1247    // Measure query time
1248    let start = Instant::now();
1249
1250    // Execute query and get pagination metadata
1251    // Handle errors specially for JSON output mode
1252    let (query_response, mut flat_results, total_results, has_more) = if use_ast {
1253        // AST query: pattern is the S-expression, scan all files
1254        match engine.search_ast_all_files(&pattern, filter.clone()) {
1255            Ok(ast_results) => {
1256                let count = ast_results.len();
1257                (None, ast_results, count, false)
1258            }
1259            Err(e) => {
1260                if as_json {
1261                    // Output error as JSON
1262                    let error_response = serde_json::json!({
1263                        "error": e.to_string(),
1264                        "query_too_broad": e.to_string().contains("Query too broad")
1265                    });
1266                    let json_output = if pretty_json {
1267                        serde_json::to_string_pretty(&error_response)?
1268                    } else {
1269                        serde_json::to_string(&error_response)?
1270                    };
1271                    println!("{}", json_output);
1272                    std::process::exit(1);
1273                } else {
1274                    return Err(e);
1275                }
1276            }
1277        }
1278    } else {
1279        // Use metadata-aware search for all queries (to get pagination info)
1280        match engine.search_with_metadata(&pattern, filter.clone()) {
1281            Ok(response) => {
1282                let total = response.pagination.total;
1283                let has_more = response.pagination.has_more;
1284
1285                // Flatten grouped results to SearchResult vec for plain text formatting
1286                let flat = response.results.iter()
1287                    .flat_map(|file_group| {
1288                        file_group.matches.iter().map(move |m| {
1289                            crate::models::SearchResult {
1290                                path: file_group.path.clone(),
1291                                lang: crate::models::Language::Unknown, // Will be set by formatter if needed
1292                                kind: m.kind.clone(),
1293                                symbol: m.symbol.clone(),
1294                                span: m.span.clone(),
1295                                preview: m.preview.clone(),
1296                                dependencies: file_group.dependencies.clone(),
1297                            }
1298                        })
1299                    })
1300                    .collect();
1301
1302                (Some(response), flat, total, has_more)
1303            }
1304            Err(e) => {
1305                if as_json {
1306                    // Output error as JSON
1307                    let error_response = serde_json::json!({
1308                        "error": e.to_string(),
1309                        "query_too_broad": e.to_string().contains("Query too broad")
1310                    });
1311                    let json_output = if pretty_json {
1312                        serde_json::to_string_pretty(&error_response)?
1313                    } else {
1314                        serde_json::to_string(&error_response)?
1315                    };
1316                    println!("{}", json_output);
1317                    std::process::exit(1);
1318                } else {
1319                    return Err(e);
1320                }
1321            }
1322        }
1323    };
1324
1325    // Apply preview truncation unless --no-truncate is set
1326    if !no_truncate {
1327        const MAX_PREVIEW_LENGTH: usize = 100;
1328        for result in &mut flat_results {
1329            result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
1330        }
1331    }
1332
1333    let elapsed = start.elapsed();
1334
1335    // Format timing string
1336    let timing_str = if elapsed.as_millis() < 1 {
1337        format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
1338    } else {
1339        format!("{}ms", elapsed.as_millis())
1340    };
1341
1342    if as_json {
1343        if count_only {
1344            // Count-only JSON mode: output simple count object
1345            let count_response = serde_json::json!({
1346                "count": total_results,
1347                "timing_ms": elapsed.as_millis()
1348            });
1349            let json_output = if pretty_json {
1350                serde_json::to_string_pretty(&count_response)?
1351            } else {
1352                serde_json::to_string(&count_response)?
1353            };
1354            println!("{}", json_output);
1355        } else if paths_only {
1356            // Paths-only JSON mode: output array of {path, line} objects
1357            let locations: Vec<serde_json::Value> = flat_results.iter()
1358                .map(|r| serde_json::json!({
1359                    "path": r.path,
1360                    "line": r.span.start_line
1361                }))
1362                .collect();
1363            let json_output = if pretty_json {
1364                serde_json::to_string_pretty(&locations)?
1365            } else {
1366                serde_json::to_string(&locations)?
1367            };
1368            println!("{}", json_output);
1369            eprintln!("Found {} unique files in {}", locations.len(), timing_str);
1370        } else {
1371            // Get or build QueryResponse for JSON output
1372            let mut response = if let Some(resp) = query_response {
1373                // We already have a response from search_with_metadata
1374                // Apply truncation to the response (the flat_results were already truncated)
1375                let mut resp = resp;
1376
1377                // Apply truncation to results
1378                if !no_truncate {
1379                    const MAX_PREVIEW_LENGTH: usize = 100;
1380                    for file_group in resp.results.iter_mut() {
1381                        for m in file_group.matches.iter_mut() {
1382                            m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
1383                        }
1384                    }
1385                }
1386
1387                resp
1388            } else {
1389                // For AST queries, build a response with minimal metadata
1390                // Group flat results by file path
1391                use crate::models::{PaginationInfo, IndexStatus, FileGroupedResult, MatchResult};
1392                use std::collections::HashMap;
1393
1394                let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
1395                for result in &flat_results {
1396                    grouped
1397                        .entry(result.path.clone())
1398                        .or_default()
1399                        .push(result.clone());
1400                }
1401
1402                // Load ContentReader for extracting context lines
1403                use crate::content_store::ContentReader;
1404                let local_cache = CacheManager::new(".");
1405                let content_path = local_cache.path().join("content.bin");
1406                let content_reader_opt = ContentReader::open(&content_path).ok();
1407
1408                let mut file_results: Vec<FileGroupedResult> = grouped
1409                    .into_iter()
1410                    .map(|(path, file_matches)| {
1411                        // Get file_id for context extraction
1412                        // Note: We use ContentReader's get_file_id_by_path() which returns array indices,
1413                        // not database file_ids (which are AUTO INCREMENT values)
1414                        let normalized_path = path.strip_prefix("./").unwrap_or(&path);
1415                        let file_id_for_context = if let Some(reader) = &content_reader_opt {
1416                            reader.get_file_id_by_path(normalized_path)
1417                        } else {
1418                            None
1419                        };
1420
1421                        let matches: Vec<MatchResult> = file_matches
1422                            .into_iter()
1423                            .map(|r| {
1424                                // Extract context lines (default: 3 lines before and after)
1425                                let (context_before, context_after) = if let (Some(reader), Some(fid)) = (&content_reader_opt, file_id_for_context) {
1426                                    reader.get_context_by_line(fid as u32, r.span.start_line, 3)
1427                                        .unwrap_or_else(|_| (vec![], vec![]))
1428                                } else {
1429                                    (vec![], vec![])
1430                                };
1431
1432                                MatchResult {
1433                                    kind: r.kind,
1434                                    symbol: r.symbol,
1435                                    span: r.span,
1436                                    preview: r.preview,
1437                                    context_before,
1438                                    context_after,
1439                                }
1440                            })
1441                            .collect();
1442                        FileGroupedResult {
1443                            path,
1444                            dependencies: None,
1445                            matches,
1446                        }
1447                    })
1448                    .collect();
1449
1450                // Sort by path for deterministic output
1451                file_results.sort_by(|a, b| a.path.cmp(&b.path));
1452
1453                crate::models::QueryResponse {
1454                    ai_instruction: None,  // Will be populated below if ai_mode is true
1455                    status: IndexStatus::Fresh,
1456                    can_trust_results: true,
1457                    warning: None,
1458                    pagination: PaginationInfo {
1459                        total: flat_results.len(),
1460                        count: flat_results.len(),
1461                        offset: offset.unwrap_or(0),
1462                        limit,
1463                        has_more: false, // AST already applied pagination
1464                    },
1465                    results: file_results,
1466                }
1467            };
1468
1469            // Generate AI instruction if in AI mode
1470            if ai_mode {
1471                let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1472
1473                response.ai_instruction = crate::query::generate_ai_instruction(
1474                    result_count,
1475                    response.pagination.total,
1476                    response.pagination.has_more,
1477                    symbols_mode,
1478                    paths_only,
1479                    use_ast,
1480                    use_regex,
1481                    language.is_some(),
1482                    !glob_patterns.is_empty(),
1483                    exact,
1484                );
1485            }
1486
1487            let json_output = if pretty_json {
1488                serde_json::to_string_pretty(&response)?
1489            } else {
1490                serde_json::to_string(&response)?
1491            };
1492            println!("{}", json_output);
1493
1494            let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
1495            eprintln!("Found {} results in {}", result_count, timing_str);
1496        }
1497    } else {
1498        // Standard output with formatting
1499        if count_only {
1500            println!("Found {} results in {}", flat_results.len(), timing_str);
1501            return Ok(());
1502        }
1503
1504        if paths_only {
1505            // Paths-only plain text mode: output one path per line
1506            if flat_results.is_empty() {
1507                eprintln!("No results found (searched in {}).", timing_str);
1508            } else {
1509                for result in &flat_results {
1510                    println!("{}", result.path);
1511                }
1512                eprintln!("Found {} unique files in {}", flat_results.len(), timing_str);
1513            }
1514        } else {
1515            // Standard result formatting
1516            if flat_results.is_empty() {
1517                println!("No results found (searched in {}).", timing_str);
1518            } else {
1519                // Use formatter for pretty output
1520                let formatter = crate::formatter::OutputFormatter::new(plain);
1521                formatter.format_results(&flat_results, &pattern)?;
1522
1523                // Print summary at the bottom with pagination details
1524                if total_results > flat_results.len() {
1525                    // Results were paginated - show detailed count
1526                    println!("\nFound {} results ({} total) in {}", flat_results.len(), total_results, timing_str);
1527                    // Show pagination hint if there are more results available
1528                    if has_more {
1529                        println!("Use --limit and --offset to paginate");
1530                    }
1531                } else {
1532                    // All results shown - simple count
1533                    println!("\nFound {} results in {}", flat_results.len(), timing_str);
1534                }
1535            }
1536        }
1537    }
1538
1539    Ok(())
1540}
1541
1542/// Handle the `serve` subcommand
1543fn handle_serve(port: u16, host: String) -> Result<()> {
1544    log::info!("Starting HTTP server on {}:{}", host, port);
1545
1546    println!("Starting Reflex HTTP server...");
1547    println!("  Address: http://{}:{}", host, port);
1548    println!("\nEndpoints:");
1549    println!("  GET  /query?q=<pattern>&lang=<lang>&kind=<kind>&limit=<n>&symbols=true&regex=true&exact=true&contains=true&expand=true&file=<pattern>&timeout=<secs>&glob=<pattern>&exclude=<pattern>&paths=true&dependencies=true");
1550    println!("  GET  /stats");
1551    println!("  POST /index");
1552    println!("\nPress Ctrl+C to stop.");
1553
1554    // Start the server using tokio runtime
1555    let runtime = tokio::runtime::Runtime::new()?;
1556    runtime.block_on(async {
1557        run_server(port, host).await
1558    })
1559}
1560
1561/// Run the HTTP server
1562async fn run_server(port: u16, host: String) -> Result<()> {
1563    use axum::{
1564        extract::{Query as AxumQuery, State},
1565        http::StatusCode,
1566        response::{IntoResponse, Json},
1567        routing::{get, post},
1568        Router,
1569    };
1570    use tower_http::cors::{CorsLayer, Any};
1571    use std::sync::Arc;
1572
1573    // Server state shared across requests
1574    #[derive(Clone)]
1575    struct AppState {
1576        cache_path: String,
1577    }
1578
1579    // Query parameters for GET /query
1580    #[derive(Debug, serde::Deserialize)]
1581    struct QueryParams {
1582        q: String,
1583        #[serde(default)]
1584        lang: Option<String>,
1585        #[serde(default)]
1586        kind: Option<String>,
1587        #[serde(default)]
1588        limit: Option<usize>,
1589        #[serde(default)]
1590        offset: Option<usize>,
1591        #[serde(default)]
1592        symbols: bool,
1593        #[serde(default)]
1594        regex: bool,
1595        #[serde(default)]
1596        exact: bool,
1597        #[serde(default)]
1598        contains: bool,
1599        #[serde(default)]
1600        expand: bool,
1601        #[serde(default)]
1602        file: Option<String>,
1603        #[serde(default = "default_timeout")]
1604        timeout: u64,
1605        #[serde(default)]
1606        glob: Vec<String>,
1607        #[serde(default)]
1608        exclude: Vec<String>,
1609        #[serde(default)]
1610        paths: bool,
1611        #[serde(default)]
1612        force: bool,
1613        #[serde(default)]
1614        dependencies: bool,
1615    }
1616
1617    // Default timeout for HTTP queries (30 seconds)
1618    fn default_timeout() -> u64 {
1619        30
1620    }
1621
1622    // Request body for POST /index
1623    #[derive(Debug, serde::Deserialize)]
1624    struct IndexRequest {
1625        #[serde(default)]
1626        force: bool,
1627        #[serde(default)]
1628        languages: Vec<String>,
1629    }
1630
1631    // GET /query endpoint
1632    async fn handle_query_endpoint(
1633        State(state): State<Arc<AppState>>,
1634        AxumQuery(params): AxumQuery<QueryParams>,
1635    ) -> Result<Json<crate::models::QueryResponse>, (StatusCode, String)> {
1636        log::info!("Query request: pattern={}", params.q);
1637
1638        let cache = CacheManager::new(&state.cache_path);
1639        let engine = QueryEngine::new(cache);
1640
1641        // Parse language filter
1642        let language = if let Some(lang_str) = params.lang.as_deref() {
1643            match Language::from_name(lang_str) {
1644                Some(l) => Some(l),
1645                None => return Err((
1646                    StatusCode::BAD_REQUEST,
1647                    format!("Unknown language '{}'. Supported: {}", lang_str, Language::supported_names_help())
1648                )),
1649            }
1650        } else {
1651            None
1652        };
1653
1654        // Parse symbol kind
1655        let kind = params.kind.as_deref().and_then(|s| {
1656            let capitalized = {
1657                let mut chars = s.chars();
1658                match chars.next() {
1659                    None => String::new(),
1660                    Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
1661                }
1662            };
1663
1664            capitalized.parse::<crate::models::SymbolKind>()
1665                .ok()
1666                .or_else(|| {
1667                    log::debug!("Treating '{}' as unknown symbol kind for filtering", s);
1668                    Some(crate::models::SymbolKind::Unknown(s.to_string()))
1669                })
1670        });
1671
1672        // Smart behavior: --kind implies --symbols
1673        let symbols_mode = params.symbols || kind.is_some();
1674
1675        // Smart limit handling (same as CLI and MCP)
1676        let final_limit = if params.paths && params.limit.is_none() {
1677            None  // --paths without explicit limit means no limit
1678        } else if let Some(user_limit) = params.limit {
1679            Some(user_limit)  // Use user-specified limit
1680        } else {
1681            Some(100)  // Default: limit to 100 results for token efficiency
1682        };
1683
1684        let filter = QueryFilter {
1685            language,
1686            kind,
1687            use_ast: false,
1688            use_regex: params.regex,
1689            limit: final_limit,
1690            symbols_mode,
1691            expand: params.expand,
1692            file_pattern: params.file,
1693            exact: params.exact,
1694            use_contains: params.contains,
1695            timeout_secs: params.timeout,
1696            glob_patterns: params.glob,
1697            exclude_patterns: params.exclude,
1698            paths_only: params.paths,
1699            offset: params.offset,
1700            force: params.force,
1701            suppress_output: true,  // HTTP API always returns JSON, suppress warnings
1702            include_dependencies: params.dependencies,
1703            ..Default::default()
1704        };
1705
1706        match engine.search_with_metadata(&params.q, filter) {
1707            Ok(response) => Ok(Json(response)),
1708            Err(e) => {
1709                log::error!("Query error: {}", e);
1710                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)))
1711            }
1712        }
1713    }
1714
1715    // GET /stats endpoint
1716    async fn handle_stats_endpoint(
1717        State(state): State<Arc<AppState>>,
1718    ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1719        log::info!("Stats request");
1720
1721        let cache = CacheManager::new(&state.cache_path);
1722
1723        if !cache.exists() {
1724            return Err((StatusCode::NOT_FOUND, "No index found. Run 'rfx index' first.".to_string()));
1725        }
1726
1727        match cache.stats() {
1728            Ok(stats) => Ok(Json(stats)),
1729            Err(e) => {
1730                log::error!("Stats error: {}", e);
1731                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get stats: {}", e)))
1732            }
1733        }
1734    }
1735
1736    // POST /index endpoint
1737    async fn handle_index_endpoint(
1738        State(state): State<Arc<AppState>>,
1739        Json(req): Json<IndexRequest>,
1740    ) -> Result<Json<crate::models::IndexStats>, (StatusCode, String)> {
1741        log::info!("Index request: force={}, languages={:?}", req.force, req.languages);
1742
1743        let cache = CacheManager::new(&state.cache_path);
1744
1745        if req.force {
1746            log::info!("Force rebuild requested, clearing existing cache");
1747            if let Err(e) = cache.clear() {
1748                return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to clear cache: {}", e)));
1749            }
1750        }
1751
1752        // Parse language filters
1753        let lang_filters: Vec<Language> = req.languages
1754            .iter()
1755            .filter_map(|s| match s.to_lowercase().as_str() {
1756                "rust" | "rs" => Some(Language::Rust),
1757                "python" | "py" => Some(Language::Python),
1758                "javascript" | "js" => Some(Language::JavaScript),
1759                "typescript" | "ts" => Some(Language::TypeScript),
1760                "vue" => Some(Language::Vue),
1761                "svelte" => Some(Language::Svelte),
1762                "go" => Some(Language::Go),
1763                "java" => Some(Language::Java),
1764                "php" => Some(Language::PHP),
1765                "c" => Some(Language::C),
1766                "cpp" | "c++" => Some(Language::Cpp),
1767                _ => {
1768                    log::warn!("Unknown language: {}", s);
1769                    None
1770                }
1771            })
1772            .collect();
1773
1774        let config = IndexConfig {
1775            languages: lang_filters,
1776            ..Default::default()
1777        };
1778
1779        let indexer = Indexer::new(cache, config);
1780        let path = std::path::PathBuf::from(&state.cache_path);
1781
1782        match indexer.index(&path, false) {
1783            Ok(stats) => Ok(Json(stats)),
1784            Err(e) => {
1785                log::error!("Index error: {}", e);
1786                Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Indexing failed: {}", e)))
1787            }
1788        }
1789    }
1790
1791    // Health check endpoint
1792    async fn handle_health() -> impl IntoResponse {
1793        (StatusCode::OK, "Reflex is running")
1794    }
1795
1796    // Create shared state
1797    let state = Arc::new(AppState {
1798        cache_path: ".".to_string(),
1799    });
1800
1801    // Configure CORS
1802    let cors = CorsLayer::new()
1803        .allow_origin(Any)
1804        .allow_methods(Any)
1805        .allow_headers(Any);
1806
1807    // Build the router
1808    let app = Router::new()
1809        .route("/query", get(handle_query_endpoint))
1810        .route("/stats", get(handle_stats_endpoint))
1811        .route("/index", post(handle_index_endpoint))
1812        .route("/health", get(handle_health))
1813        .layer(cors)
1814        .with_state(state);
1815
1816    // Bind to the specified address
1817    let addr = format!("{}:{}", host, port);
1818    let listener = tokio::net::TcpListener::bind(&addr).await
1819        .map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", addr, e))?;
1820
1821    log::info!("Server listening on {}", addr);
1822
1823    // Run the server
1824    axum::serve(listener, app)
1825        .await
1826        .map_err(|e| anyhow::anyhow!("Server error: {}", e))?;
1827
1828    Ok(())
1829}
1830
1831/// Handle the `stats` subcommand
1832fn handle_stats(as_json: bool, pretty_json: bool) -> Result<()> {
1833    log::info!("Showing index statistics");
1834
1835    let cache = CacheManager::new(".");
1836
1837    if !cache.exists() {
1838        anyhow::bail!(
1839            "No index found in current directory.\n\
1840             \n\
1841             Run 'rfx index' to build the code search index first.\n\
1842             This will scan all files in the current directory and create a .reflex/ cache.\n\
1843             \n\
1844             Example:\n\
1845             $ rfx index          # Index current directory\n\
1846             $ rfx stats          # Show index statistics"
1847        );
1848    }
1849
1850    let stats = cache.stats()?;
1851
1852    if as_json {
1853        let json_output = if pretty_json {
1854            serde_json::to_string_pretty(&stats)?
1855        } else {
1856            serde_json::to_string(&stats)?
1857        };
1858        println!("{}", json_output);
1859    } else {
1860        println!("Reflex Index Statistics");
1861        println!("=======================");
1862
1863        // Show git branch info if in git repo, or (None) if not
1864        let root = std::env::current_dir()?;
1865        if crate::git::is_git_repo(&root) {
1866            match crate::git::get_git_state(&root) {
1867                Ok(git_state) => {
1868                    let dirty_indicator = if git_state.dirty { " (uncommitted changes)" } else { " (clean)" };
1869                    println!("Branch:         {}@{}{}",
1870                             git_state.branch,
1871                             &git_state.commit[..7],
1872                             dirty_indicator);
1873
1874                    // Check if current branch is indexed
1875                    match cache.get_branch_info(&git_state.branch) {
1876                        Ok(branch_info) => {
1877                            if branch_info.commit_sha != git_state.commit {
1878                                println!("                ⚠️  Index commit mismatch (indexed: {})",
1879                                         &branch_info.commit_sha[..7]);
1880                            }
1881                            if git_state.dirty && !branch_info.is_dirty {
1882                                println!("                ⚠️  Uncommitted changes not indexed");
1883                            }
1884                        }
1885                        Err(_) => {
1886                            println!("                ⚠️  Branch not indexed");
1887                        }
1888                    }
1889                }
1890                Err(e) => {
1891                    log::warn!("Failed to get git state: {}", e);
1892                }
1893            }
1894        } else {
1895            // Not a git repository - show (None)
1896            println!("Branch:         (None)");
1897        }
1898
1899        println!("Files indexed:  {}", stats.total_files);
1900        println!("Index size:     {} bytes", stats.index_size_bytes);
1901        println!("Last updated:   {}", stats.last_updated);
1902
1903        // Display language breakdown if we have indexed files
1904        if !stats.files_by_language.is_empty() {
1905            println!("\nFiles by language:");
1906
1907            // Sort languages by count (descending) for consistent output
1908            let mut lang_vec: Vec<_> = stats.files_by_language.iter().collect();
1909            lang_vec.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
1910
1911            // Calculate column widths
1912            let max_lang_len = lang_vec.iter().map(|(lang, _)| lang.len()).max().unwrap_or(8);
1913            let lang_width = max_lang_len.max(8); // At least "Language" header width
1914
1915            // Print table header
1916            println!("  {:<width$}  Files  Lines", "Language", width = lang_width);
1917            println!("  {}  -----  -------", "-".repeat(lang_width));
1918
1919            // Print rows
1920            for (language, file_count) in lang_vec {
1921                let line_count = stats.lines_by_language.get(language).copied().unwrap_or(0);
1922                println!("  {:<width$}  {:5}  {:7}",
1923                    language, file_count, line_count,
1924                    width = lang_width);
1925            }
1926        }
1927    }
1928
1929    Ok(())
1930}
1931
1932/// Handle the `clear` subcommand
1933fn handle_clear(skip_confirm: bool) -> Result<()> {
1934    let cache = CacheManager::new(".");
1935
1936    if !cache.exists() {
1937        println!("No cache to clear.");
1938        return Ok(());
1939    }
1940
1941    if !skip_confirm {
1942        println!("This will delete the local Reflex cache at: {:?}", cache.path());
1943        print!("Are you sure? [y/N] ");
1944        use std::io::{self, Write};
1945        io::stdout().flush()?;
1946
1947        let mut input = String::new();
1948        io::stdin().read_line(&mut input)?;
1949
1950        if !input.trim().eq_ignore_ascii_case("y") {
1951            println!("Cancelled.");
1952            return Ok(());
1953        }
1954    }
1955
1956    cache.clear()?;
1957    println!("Cache cleared successfully.");
1958
1959    Ok(())
1960}
1961
1962/// Handle the `list-files` subcommand
1963fn handle_list_files(as_json: bool, pretty_json: bool) -> Result<()> {
1964    let cache = CacheManager::new(".");
1965
1966    if !cache.exists() {
1967        anyhow::bail!(
1968            "No index found in current directory.\n\
1969             \n\
1970             Run 'rfx index' to build the code search index first.\n\
1971             This will scan all files in the current directory and create a .reflex/ cache.\n\
1972             \n\
1973             Example:\n\
1974             $ rfx index            # Index current directory\n\
1975             $ rfx list-files       # List indexed files"
1976        );
1977    }
1978
1979    let files = cache.list_files()?;
1980
1981    if as_json {
1982        let json_output = if pretty_json {
1983            serde_json::to_string_pretty(&files)?
1984        } else {
1985            serde_json::to_string(&files)?
1986        };
1987        println!("{}", json_output);
1988    } else if files.is_empty() {
1989        println!("No files indexed yet.");
1990    } else {
1991        println!("Indexed Files ({} total):", files.len());
1992        println!();
1993        for file in files {
1994            println!("  {} ({})",
1995                     file.path,
1996                     file.language);
1997        }
1998    }
1999
2000    Ok(())
2001}
2002
2003/// Handle the `watch` subcommand
2004fn handle_watch(path: PathBuf, debounce_ms: u64, quiet: bool) -> Result<()> {
2005    log::info!("Starting watch mode for {:?}", path);
2006
2007    // Validate debounce range (5s - 30s)
2008    if !(5000..=30000).contains(&debounce_ms) {
2009        anyhow::bail!(
2010            "Debounce must be between 5000ms (5s) and 30000ms (30s). Got: {}ms",
2011            debounce_ms
2012        );
2013    }
2014
2015    if !quiet {
2016        println!("Starting Reflex watch mode...");
2017        println!("  Directory: {}", path.display());
2018        println!("  Debounce: {}ms ({}s)", debounce_ms, debounce_ms / 1000);
2019        println!("  Press Ctrl+C to stop.\n");
2020    }
2021
2022    // Setup cache
2023    let cache = CacheManager::new(&path);
2024
2025    // Initial index if cache doesn't exist
2026    if !cache.exists() {
2027        if !quiet {
2028            println!("No index found, running initial index...");
2029        }
2030        let config = IndexConfig::default();
2031        let indexer = Indexer::new(cache, config);
2032        indexer.index(&path, !quiet)?;
2033        if !quiet {
2034            println!("Initial index complete. Now watching for changes...\n");
2035        }
2036    }
2037
2038    // Create indexer for watcher
2039    let cache = CacheManager::new(&path);
2040    let config = IndexConfig::default();
2041    let indexer = Indexer::new(cache, config);
2042
2043    // Start watcher
2044    let watch_config = crate::watcher::WatchConfig {
2045        debounce_ms,
2046        quiet,
2047    };
2048
2049    crate::watcher::watch(&path, indexer, watch_config)?;
2050
2051    Ok(())
2052}
2053
2054/// Handle interactive mode (default when no command is given)
2055fn handle_interactive() -> Result<()> {
2056    log::info!("Launching interactive mode");
2057    crate::interactive::run_interactive()
2058}
2059
2060/// Handle the `mcp` subcommand
2061fn handle_mcp() -> Result<()> {
2062    log::info!("Starting MCP server");
2063    crate::mcp::run_mcp_server()
2064}
2065
2066/// Handle the internal `index-symbols-internal` command
2067fn handle_index_symbols_internal(cache_dir: PathBuf) -> Result<()> {
2068    let mut indexer = crate::background_indexer::BackgroundIndexer::new(&cache_dir)?;
2069    indexer.run()?;
2070    Ok(())
2071}
2072
2073/// Handle the `analyze` subcommand
2074#[allow(clippy::too_many_arguments)]
2075fn handle_analyze(
2076    circular: bool,
2077    hotspots: bool,
2078    min_dependents: usize,
2079    unused: bool,
2080    islands: bool,
2081    min_island_size: usize,
2082    max_island_size: Option<usize>,
2083    format: String,
2084    as_json: bool,
2085    pretty_json: bool,
2086    count_only: bool,
2087    all: bool,
2088    plain: bool,
2089    _glob_patterns: Vec<String>,
2090    _exclude_patterns: Vec<String>,
2091    _force: bool,
2092    limit: Option<usize>,
2093    offset: Option<usize>,
2094    sort: Option<String>,
2095) -> Result<()> {
2096    use crate::dependency::DependencyIndex;
2097
2098    log::info!("Starting analyze command");
2099
2100    let cache = CacheManager::new(".");
2101
2102    if !cache.exists() {
2103        anyhow::bail!(
2104            "No index found in current directory.\n\
2105             \n\
2106             Run 'rfx index' to build the code search index first.\n\
2107             \n\
2108             Example:\n\
2109             $ rfx index             # Index current directory\n\
2110             $ rfx analyze           # Run dependency analysis"
2111        );
2112    }
2113
2114    let deps_index = DependencyIndex::new(cache);
2115
2116    // JSON mode overrides format
2117    let format = if as_json { "json" } else { &format };
2118
2119    // Smart limit handling for analyze commands (default: 200 per page)
2120    let final_limit = if all {
2121        None  // --all means no limit
2122    } else if let Some(user_limit) = limit {
2123        Some(user_limit)  // Use user-specified limit
2124    } else {
2125        Some(200)  // Default: limit to 200 results per page for token efficiency
2126    };
2127
2128    // If no specific flags, show summary
2129    if !circular && !hotspots && !unused && !islands {
2130        return handle_analyze_summary(&deps_index, min_dependents, count_only, as_json, pretty_json);
2131    }
2132
2133    // Run specific analyses based on flags
2134    if circular {
2135        handle_deps_circular(&deps_index, format, pretty_json, final_limit, offset, count_only, plain, sort.clone())?;
2136    }
2137
2138    if hotspots {
2139        handle_deps_hotspots(&deps_index, format, pretty_json, final_limit, offset, min_dependents, count_only, plain, sort.clone())?;
2140    }
2141
2142    if unused {
2143        handle_deps_unused(&deps_index, format, pretty_json, final_limit, offset, count_only, plain)?;
2144    }
2145
2146    if islands {
2147        handle_deps_islands(&deps_index, format, pretty_json, final_limit, offset, min_island_size, max_island_size, count_only, plain, sort.clone())?;
2148    }
2149
2150    Ok(())
2151}
2152
2153/// Handle analyze summary (default --analyze behavior)
2154fn handle_analyze_summary(
2155    deps_index: &crate::dependency::DependencyIndex,
2156    min_dependents: usize,
2157    count_only: bool,
2158    as_json: bool,
2159    pretty_json: bool,
2160) -> Result<()> {
2161    // Gather counts
2162    let cycles = deps_index.detect_circular_dependencies()?;
2163    let hotspots = deps_index.find_hotspots(None, min_dependents)?;
2164    let unused = deps_index.find_unused_files()?;
2165    let all_islands = deps_index.find_islands()?;
2166
2167    if as_json {
2168        // JSON output
2169        let summary = serde_json::json!({
2170            "circular_dependencies": cycles.len(),
2171            "hotspots": hotspots.len(),
2172            "unused_files": unused.len(),
2173            "islands": all_islands.len(),
2174            "min_dependents": min_dependents,
2175        });
2176
2177        let json_str = if pretty_json {
2178            serde_json::to_string_pretty(&summary)?
2179        } else {
2180            serde_json::to_string(&summary)?
2181        };
2182        println!("{}", json_str);
2183    } else if count_only {
2184        // Just show counts without any extra formatting
2185        println!("{} circular dependencies", cycles.len());
2186        println!("{} hotspots ({}+ dependents)", hotspots.len(), min_dependents);
2187        println!("{} unused files", unused.len());
2188        println!("{} islands", all_islands.len());
2189    } else {
2190        // Full summary with headers and suggestions
2191        println!("Dependency Analysis Summary\n");
2192
2193        // Circular dependencies
2194        println!("Circular Dependencies: {} cycle(s)", cycles.len());
2195
2196        // Hotspots
2197        println!("Hotspots: {} file(s) with {}+ dependents", hotspots.len(), min_dependents);
2198
2199        // Unused
2200        println!("Unused Files: {} file(s)", unused.len());
2201
2202        // Islands
2203        println!("Islands: {} disconnected component(s)", all_islands.len());
2204
2205        println!("\nUse specific flags for detailed results:");
2206        println!("  rfx analyze --circular");
2207        println!("  rfx analyze --hotspots");
2208        println!("  rfx analyze --unused");
2209        println!("  rfx analyze --islands");
2210    }
2211
2212    Ok(())
2213}
2214
2215/// Handle the `deps` subcommand
2216fn handle_deps(
2217    file: PathBuf,
2218    reverse: bool,
2219    depth: usize,
2220    format: String,
2221    as_json: bool,
2222    pretty_json: bool,
2223) -> Result<()> {
2224    use crate::dependency::DependencyIndex;
2225
2226    log::info!("Starting deps command");
2227
2228    let cache = CacheManager::new(".");
2229
2230    if !cache.exists() {
2231        anyhow::bail!(
2232            "No index found in current directory.\n\
2233             \n\
2234             Run 'rfx index' to build the code search index first.\n\
2235             \n\
2236             Example:\n\
2237             $ rfx index          # Index current directory\n\
2238             $ rfx deps <file>    # Analyze dependencies"
2239        );
2240    }
2241
2242    let deps_index = DependencyIndex::new(cache);
2243
2244    // JSON mode overrides format
2245    let format = if as_json { "json" } else { &format };
2246
2247    // Convert file path to string
2248    let file_str = file.to_string_lossy().to_string();
2249
2250    // Get file ID
2251    let file_id = deps_index.get_file_id_by_path(&file_str)?
2252        .ok_or_else(|| anyhow::anyhow!("File '{}' not found in index", file_str))?;
2253
2254    if reverse {
2255        // Show dependents (who imports this file)
2256        let dependents = deps_index.get_dependents(file_id)?;
2257        let paths = deps_index.get_file_paths(&dependents)?;
2258
2259        match format.as_ref() {
2260            "json" => {
2261                let output: Vec<_> = dependents.iter()
2262                    .filter_map(|id| paths.get(id).map(|path| serde_json::json!({
2263                        "file_id": id,
2264                        "path": path,
2265                    })))
2266                    .collect();
2267
2268                let json_str = if pretty_json {
2269                    serde_json::to_string_pretty(&output)?
2270                } else {
2271                    serde_json::to_string(&output)?
2272                };
2273                println!("{}", json_str);
2274                eprintln!("Found {} files that import {}", dependents.len(), file_str);
2275            }
2276            "tree" => {
2277                println!("Files that import {}:", file_str);
2278                for (id, path) in &paths {
2279                    if dependents.contains(id) {
2280                        println!("  └─ {}", path);
2281                    }
2282                }
2283                eprintln!("\nFound {} dependents", dependents.len());
2284            }
2285            "table" => {
2286                println!("ID     Path");
2287                println!("-----  ----");
2288                for id in &dependents {
2289                    if let Some(path) = paths.get(id) {
2290                        println!("{:<5}  {}", id, path);
2291                    }
2292                }
2293                eprintln!("\nFound {} dependents", dependents.len());
2294            }
2295            _ => {
2296                anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2297            }
2298        }
2299    } else {
2300        // Show dependencies (what this file imports)
2301        if depth == 1 {
2302            // Direct dependencies only
2303            let deps = deps_index.get_dependencies(file_id)?;
2304
2305            match format.as_ref() {
2306                "json" => {
2307                    let output: Vec<_> = deps.iter()
2308                        .map(|dep| serde_json::json!({
2309                            "imported_path": dep.imported_path,
2310                            "resolved_file_id": dep.resolved_file_id,
2311                            "import_type": match dep.import_type {
2312                                crate::models::ImportType::Internal => "internal",
2313                                crate::models::ImportType::External => "external",
2314                                crate::models::ImportType::Stdlib => "stdlib",
2315                            },
2316                            "line": dep.line_number,
2317                            "symbols": dep.imported_symbols,
2318                        }))
2319                        .collect();
2320
2321                    let json_str = if pretty_json {
2322                        serde_json::to_string_pretty(&output)?
2323                    } else {
2324                        serde_json::to_string(&output)?
2325                    };
2326                    println!("{}", json_str);
2327                    eprintln!("Found {} dependencies for {}", deps.len(), file_str);
2328                }
2329                "tree" => {
2330                    println!("Dependencies of {}:", file_str);
2331                    for dep in &deps {
2332                        let type_label = match dep.import_type {
2333                            crate::models::ImportType::Internal => "[internal]",
2334                            crate::models::ImportType::External => "[external]",
2335                            crate::models::ImportType::Stdlib => "[stdlib]",
2336                        };
2337                        println!("  └─ {} {} (line {})", dep.imported_path, type_label, dep.line_number);
2338                    }
2339                    eprintln!("\nFound {} dependencies", deps.len());
2340                }
2341                "table" => {
2342                    println!("Path                          Type       Line");
2343                    println!("----------------------------  ---------  ----");
2344                    for dep in &deps {
2345                        let type_str = match dep.import_type {
2346                            crate::models::ImportType::Internal => "internal",
2347                            crate::models::ImportType::External => "external",
2348                            crate::models::ImportType::Stdlib => "stdlib",
2349                        };
2350                        println!("{:<28}  {:<9}  {}", dep.imported_path, type_str, dep.line_number);
2351                    }
2352                    eprintln!("\nFound {} dependencies", deps.len());
2353                }
2354                _ => {
2355                    anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2356                }
2357            }
2358        } else {
2359            // Transitive dependencies (depth > 1)
2360            let transitive = deps_index.get_transitive_deps(file_id, depth)?;
2361            let file_ids: Vec<_> = transitive.keys().copied().collect();
2362            let paths = deps_index.get_file_paths(&file_ids)?;
2363
2364            match format.as_ref() {
2365                "json" => {
2366                    let output: Vec<_> = transitive.iter()
2367                        .filter_map(|(id, d)| {
2368                            paths.get(id).map(|path| serde_json::json!({
2369                                "file_id": id,
2370                                "path": path,
2371                                "depth": d,
2372                            }))
2373                        })
2374                        .collect();
2375
2376                    let json_str = if pretty_json {
2377                        serde_json::to_string_pretty(&output)?
2378                    } else {
2379                        serde_json::to_string(&output)?
2380                    };
2381                    println!("{}", json_str);
2382                    eprintln!("Found {} transitive dependencies (depth {})", transitive.len(), depth);
2383                }
2384                "tree" => {
2385                    println!("Transitive dependencies of {} (depth {}):", file_str, depth);
2386                    // Group by depth for tree display
2387                    let mut by_depth: std::collections::HashMap<usize, Vec<i64>> = std::collections::HashMap::new();
2388                    for (id, d) in &transitive {
2389                        by_depth.entry(*d).or_insert_with(Vec::new).push(*id);
2390                    }
2391
2392                    for depth_level in 0..=depth {
2393                        if let Some(ids) = by_depth.get(&depth_level) {
2394                            let indent = "  ".repeat(depth_level);
2395                            for id in ids {
2396                                if let Some(path) = paths.get(id) {
2397                                    if depth_level == 0 {
2398                                        println!("{}{} (self)", indent, path);
2399                                    } else {
2400                                        println!("{}└─ {}", indent, path);
2401                                    }
2402                                }
2403                            }
2404                        }
2405                    }
2406                    eprintln!("\nFound {} transitive dependencies", transitive.len());
2407                }
2408                "table" => {
2409                    println!("Depth  File ID  Path");
2410                    println!("-----  -------  ----");
2411                    let mut sorted: Vec<_> = transitive.iter().collect();
2412                    sorted.sort_by_key(|(_, d)| *d);
2413                    for (id, d) in sorted {
2414                        if let Some(path) = paths.get(id) {
2415                            println!("{:<5}  {:<7}  {}", d, id, path);
2416                        }
2417                    }
2418                    eprintln!("\nFound {} transitive dependencies", transitive.len());
2419                }
2420                _ => {
2421                    anyhow::bail!("Unknown format '{}'. Supported: json, tree, table, dot", format);
2422                }
2423            }
2424        }
2425    }
2426
2427    Ok(())
2428}
2429
2430/// Handle the `ask` command
2431fn handle_ask(
2432    question: Option<String>,
2433    _auto_execute: bool,
2434    provider_override: Option<String>,
2435    as_json: bool,
2436    pretty_json: bool,
2437    additional_context: Option<String>,
2438    configure: bool,
2439    agentic: bool,
2440    max_iterations: usize,
2441    no_eval: bool,
2442    show_reasoning: bool,
2443    verbose: bool,
2444    quiet: bool,
2445    answer: bool,
2446    interactive: bool,
2447    debug: bool,
2448) -> Result<()> {
2449    // If --configure flag is set, launch the configuration wizard
2450    if configure {
2451        log::info!("Launching configuration wizard");
2452        return crate::semantic::run_configure_wizard();
2453    }
2454
2455    // Check if any API key is configured before allowing rfx ask to run
2456    if !crate::semantic::is_any_api_key_configured() {
2457        anyhow::bail!(
2458            "No API key configured.\n\
2459             \n\
2460             Please run 'rfx ask --configure' to set up your API provider and key.\n\
2461             \n\
2462             Alternatively, you can set an environment variable:\n\
2463             - OPENAI_API_KEY\n\
2464             - ANTHROPIC_API_KEY\n\
2465             - GROQ_API_KEY"
2466        );
2467    }
2468
2469    // If no question provided and not in configure mode, default to interactive mode
2470    // If --interactive flag is set, launch interactive chat mode (TUI)
2471    if interactive || question.is_none() {
2472        log::info!("Launching interactive chat mode");
2473        let cache = CacheManager::new(".");
2474
2475        if !cache.exists() {
2476            anyhow::bail!(
2477                "No index found in current directory.\n\
2478                 \n\
2479                 Run 'rfx index' to build the code search index first.\n\
2480                 \n\
2481                 Example:\n\
2482                 $ rfx index                          # Index current directory\n\
2483                 $ rfx ask                            # Launch interactive chat"
2484            );
2485        }
2486
2487        return crate::semantic::run_chat_mode(cache, provider_override, None);
2488    }
2489
2490    // At this point, question must be Some
2491    let question = question.unwrap();
2492
2493    log::info!("Starting ask command");
2494
2495    let cache = CacheManager::new(".");
2496
2497    if !cache.exists() {
2498        anyhow::bail!(
2499            "No index found in current directory.\n\
2500             \n\
2501             Run 'rfx index' to build the code search index first.\n\
2502             \n\
2503             Example:\n\
2504             $ rfx index                          # Index current directory\n\
2505             $ rfx ask \"Find all TODOs\"          # Ask questions"
2506        );
2507    }
2508
2509    // Create a tokio runtime for async operations
2510    let runtime = tokio::runtime::Runtime::new()
2511        .context("Failed to create async runtime")?;
2512
2513    // Force quiet mode for JSON output (machine-readable, no UI output)
2514    let quiet = quiet || as_json;
2515
2516    // Create optional spinner (skip entirely in JSON mode for clean machine-readable output)
2517    let spinner = if !as_json {
2518        let s = ProgressBar::new_spinner();
2519        s.set_style(
2520            ProgressStyle::default_spinner()
2521                .template("{spinner:.cyan} {msg}")
2522                .unwrap()
2523                .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2524        );
2525        s.set_message("Generating queries...".to_string());
2526        s.enable_steady_tick(std::time::Duration::from_millis(80));
2527        Some(s)
2528    } else {
2529        None
2530    };
2531
2532    let (queries, results, total_count, count_only, gathered_context) = if agentic {
2533        // Agentic mode: multi-step reasoning with context gathering
2534
2535        // Wrap spinner in Arc<Mutex<>> for sharing with reporter (non-quiet mode)
2536        let spinner_shared = if !quiet {
2537            spinner.as_ref().map(|s| Arc::new(Mutex::new(s.clone())))
2538        } else {
2539            None
2540        };
2541
2542        // Create reporter based on flags
2543        let reporter: Box<dyn crate::semantic::AgenticReporter> = if quiet {
2544            Box::new(crate::semantic::QuietReporter)
2545        } else {
2546            Box::new(crate::semantic::ConsoleReporter::new(show_reasoning, verbose, debug, spinner_shared))
2547        };
2548
2549        // Set initial spinner message and enable ticking
2550        if let Some(ref s) = spinner {
2551            s.set_message("Starting agentic mode...".to_string());
2552            s.enable_steady_tick(std::time::Duration::from_millis(80));
2553        }
2554
2555        let agentic_config = crate::semantic::AgenticConfig {
2556            max_iterations,
2557            max_tools_per_phase: 5,
2558            enable_evaluation: !no_eval,
2559            eval_config: Default::default(),
2560            provider_override: provider_override.clone(),
2561            model_override: None,
2562            show_reasoning,
2563            verbose,
2564            debug,
2565        };
2566
2567        let agentic_response = runtime.block_on(async {
2568            crate::semantic::run_agentic_loop(&question, &cache, agentic_config, &*reporter).await
2569        }).context("Failed to run agentic loop")?;
2570
2571        // Clear spinner after agentic loop completes
2572        if let Some(ref s) = spinner {
2573            s.finish_and_clear();
2574        }
2575
2576        // Clear ephemeral output (Phase 5 evaluation) before showing final results
2577        if !as_json {
2578            reporter.clear_all();
2579        }
2580
2581        log::info!("Agentic loop completed: {} queries generated", agentic_response.queries.len());
2582
2583        // Destructure AgenticQueryResponse into tuple (preserve gathered_context)
2584        let count_only_mode = agentic_response.total_count.is_none();
2585        let count = agentic_response.total_count.unwrap_or(0);
2586        (agentic_response.queries, agentic_response.results, count, count_only_mode, agentic_response.gathered_context)
2587    } else {
2588        // Standard mode: single LLM call + execution
2589        if let Some(ref s) = spinner {
2590            s.set_message("Generating queries...".to_string());
2591            s.enable_steady_tick(std::time::Duration::from_millis(80));
2592        }
2593
2594        let semantic_response = runtime.block_on(async {
2595            crate::semantic::ask_question(&question, &cache, provider_override.clone(), additional_context, debug).await
2596        }).context("Failed to generate semantic queries")?;
2597
2598        if let Some(ref s) = spinner {
2599            s.finish_and_clear();
2600        }
2601        log::info!("LLM generated {} queries", semantic_response.queries.len());
2602
2603        // Execute queries for standard mode
2604        let (exec_results, exec_total, exec_count_only) = runtime.block_on(async {
2605            crate::semantic::execute_queries(semantic_response.queries.clone(), &cache).await
2606        }).context("Failed to execute queries")?;
2607
2608        (semantic_response.queries, exec_results, exec_total, exec_count_only, None)
2609    };
2610
2611    // Generate conversational answer if --answer flag is set
2612    let generated_answer = if answer {
2613        // Show spinner while generating answer
2614        let answer_spinner = if !as_json {
2615            let s = ProgressBar::new_spinner();
2616            s.set_style(
2617                ProgressStyle::default_spinner()
2618                    .template("{spinner:.cyan} {msg}")
2619                    .unwrap()
2620                    .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
2621            );
2622            s.set_message("Generating answer...".to_string());
2623            s.enable_steady_tick(std::time::Duration::from_millis(80));
2624            Some(s)
2625        } else {
2626            None
2627        };
2628
2629        // Initialize provider for answer generation
2630        let mut config = crate::semantic::config::load_config(cache.path())?;
2631        if let Some(provider) = &provider_override {
2632            config.provider = provider.clone();
2633        }
2634        let api_key = crate::semantic::config::get_api_key(&config.provider)?;
2635        let model = if config.model.is_some() {
2636            config.model.clone()
2637        } else {
2638            crate::semantic::config::get_user_model(&config.provider)
2639        };
2640        let provider_instance = crate::semantic::providers::create_provider(
2641            &config.provider,
2642            api_key,
2643            model,
2644        )?;
2645
2646        // Extract codebase context (always available metadata: languages, file counts, directories)
2647        let codebase_context_str = crate::semantic::context::CodebaseContext::extract(&cache)
2648            .ok()
2649            .map(|ctx| ctx.to_prompt_string());
2650
2651        // Generate answer (with optional gathered context from agentic mode + codebase context)
2652        let answer_result = runtime.block_on(async {
2653            crate::semantic::generate_answer(
2654                &question,
2655                &results,
2656                total_count,
2657                gathered_context.as_deref(),
2658                codebase_context_str.as_deref(),
2659                &*provider_instance,
2660            ).await
2661        }).context("Failed to generate answer")?;
2662
2663        if let Some(s) = answer_spinner {
2664            s.finish_and_clear();
2665        }
2666
2667        Some(answer_result)
2668    } else {
2669        None
2670    };
2671
2672    // Output in JSON format if requested
2673    if as_json {
2674        // Build AgenticQueryResponse for JSON output (includes both queries and results)
2675        let json_response = crate::semantic::AgenticQueryResponse {
2676            queries: queries.clone(),
2677            results: results.clone(),
2678            total_count: if count_only { None } else { Some(total_count) },
2679            gathered_context: gathered_context.clone(),
2680            tools_executed: None, // No tools in non-agentic mode
2681            answer: generated_answer,
2682        };
2683
2684        let json_str = if pretty_json {
2685            serde_json::to_string_pretty(&json_response)?
2686        } else {
2687            serde_json::to_string(&json_response)?
2688        };
2689        println!("{}", json_str);
2690        return Ok(());
2691    }
2692
2693    // Display generated queries with color (unless in answer mode)
2694    if !answer {
2695        println!("\n{}", "Generated Queries:".bold().cyan());
2696        println!("{}", "==================".cyan());
2697        for (idx, query_cmd) in queries.iter().enumerate() {
2698            println!(
2699                "{}. {} {} {}",
2700                (idx + 1).to_string().bright_white().bold(),
2701                format!("[order: {}, merge: {}]", query_cmd.order, query_cmd.merge).dimmed(),
2702                "rfx".bright_green().bold(),
2703                query_cmd.command.bright_white()
2704            );
2705        }
2706        println!();
2707    }
2708
2709    // Note: queries already executed in both modes above
2710    // Agentic mode: executed during run_agentic_loop
2711    // Standard mode: executed after ask_question
2712
2713    // Display answer or results
2714    println!();
2715    if let Some(answer_text) = generated_answer {
2716        // Answer mode: show the conversational answer
2717        println!("{}", "Answer:".bold().green());
2718        println!("{}", "=======".green());
2719        println!();
2720
2721        // Render markdown if it looks like markdown, otherwise print as-is
2722        termimad::print_text(&answer_text);
2723        println!();
2724
2725        // Show summary of results used
2726        if !results.is_empty() {
2727            println!(
2728                "{}",
2729                format!(
2730                    "(Based on {} matches across {} files)",
2731                    total_count,
2732                    results.len()
2733                ).dimmed()
2734            );
2735        }
2736    } else {
2737        // Standard mode: show raw results
2738        if count_only {
2739            // Count-only mode: just show the total count (matching direct CLI behavior)
2740            println!("{} {}", "Found".bright_green().bold(), format!("{} results", total_count).bright_white().bold());
2741        } else if results.is_empty() {
2742            println!("{}", "No results found.".yellow());
2743        } else {
2744            println!(
2745                "{} {} {} {} {}",
2746                "Found".bright_green().bold(),
2747                total_count.to_string().bright_white().bold(),
2748                "total results across".dimmed(),
2749                results.len().to_string().bright_white().bold(),
2750                "files:".dimmed()
2751            );
2752            println!();
2753
2754            for file_group in &results {
2755                println!("{}:", file_group.path.bright_cyan().bold());
2756                for match_result in &file_group.matches {
2757                    println!(
2758                        "  {} {}-{}: {}",
2759                        "Line".dimmed(),
2760                        match_result.span.start_line.to_string().bright_yellow(),
2761                        match_result.span.end_line.to_string().bright_yellow(),
2762                        match_result.preview.lines().next().unwrap_or("")
2763                    );
2764                }
2765                println!();
2766            }
2767        }
2768    }
2769
2770    Ok(())
2771}
2772
2773/// Handle the `context` command
2774fn handle_context(
2775    structure: bool,
2776    path: Option<String>,
2777    file_types: bool,
2778    project_type: bool,
2779    framework: bool,
2780    entry_points: bool,
2781    test_layout: bool,
2782    config_files: bool,
2783    depth: usize,
2784    json: bool,
2785) -> Result<()> {
2786    let cache = CacheManager::new(".");
2787
2788    if !cache.exists() {
2789        anyhow::bail!(
2790            "No index found in current directory.\n\
2791             \n\
2792             Run 'rfx index' to build the code search index first.\n\
2793             \n\
2794             Example:\n\
2795             $ rfx index                  # Index current directory\n\
2796             $ rfx context                # Generate context"
2797        );
2798    }
2799
2800    // Build context options
2801    let opts = crate::context::ContextOptions {
2802        structure,
2803        path,
2804        file_types,
2805        project_type,
2806        framework,
2807        entry_points,
2808        test_layout,
2809        config_files,
2810        depth,
2811        json,
2812    };
2813
2814    // Generate context
2815    let context_output = crate::context::generate_context(&cache, &opts)
2816        .context("Failed to generate codebase context")?;
2817
2818    // Print output
2819    println!("{}", context_output);
2820
2821    Ok(())
2822}
2823
2824/// Handle --circular flag (detect cycles)
2825fn handle_deps_circular(
2826    deps_index: &crate::dependency::DependencyIndex,
2827    format: &str,
2828    pretty_json: bool,
2829    limit: Option<usize>,
2830    offset: Option<usize>,
2831    count_only: bool,
2832    _plain: bool,
2833    sort: Option<String>,
2834) -> Result<()> {
2835    let mut all_cycles = deps_index.detect_circular_dependencies()?;
2836
2837    // Apply sorting (default: descending - longest cycles first)
2838    let sort_order = sort.as_deref().unwrap_or("desc");
2839    match sort_order {
2840        "asc" => {
2841            // Ascending: shortest cycles first
2842            all_cycles.sort_by_key(|cycle| cycle.len());
2843        }
2844        "desc" => {
2845            // Descending: longest cycles first (default)
2846            all_cycles.sort_by_key(|cycle| std::cmp::Reverse(cycle.len()));
2847        }
2848        _ => {
2849            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
2850        }
2851    }
2852
2853    let total_count = all_cycles.len();
2854
2855    if count_only {
2856        println!("Found {} circular dependencies", total_count);
2857        return Ok(());
2858    }
2859
2860    if all_cycles.is_empty() {
2861        println!("No circular dependencies found.");
2862        return Ok(());
2863    }
2864
2865    // Apply offset pagination
2866    let offset_val = offset.unwrap_or(0);
2867    let mut cycles: Vec<_> = all_cycles.into_iter().skip(offset_val).collect();
2868
2869    // Apply limit
2870    if let Some(lim) = limit {
2871        cycles.truncate(lim);
2872    }
2873
2874    if cycles.is_empty() {
2875        println!("No circular dependencies found at offset {}.", offset_val);
2876        return Ok(());
2877    }
2878
2879    let count = cycles.len();
2880    let has_more = offset_val + count < total_count;
2881
2882    match format {
2883        "json" => {
2884            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2885            let paths = deps_index.get_file_paths(&file_ids)?;
2886
2887            let results: Vec<_> = cycles.iter()
2888                .map(|cycle| {
2889                    let cycle_paths: Vec<_> = cycle.iter()
2890                        .filter_map(|id| paths.get(id).cloned())
2891                        .collect();
2892                    serde_json::json!({
2893                        "paths": cycle_paths,
2894                    })
2895                })
2896                .collect();
2897
2898            let output = serde_json::json!({
2899                "pagination": {
2900                    "total": total_count,
2901                    "count": count,
2902                    "offset": offset_val,
2903                    "limit": limit,
2904                    "has_more": has_more,
2905                },
2906                "results": results,
2907            });
2908
2909            let json_str = if pretty_json {
2910                serde_json::to_string_pretty(&output)?
2911            } else {
2912                serde_json::to_string(&output)?
2913            };
2914            println!("{}", json_str);
2915            if total_count > count {
2916                eprintln!("Found {} circular dependencies ({} total)", count, total_count);
2917            } else {
2918                eprintln!("Found {} circular dependencies", count);
2919            }
2920        }
2921        "tree" => {
2922            println!("Circular Dependencies Found:");
2923            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2924            let paths = deps_index.get_file_paths(&file_ids)?;
2925
2926            for (idx, cycle) in cycles.iter().enumerate() {
2927                println!("\nCycle {}:", idx + 1);
2928                for id in cycle {
2929                    if let Some(path) = paths.get(id) {
2930                        println!("  → {}", path);
2931                    }
2932                }
2933                // Show cycle completion
2934                if let Some(first_id) = cycle.first() {
2935                    if let Some(path) = paths.get(first_id) {
2936                        println!("  → {} (cycle completes)", path);
2937                    }
2938                }
2939            }
2940            if total_count > count {
2941                eprintln!("\nFound {} cycles ({} total)", count, total_count);
2942                if has_more {
2943                    eprintln!("Use --limit and --offset to paginate");
2944                }
2945            } else {
2946                eprintln!("\nFound {} cycles", count);
2947            }
2948        }
2949        "table" => {
2950            println!("Cycle  Files in Cycle");
2951            println!("-----  --------------");
2952            let file_ids: Vec<i64> = cycles.iter().flat_map(|c| c.iter()).copied().collect();
2953            let paths = deps_index.get_file_paths(&file_ids)?;
2954
2955            for (idx, cycle) in cycles.iter().enumerate() {
2956                let cycle_str = cycle.iter()
2957                    .filter_map(|id| paths.get(id).map(|p| p.as_str()))
2958                    .collect::<Vec<_>>()
2959                    .join(" → ");
2960                println!("{:<5}  {}", idx + 1, cycle_str);
2961            }
2962            if total_count > count {
2963                eprintln!("\nFound {} cycles ({} total)", count, total_count);
2964                if has_more {
2965                    eprintln!("Use --limit and --offset to paginate");
2966                }
2967            } else {
2968                eprintln!("\nFound {} cycles", count);
2969            }
2970        }
2971        _ => {
2972            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
2973        }
2974    }
2975
2976    Ok(())
2977}
2978
2979/// Handle --hotspots flag (most-imported files)
2980fn handle_deps_hotspots(
2981    deps_index: &crate::dependency::DependencyIndex,
2982    format: &str,
2983    pretty_json: bool,
2984    limit: Option<usize>,
2985    offset: Option<usize>,
2986    min_dependents: usize,
2987    count_only: bool,
2988    _plain: bool,
2989    sort: Option<String>,
2990) -> Result<()> {
2991    // Get all hotspots without limit first to track total count
2992    let mut all_hotspots = deps_index.find_hotspots(None, min_dependents)?;
2993
2994    // Apply sorting (default: descending - most imports first)
2995    let sort_order = sort.as_deref().unwrap_or("desc");
2996    match sort_order {
2997        "asc" => {
2998            // Ascending: least imports first
2999            all_hotspots.sort_by(|a, b| a.1.cmp(&b.1));
3000        }
3001        "desc" => {
3002            // Descending: most imports first (default)
3003            all_hotspots.sort_by(|a, b| b.1.cmp(&a.1));
3004        }
3005        _ => {
3006            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3007        }
3008    }
3009
3010    let total_count = all_hotspots.len();
3011
3012    if count_only {
3013        println!("Found {} hotspots with {}+ dependents", total_count, min_dependents);
3014        return Ok(());
3015    }
3016
3017    if all_hotspots.is_empty() {
3018        println!("No hotspots found.");
3019        return Ok(());
3020    }
3021
3022    // Apply offset pagination
3023    let offset_val = offset.unwrap_or(0);
3024    let mut hotspots: Vec<_> = all_hotspots.into_iter().skip(offset_val).collect();
3025
3026    // Apply limit
3027    if let Some(lim) = limit {
3028        hotspots.truncate(lim);
3029    }
3030
3031    if hotspots.is_empty() {
3032        println!("No hotspots found at offset {}.", offset_val);
3033        return Ok(());
3034    }
3035
3036    let count = hotspots.len();
3037    let has_more = offset_val + count < total_count;
3038
3039    let file_ids: Vec<i64> = hotspots.iter().map(|(id, _)| *id).collect();
3040    let paths = deps_index.get_file_paths(&file_ids)?;
3041
3042    match format {
3043        "json" => {
3044            let results: Vec<_> = hotspots.iter()
3045                .filter_map(|(id, import_count)| {
3046                    paths.get(id).map(|path| serde_json::json!({
3047                        "path": path,
3048                        "import_count": import_count,
3049                    }))
3050                })
3051                .collect();
3052
3053            let output = serde_json::json!({
3054                "pagination": {
3055                    "total": total_count,
3056                    "count": count,
3057                    "offset": offset_val,
3058                    "limit": limit,
3059                    "has_more": has_more,
3060                },
3061                "results": results,
3062            });
3063
3064            let json_str = if pretty_json {
3065                serde_json::to_string_pretty(&output)?
3066            } else {
3067                serde_json::to_string(&output)?
3068            };
3069            println!("{}", json_str);
3070            if total_count > count {
3071                eprintln!("Found {} hotspots ({} total)", count, total_count);
3072            } else {
3073                eprintln!("Found {} hotspots", count);
3074            }
3075        }
3076        "tree" => {
3077            println!("Hotspots (Most-Imported Files):");
3078            for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3079                if let Some(path) = paths.get(id) {
3080                    println!("  {}. {} ({} imports)", idx + 1, path, import_count);
3081                }
3082            }
3083            if total_count > count {
3084                eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3085                if has_more {
3086                    eprintln!("Use --limit and --offset to paginate");
3087                }
3088            } else {
3089                eprintln!("\nFound {} hotspots", count);
3090            }
3091        }
3092        "table" => {
3093            println!("Rank  Imports  File");
3094            println!("----  -------  ----");
3095            for (idx, (id, import_count)) in hotspots.iter().enumerate() {
3096                if let Some(path) = paths.get(id) {
3097                    println!("{:<4}  {:<7}  {}", idx + 1, import_count, path);
3098                }
3099            }
3100            if total_count > count {
3101                eprintln!("\nFound {} hotspots ({} total)", count, total_count);
3102                if has_more {
3103                    eprintln!("Use --limit and --offset to paginate");
3104                }
3105            } else {
3106                eprintln!("\nFound {} hotspots", count);
3107            }
3108        }
3109        _ => {
3110            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3111        }
3112    }
3113
3114    Ok(())
3115}
3116
3117/// Handle --unused flag (orphaned files)
3118fn handle_deps_unused(
3119    deps_index: &crate::dependency::DependencyIndex,
3120    format: &str,
3121    pretty_json: bool,
3122    limit: Option<usize>,
3123    offset: Option<usize>,
3124    count_only: bool,
3125    _plain: bool,
3126) -> Result<()> {
3127    let all_unused = deps_index.find_unused_files()?;
3128    let total_count = all_unused.len();
3129
3130    if count_only {
3131        println!("Found {} unused files", total_count);
3132        return Ok(());
3133    }
3134
3135    if all_unused.is_empty() {
3136        println!("No unused files found (all files have incoming dependencies).");
3137        return Ok(());
3138    }
3139
3140    // Apply offset pagination
3141    let offset_val = offset.unwrap_or(0);
3142    let mut unused: Vec<_> = all_unused.into_iter().skip(offset_val).collect();
3143
3144    if unused.is_empty() {
3145        println!("No unused files found at offset {}.", offset_val);
3146        return Ok(());
3147    }
3148
3149    // Apply limit
3150    if let Some(lim) = limit {
3151        unused.truncate(lim);
3152    }
3153
3154    let count = unused.len();
3155    let has_more = offset_val + count < total_count;
3156
3157    let paths = deps_index.get_file_paths(&unused)?;
3158
3159    match format {
3160        "json" => {
3161            // Return flat array of path strings (no "path" key wrapper)
3162            let results: Vec<String> = unused.iter()
3163                .filter_map(|id| paths.get(id).cloned())
3164                .collect();
3165
3166            let output = serde_json::json!({
3167                "pagination": {
3168                    "total": total_count,
3169                    "count": count,
3170                    "offset": offset_val,
3171                    "limit": limit,
3172                    "has_more": has_more,
3173                },
3174                "results": results,
3175            });
3176
3177            let json_str = if pretty_json {
3178                serde_json::to_string_pretty(&output)?
3179            } else {
3180                serde_json::to_string(&output)?
3181            };
3182            println!("{}", json_str);
3183            if total_count > count {
3184                eprintln!("Found {} unused files ({} total)", count, total_count);
3185            } else {
3186                eprintln!("Found {} unused files", count);
3187            }
3188        }
3189        "tree" => {
3190            println!("Unused Files (No Incoming Dependencies):");
3191            for (idx, id) in unused.iter().enumerate() {
3192                if let Some(path) = paths.get(id) {
3193                    println!("  {}. {}", idx + 1, path);
3194                }
3195            }
3196            if total_count > count {
3197                eprintln!("\nFound {} unused files ({} total)", count, total_count);
3198                if has_more {
3199                    eprintln!("Use --limit and --offset to paginate");
3200                }
3201            } else {
3202                eprintln!("\nFound {} unused files", count);
3203            }
3204        }
3205        "table" => {
3206            println!("Path");
3207            println!("----");
3208            for id in &unused {
3209                if let Some(path) = paths.get(id) {
3210                    println!("{}", path);
3211                }
3212            }
3213            if total_count > count {
3214                eprintln!("\nFound {} unused files ({} total)", count, total_count);
3215                if has_more {
3216                    eprintln!("Use --limit and --offset to paginate");
3217                }
3218            } else {
3219                eprintln!("\nFound {} unused files", count);
3220            }
3221        }
3222        _ => {
3223            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3224        }
3225    }
3226
3227    Ok(())
3228}
3229
3230/// Handle --islands flag (disconnected components)
3231fn handle_deps_islands(
3232    deps_index: &crate::dependency::DependencyIndex,
3233    format: &str,
3234    pretty_json: bool,
3235    limit: Option<usize>,
3236    offset: Option<usize>,
3237    min_island_size: usize,
3238    max_island_size: Option<usize>,
3239    count_only: bool,
3240    _plain: bool,
3241    sort: Option<String>,
3242) -> Result<()> {
3243    let all_islands = deps_index.find_islands()?;
3244    let total_components = all_islands.len();
3245
3246    // Get total file count from the cache for percentage calculation
3247    let cache = deps_index.get_cache();
3248    let total_files = cache.stats()?.total_files as usize;
3249
3250    // Calculate max_island_size default: min of 500 or 50% of total files
3251    let max_size = max_island_size.unwrap_or_else(|| {
3252        let fifty_percent = (total_files as f64 * 0.5) as usize;
3253        fifty_percent.min(500)
3254    });
3255
3256    // Filter islands by size
3257    let mut islands: Vec<_> = all_islands.into_iter()
3258        .filter(|island| {
3259            let size = island.len();
3260            size >= min_island_size && size <= max_size
3261        })
3262        .collect();
3263
3264    // Apply sorting (default: descending - largest islands first)
3265    let sort_order = sort.as_deref().unwrap_or("desc");
3266    match sort_order {
3267        "asc" => {
3268            // Ascending: smallest islands first
3269            islands.sort_by_key(|island| island.len());
3270        }
3271        "desc" => {
3272            // Descending: largest islands first (default)
3273            islands.sort_by_key(|island| std::cmp::Reverse(island.len()));
3274        }
3275        _ => {
3276            anyhow::bail!("Invalid sort order '{}'. Supported: asc, desc", sort_order);
3277        }
3278    }
3279
3280    let filtered_count = total_components - islands.len();
3281
3282    if count_only {
3283        if filtered_count > 0 {
3284            println!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3285                islands.len(), filtered_count, total_components, min_island_size, max_size);
3286        } else {
3287            println!("Found {} islands", islands.len());
3288        }
3289        return Ok(());
3290    }
3291
3292    // Apply offset pagination first
3293    let offset_val = offset.unwrap_or(0);
3294    if offset_val > 0 && offset_val < islands.len() {
3295        islands = islands.into_iter().skip(offset_val).collect();
3296    } else if offset_val >= islands.len() {
3297        if filtered_count > 0 {
3298            println!("No islands found at offset {} (filtered {} of {} total components by size: {}-{}).",
3299                offset_val, filtered_count, total_components, min_island_size, max_size);
3300        } else {
3301            println!("No islands found at offset {}.", offset_val);
3302        }
3303        return Ok(());
3304    }
3305
3306    // Apply limit to number of islands
3307    if let Some(lim) = limit {
3308        islands.truncate(lim);
3309    }
3310
3311    if islands.is_empty() {
3312        if filtered_count > 0 {
3313            println!("No islands found matching criteria (filtered {} of {} total components by size: {}-{}).",
3314                filtered_count, total_components, min_island_size, max_size);
3315        } else {
3316            println!("No islands found.");
3317        }
3318        return Ok(());
3319    }
3320
3321    // Get all file IDs from all islands and track pagination
3322    let count = islands.len();
3323    let has_more = offset_val + count < total_components - filtered_count;
3324
3325    let file_ids: Vec<i64> = islands.iter().flat_map(|island| island.iter()).copied().collect();
3326    let paths = deps_index.get_file_paths(&file_ids)?;
3327
3328    match format {
3329        "json" => {
3330            let results: Vec<_> = islands.iter()
3331                .enumerate()
3332                .map(|(idx, island)| {
3333                    let island_paths: Vec<_> = island.iter()
3334                        .filter_map(|id| paths.get(id).cloned())
3335                        .collect();
3336                    serde_json::json!({
3337                        "island_id": idx + 1,
3338                        "size": island.len(),
3339                        "paths": island_paths,
3340                    })
3341                })
3342                .collect();
3343
3344            let output = serde_json::json!({
3345                "pagination": {
3346                    "total": total_components - filtered_count,
3347                    "count": count,
3348                    "offset": offset_val,
3349                    "limit": limit,
3350                    "has_more": has_more,
3351                },
3352                "results": results,
3353            });
3354
3355            let json_str = if pretty_json {
3356                serde_json::to_string_pretty(&output)?
3357            } else {
3358                serde_json::to_string(&output)?
3359            };
3360            println!("{}", json_str);
3361            if filtered_count > 0 {
3362                eprintln!("Found {} islands (filtered {} of {} total components by size: {}-{})",
3363                    count, filtered_count, total_components, min_island_size, max_size);
3364            } else if total_components - filtered_count > count {
3365                eprintln!("Found {} islands ({} total)", count, total_components - filtered_count);
3366            } else {
3367                eprintln!("Found {} islands (disconnected components)", count);
3368            }
3369        }
3370        "tree" => {
3371            println!("Islands (Disconnected Components):");
3372            for (idx, island) in islands.iter().enumerate() {
3373                println!("\nIsland {} ({} files):", idx + 1, island.len());
3374                for id in island {
3375                    if let Some(path) = paths.get(id) {
3376                        println!("  ├─ {}", path);
3377                    }
3378                }
3379            }
3380            if filtered_count > 0 {
3381                eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3382                    count, filtered_count, total_components, min_island_size, max_size);
3383                if has_more {
3384                    eprintln!("Use --limit and --offset to paginate");
3385                }
3386            } else if total_components - filtered_count > count {
3387                eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3388                if has_more {
3389                    eprintln!("Use --limit and --offset to paginate");
3390                }
3391            } else {
3392                eprintln!("\nFound {} islands", count);
3393            }
3394        }
3395        "table" => {
3396            println!("Island  Size  Files");
3397            println!("------  ----  -----");
3398            for (idx, island) in islands.iter().enumerate() {
3399                let island_files = island.iter()
3400                    .filter_map(|id| paths.get(id).map(|p| p.as_str()))
3401                    .collect::<Vec<_>>()
3402                    .join(", ");
3403                println!("{:<6}  {:<4}  {}", idx + 1, island.len(), island_files);
3404            }
3405            if filtered_count > 0 {
3406                eprintln!("\nFound {} islands (filtered {} of {} total components by size: {}-{})",
3407                    count, filtered_count, total_components, min_island_size, max_size);
3408                if has_more {
3409                    eprintln!("Use --limit and --offset to paginate");
3410                }
3411            } else if total_components - filtered_count > count {
3412                eprintln!("\nFound {} islands ({} total)", count, total_components - filtered_count);
3413                if has_more {
3414                    eprintln!("Use --limit and --offset to paginate");
3415                }
3416            } else {
3417                eprintln!("\nFound {} islands", count);
3418            }
3419        }
3420        _ => {
3421            anyhow::bail!("Unknown format '{}'. Supported: json, tree, table", format);
3422        }
3423    }
3424
3425    Ok(())
3426}
3427