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