Skip to main content

reflex/cli/
mod.rs

1//! CLI argument parsing and command router
2
3use crate::cache::CacheManager;
4use anyhow::Result;
5use clap::{CommandFactory, Parser, Subcommand};
6use std::path::PathBuf;
7
8mod ask;
9mod deps;
10mod index;
11mod llm;
12mod misc;
13mod pulse;
14mod query;
15mod serve;
16mod snapshot;
17mod watch;
18
19pub use self::query::truncate_preview;
20
21/// Reflex: Local-first, structure-aware code search for AI agents
22#[derive(Parser, Debug)]
23#[command(
24    name = "rfx",
25    version,
26    about = "A fast, deterministic code search engine built for AI",
27    long_about = "Reflex is a local-first, structure-aware code search engine that returns \
28                  structured results (symbols, spans, scopes) with sub-100ms latency. \
29                  Designed for AI coding agents and automation."
30)]
31pub struct Cli {
32    /// Enable verbose logging (can be repeated for more verbosity)
33    #[arg(short, long, action = clap::ArgAction::Count)]
34    pub verbose: u8,
35
36    #[command(subcommand)]
37    pub command: Option<Command>,
38}
39
40#[derive(Subcommand, Debug)]
41pub enum IndexSubcommand {
42    /// Show background symbol indexing status
43    Status,
44
45    /// Compact the cache by removing deleted files
46    ///
47    /// Removes files from the cache that no longer exist on disk and reclaims
48    /// disk space using SQLite VACUUM. This operation is also performed automatically
49    /// in the background every 24 hours during normal usage.
50    ///
51    /// Examples:
52    ///   rfx index compact                # Show compaction results
53    ///   rfx index compact --json         # JSON output
54    Compact {
55        /// Output format as JSON
56        #[arg(long)]
57        json: bool,
58
59        /// Pretty-print JSON output (only with --json)
60        #[arg(long)]
61        pretty: bool,
62    },
63}
64
65#[derive(Subcommand, Debug)]
66pub enum Command {
67    /// Build or update the local code index
68    Index {
69        /// Directory to index (defaults to current directory)
70        #[arg(value_name = "PATH", default_value = ".")]
71        path: PathBuf,
72
73        /// Force full rebuild (ignore incremental cache)
74        #[arg(short, long)]
75        force: bool,
76
77        /// Languages to include (empty = all)
78        #[arg(short, long, value_delimiter = ',')]
79        languages: Vec<String>,
80
81        /// Suppress all output (no progress bar, no summary)
82        #[arg(short, long)]
83        quiet: bool,
84
85        /// Subcommand (status, compact)
86        #[command(subcommand)]
87        command: Option<IndexSubcommand>,
88    },
89
90    /// Query the code index
91    ///
92    /// If no pattern is provided, launches interactive mode (TUI).
93    ///
94    /// Search modes:
95    ///   - Default: Word-boundary matching (precise, finds complete identifiers)
96    ///     Example: rfx query "Error" → finds "Error" but not "NetworkError"
97    ///     Example: rfx query "test" → finds "test" but not "test_helper"
98    ///
99    ///   - Symbol search: Word-boundary for text, exact match for symbols
100    ///     Example: rfx query "parse" --symbols → finds only "parse" function/class
101    ///     Example: rfx query "parse" --kind function → finds only "parse" functions
102    ///
103    ///   - Substring search: Expansive matching (opt-in with --contains)
104    ///     Example: rfx query "mb" --contains → finds "mb", "kmb_dai_ops", "symbol", etc.
105    ///
106    ///   - Regex search: Pattern-controlled matching (opt-in with --regex)
107    ///     Example: rfx query "^mb_.*" --regex → finds "mb_init", "mb_start", etc.
108    ///
109    /// Interactive mode:
110    ///   - Launch with: rfx query
111    ///   - Search, filter, and navigate code results in a live TUI
112    ///   - Press '?' for help, 'q' to quit
113    Query {
114        /// Search pattern (omit to launch interactive mode)
115        pattern: Option<String>,
116
117        /// Search symbol definitions only (functions, classes, etc.)
118        #[arg(short, long)]
119        symbols: bool,
120
121        /// Filter by language
122        /// Supported: rust, python, javascript, typescript, vue, svelte, go, java, php, c, c++, c#, ruby, kotlin, zig
123        #[arg(short, long)]
124        lang: Option<String>,
125
126        /// Filter by symbol kind (implies --symbols)
127        /// Supported: function, class, struct, enum, interface, trait, constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute
128        #[arg(short, long)]
129        kind: Option<String>,
130
131        /// Use AST pattern matching (SLOW: 500ms-2s+, scans all files)
132        ///
133        /// WARNING: AST queries bypass trigram optimization and scan the entire codebase.
134        /// In 95% of cases, use --symbols instead which is 10-100x faster.
135        ///
136        /// When --ast is set, the pattern parameter is interpreted as a Tree-sitter
137        /// S-expression query instead of text search.
138        ///
139        /// RECOMMENDED: Always use --glob to limit scope for better performance.
140        ///
141        /// Examples:
142        ///   Fast (2-50ms):    rfx query "fetch" --symbols --kind function --lang python
143        ///   Slow (500ms-2s):  rfx query "(function_definition) @fn" --ast --lang python
144        ///   Faster with glob: rfx query "(class_declaration) @class" --ast --lang typescript --glob "src/**/*.ts"
145        #[arg(long)]
146        ast: bool,
147
148        /// Use regex pattern matching
149        ///
150        /// Enables standard regex syntax in the search pattern:
151        ///   |  for alternation (OR) - NO backslash needed
152        ///   .  matches any character
153        ///   .*  matches zero or more characters
154        ///   ^  anchors to start of line
155        ///   $  anchors to end of line
156        ///
157        /// Examples:
158        ///   --regex "belongsTo|hasMany"       Match belongsTo OR hasMany
159        ///   --regex "^import.*from"           Lines starting with import...from
160        ///   --regex "fn.*test"                Functions containing 'test'
161        ///
162        /// Note: Cannot be combined with --contains (mutually exclusive)
163        #[arg(short = 'r', long)]
164        regex: bool,
165
166        /// Output format as JSON
167        #[arg(long)]
168        json: bool,
169
170        /// Pretty-print JSON output (only with --json)
171        /// By default, JSON is minified to reduce token usage
172        #[arg(long)]
173        pretty: bool,
174
175        /// AI-optimized mode: returns JSON with ai_instruction field
176        /// Implies --json (minified by default, use --pretty for formatted output)
177        /// Provides context-aware guidance to AI agents on response format and next actions
178        #[arg(long)]
179        ai: bool,
180
181        /// Maximum number of results
182        #[arg(short = 'n', long)]
183        limit: Option<usize>,
184
185        /// Pagination offset (skip first N results after sorting)
186        /// Use with --limit for pagination: --offset 0 --limit 10, then --offset 10 --limit 10
187        #[arg(short = 'o', long)]
188        offset: Option<usize>,
189
190        /// Show full symbol definition (entire function/class body)
191        /// Only applicable to symbol searches
192        #[arg(long)]
193        expand: bool,
194
195        /// Filter by file path (supports substring matching)
196        /// Example: --file math.rs or --file helpers/
197        #[arg(short = 'f', long)]
198        file: Option<String>,
199
200        /// Exact symbol name match (no substring matching)
201        /// Only applicable to symbol searches
202        #[arg(long)]
203        exact: bool,
204
205        /// Use substring matching for both text and symbols (expansive search)
206        ///
207        /// Default behavior uses word-boundary matching for precision:
208        ///   "Error" matches "Error" but not "NetworkError"
209        ///
210        /// With --contains, enables substring matching (expansive):
211        ///   "Error" matches "Error", "NetworkError", "error_handler", etc.
212        ///
213        /// Use cases:
214        ///   - Finding partial matches: --contains "partial"
215        ///   - When you're unsure of exact names
216        ///   - Exploratory searches
217        ///
218        /// Note: Cannot be combined with --regex or --exact (mutually exclusive)
219        #[arg(long)]
220        contains: bool,
221
222        /// Only show count and timing, not the actual results
223        #[arg(short, long)]
224        count: bool,
225
226        /// Query timeout in seconds (0 = no timeout, default: 30)
227        #[arg(short = 't', long, default_value = "30")]
228        timeout: u64,
229
230        /// Use plain text output (disable colors and syntax highlighting)
231        #[arg(long)]
232        plain: bool,
233
234        /// Include files matching glob pattern (can be repeated)
235        ///
236        /// Pattern syntax (NO shell quotes in the pattern itself):
237        ///   ** = recursive match (all subdirectories)
238        ///   *  = single level match (one directory)
239        ///
240        /// Examples:
241        ///   --glob src/**/*.rs          All .rs files under src/ (recursive)
242        ///   --glob app/Models/*.php     PHP files directly in Models/ (not subdirs)
243        ///   --glob tests/**/*_test.go   All test files under tests/
244        ///
245        /// Tip: Use --file for simple substring matching instead:
246        ///   --file User.php             Simpler than --glob **/User.php
247        #[arg(short = 'g', long)]
248        glob: Vec<String>,
249
250        /// Exclude files matching glob pattern (can be repeated)
251        ///
252        /// Same syntax as --glob (** for recursive, * for single level)
253        ///
254        /// Examples:
255        ///   --exclude target/**         Exclude all files under target/
256        ///   --exclude **/*.gen.rs       Exclude generated Rust files
257        ///   --exclude node_modules/**   Exclude npm dependencies
258        #[arg(short = 'x', long)]
259        exclude: Vec<String>,
260
261        /// Return only unique file paths (no line numbers or content)
262        /// Compatible with --json to output ["path1", "path2", ...]
263        #[arg(short = 'p', long)]
264        paths: bool,
265
266        /// Disable smart preview truncation (show full lines)
267        /// By default, previews are truncated to ~100 chars to reduce token usage
268        #[arg(long)]
269        no_truncate: bool,
270
271        /// Number of context lines to show before and after each match (max: 10)
272        /// Example: -C 3 shows 3 lines before and after each match
273        #[arg(short = 'C', long, value_name = "N")]
274        context: Option<usize>,
275
276        /// Return all results (no limit)
277        #[arg(short = 'a', long)]
278        all: bool,
279
280        /// Force execution of potentially expensive queries
281        /// Bypasses broad query detection that prevents queries with:
282        /// • Short patterns (< 3 characters)
283        /// • High candidate counts (> 5,000 files for symbol/AST queries)
284        /// • AST queries without --glob restrictions
285        #[arg(long)]
286        force: bool,
287
288        /// Include dependency information (imports) in results
289        /// Currently only available for Rust files
290        #[arg(long)]
291        dependencies: bool,
292    },
293
294    /// Start a local HTTP API server
295    Serve {
296        /// Port to listen on
297        #[arg(short, long, default_value = "7878")]
298        port: u16,
299
300        /// Host to bind to
301        #[arg(long, default_value = "127.0.0.1")]
302        host: String,
303    },
304
305    /// Show index statistics and cache information
306    Stats {
307        /// Output format as JSON
308        #[arg(long)]
309        json: bool,
310
311        /// Pretty-print JSON output (only with --json)
312        #[arg(long)]
313        pretty: bool,
314    },
315
316    /// Clear the local cache
317    Clear {
318        /// Skip confirmation prompt
319        #[arg(short, long)]
320        yes: bool,
321    },
322
323    /// List all indexed files
324    ListFiles {
325        /// Output format as JSON
326        #[arg(long)]
327        json: bool,
328
329        /// Pretty-print JSON output (only with --json)
330        #[arg(long)]
331        pretty: bool,
332
333        /// Filter by language (e.g. rust, python, typescript)
334        #[arg(short, long)]
335        lang: Option<String>,
336
337        /// Include files matching glob pattern (can be repeated)
338        /// Example: --glob "src/**/*.rs"
339        #[arg(short = 'g', long)]
340        glob: Vec<String>,
341    },
342
343    /// Watch for file changes and auto-reindex
344    ///
345    /// Continuously monitors the workspace for changes and automatically
346    /// triggers incremental reindexing. Useful for IDE integrations and
347    /// keeping the index always fresh during active development.
348    ///
349    /// The debounce timer resets on every file change, batching rapid edits
350    /// (e.g., multi-file refactors, format-on-save) into a single reindex.
351    Watch {
352        /// Directory to watch (defaults to current directory)
353        #[arg(value_name = "PATH", default_value = ".")]
354        path: PathBuf,
355
356        /// Debounce duration in milliseconds (default: 15000 = 15s)
357        /// Waits this long after the last change before reindexing
358        /// Valid range: 5000-30000 (5-30 seconds)
359        #[arg(short, long, default_value = "15000")]
360        debounce: u64,
361
362        /// Suppress output (only log errors)
363        #[arg(short, long)]
364        quiet: bool,
365    },
366
367    /// Start MCP server for AI agent integration
368    ///
369    /// Runs Reflex as a Model Context Protocol (MCP) server using stdio transport.
370    /// This command is automatically invoked by MCP clients like Claude Code and
371    /// should not be run manually.
372    ///
373    /// Configuration example for Claude Code (~/.claude/claude_code_config.json):
374    /// {
375    ///   "mcpServers": {
376    ///     "reflex": {
377    ///       "type": "stdio",
378    ///       "command": "rfx",
379    ///       "args": ["mcp"]
380    ///     }
381    ///   }
382    /// }
383    Mcp,
384
385    /// Analyze codebase structure and dependencies
386    ///
387    /// Perform graph-wide dependency analysis to understand code architecture.
388    /// By default, shows a summary report with counts. Use specific flags for
389    /// detailed results.
390    ///
391    /// Examples:
392    ///   rfx analyze                                # Summary report
393    ///   rfx analyze --circular                     # Find cycles
394    ///   rfx analyze --hotspots                     # Most-imported files
395    ///   rfx analyze --hotspots --min-dependents 5  # Filter by minimum
396    ///   rfx analyze --unused                       # Orphaned files
397    ///   rfx analyze --islands                      # Disconnected components
398    ///   rfx analyze --hotspots --count             # Just show count
399    ///   rfx analyze --circular --glob "src/**"     # Limit to src/
400    Analyze {
401        /// Show circular dependencies
402        #[arg(long)]
403        circular: bool,
404
405        /// Show most-imported files (hotspots)
406        #[arg(long)]
407        hotspots: bool,
408
409        /// Minimum number of dependents for hotspots (default: 2)
410        #[arg(long, default_value = "2", requires = "hotspots")]
411        min_dependents: usize,
412
413        /// Show unused/orphaned files
414        #[arg(long)]
415        unused: bool,
416
417        /// Show disconnected components (islands)
418        #[arg(long)]
419        islands: bool,
420
421        /// Minimum island size (default: 2)
422        #[arg(long, default_value = "2", requires = "islands")]
423        min_island_size: usize,
424
425        /// Maximum island size (default: 500 or 50% of total files)
426        #[arg(long, requires = "islands")]
427        max_island_size: Option<usize>,
428
429        /// Output format: tree (default), table, dot
430        #[arg(short = 'f', long, default_value = "tree")]
431        format: String,
432
433        /// Output as JSON
434        #[arg(long)]
435        json: bool,
436
437        /// Pretty-print JSON output
438        #[arg(long)]
439        pretty: bool,
440
441        /// Only show count and timing, not the actual results
442        #[arg(short, long)]
443        count: bool,
444
445        /// Return all results (no limit)
446        /// Equivalent to --limit 0, convenience flag for unlimited results
447        #[arg(short = 'a', long)]
448        all: bool,
449
450        /// Use plain text output (disable colors and syntax highlighting)
451        #[arg(long)]
452        plain: bool,
453
454        /// Include files matching glob pattern (can be repeated)
455        /// Example: --glob "src/**/*.rs" --glob "tests/**/*.rs"
456        #[arg(short = 'g', long)]
457        glob: Vec<String>,
458
459        /// Exclude files matching glob pattern (can be repeated)
460        /// Example: --exclude "target/**" --exclude "*.gen.rs"
461        #[arg(short = 'x', long)]
462        exclude: Vec<String>,
463
464        /// Force execution of potentially expensive queries
465        /// Bypasses broad query detection
466        #[arg(long)]
467        force: bool,
468
469        /// Maximum number of results
470        #[arg(short = 'n', long)]
471        limit: Option<usize>,
472
473        /// Pagination offset
474        #[arg(short = 'o', long)]
475        offset: Option<usize>,
476
477        /// Sort order for results: asc (ascending) or desc (descending)
478        /// Applies to --hotspots (by import_count), --islands (by size), --circular (by cycle length)
479        /// Default: desc (most important first)
480        #[arg(long)]
481        sort: Option<String>,
482    },
483
484    /// Analyze dependencies for a specific file
485    ///
486    /// Show dependencies and dependents for a single file.
487    /// For graph-wide analysis, use 'rfx analyze' instead.
488    ///
489    /// Examples:
490    ///   rfx deps src/main.rs                  # Show dependencies
491    ///   rfx deps src/config.rs --reverse      # Show dependents
492    ///   rfx deps src/api.rs --depth 3         # Transitive deps
493    Deps {
494        /// File path to analyze
495        file: PathBuf,
496
497        /// Show files that depend on this file (reverse lookup)
498        #[arg(short, long)]
499        reverse: bool,
500
501        /// Traversal depth for transitive dependencies (default: 1)
502        #[arg(short, long, default_value = "1")]
503        depth: usize,
504
505        /// Output format: tree (default), table, dot
506        #[arg(short = 'f', long, default_value = "tree")]
507        format: String,
508
509        /// Output as JSON
510        #[arg(long)]
511        json: bool,
512
513        /// Pretty-print JSON output
514        #[arg(long)]
515        pretty: bool,
516    },
517
518    /// Ask a natural language question and generate search queries
519    ///
520    /// Uses an LLM to translate natural language questions into `rfx query` commands.
521    /// Requires API key configuration for one of: OpenAI, Anthropic, or OpenRouter.
522    ///
523    /// If no question is provided, launches interactive chat mode by default.
524    ///
525    /// Configuration:
526    ///   1. Run interactive setup wizard (recommended):
527    ///      rfx ask --configure
528    ///
529    ///   2. OR set API key via environment variable:
530    ///      - OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY
531    ///
532    ///   3. Optional: Configure provider in .reflex/config.toml:
533    ///      [semantic]
534    ///      provider = "openai"  # or anthropic, openrouter
535    ///      model = "gpt-5.1-mini"  # optional, defaults to provider default
536    ///
537    /// Examples:
538    ///   rfx ask --configure                           # Interactive setup wizard
539    ///   rfx ask                                       # Launch interactive chat (default)
540    ///   rfx ask "Find all TODOs in Rust files"
541    ///   rfx ask "Where is the main function defined?" --execute
542    ///   rfx ask "Show me error handling code" --provider openrouter
543    Ask {
544        /// Natural language question
545        question: Option<String>,
546
547        /// Execute queries immediately without confirmation
548        #[arg(short, long)]
549        execute: bool,
550
551        /// Override configured LLM provider (openai, anthropic, openrouter, openai-compatible)
552        #[arg(short, long)]
553        provider: Option<String>,
554
555        /// Output format as JSON
556        #[arg(long)]
557        json: bool,
558
559        /// Pretty-print JSON output (only with --json)
560        #[arg(long)]
561        pretty: bool,
562
563        /// Additional context to inject into prompt (e.g., from `rfx context`)
564        #[arg(long)]
565        additional_context: Option<String>,
566
567        /// Launch interactive configuration wizard to set up AI provider and API key
568        #[arg(long)]
569        configure: bool,
570
571        /// Enable agentic mode (multi-step reasoning with context gathering)
572        #[arg(long)]
573        agentic: bool,
574
575        /// Maximum iterations for query refinement in agentic mode (default: 2)
576        #[arg(long, default_value = "2")]
577        max_iterations: usize,
578
579        /// Skip result evaluation in agentic mode
580        #[arg(long)]
581        no_eval: bool,
582
583        /// Show LLM reasoning blocks at each phase (agentic mode only)
584        #[arg(long)]
585        show_reasoning: bool,
586
587        /// Verbose output: show tool results and details (agentic mode only)
588        #[arg(long)]
589        verbose: bool,
590
591        /// Quiet mode: suppress progress output (agentic mode only)
592        #[arg(long)]
593        quiet: bool,
594
595        /// Generate a conversational answer based on search results
596        #[arg(long)]
597        answer: bool,
598
599        /// Launch interactive chat mode (TUI) with conversation history
600        #[arg(short = 'i', long)]
601        interactive: bool,
602
603        /// Debug mode: output full LLM prompts and retain terminal history
604        #[arg(long)]
605        debug: bool,
606    },
607
608    /// Generate codebase context for AI prompts
609    ///
610    /// Provides structural and organizational context about the project to help
611    /// LLMs understand project layout. Use with `rfx ask --additional-context`.
612    ///
613    /// By default (no flags), shows all context types. Use individual flags to
614    /// select specific context types.
615    ///
616    /// Examples:
617    ///   rfx context                                    # Full context (all types)
618    ///   rfx context --path services/backend            # Full context for monorepo subdirectory
619    ///   rfx context --framework --entry-points         # Specific context types only
620    ///   rfx context --structure --depth 5              # Deep directory tree
621    ///
622    ///   # Use with semantic queries
623    ///   rfx ask "find auth" --additional-context "$(rfx context --framework)"
624    Context {
625        /// Show directory structure (enabled by default)
626        #[arg(long)]
627        structure: bool,
628
629        /// Focus on specific directory path
630        #[arg(short, long)]
631        path: Option<String>,
632
633        /// Show file type distribution (enabled by default)
634        #[arg(long)]
635        file_types: bool,
636
637        /// Detect project type (CLI/library/webapp/monorepo)
638        #[arg(long)]
639        project_type: bool,
640
641        /// Detect frameworks and conventions
642        #[arg(long)]
643        framework: bool,
644
645        /// Show entry point files
646        #[arg(long)]
647        entry_points: bool,
648
649        /// Show test organization pattern
650        #[arg(long)]
651        test_layout: bool,
652
653        /// List important configuration files
654        #[arg(long)]
655        config_files: bool,
656
657        /// Tree depth for --structure (default: 1)
658        #[arg(long, default_value = "1")]
659        depth: usize,
660
661        /// Output as JSON
662        #[arg(long)]
663        json: bool,
664    },
665
666    /// Internal command: Run background symbol indexing (hidden from help)
667    #[command(hide = true)]
668    IndexSymbolsInternal {
669        /// Cache directory path
670        cache_dir: PathBuf,
671    },
672
673    /// Take and manage codebase snapshots for structural tracking
674    ///
675    /// Snapshots capture the structural state of the index (files, dependencies,
676    /// metrics) for diffing and historical analysis.
677    ///
678    /// With no subcommand, creates a new snapshot.
679    ///
680    /// Examples:
681    ///   rfx snapshot               # Create a new snapshot
682    ///   rfx snapshot list           # List available snapshots
683    ///   rfx snapshot diff           # Diff latest vs previous
684    ///   rfx snapshot gc             # Run retention policy
685    Snapshot {
686        #[command(subcommand)]
687        command: Option<SnapshotSubcommand>,
688    },
689
690    /// Generate codebase intelligence surfaces (changelog, wiki, map, site)
691    ///
692    /// Pulse turns structural facts from the index into browsable documentation.
693    /// The `generate` command creates a Zola project and builds it into a static HTML site.
694    ///
695    /// Examples:
696    ///   rfx pulse changelog --no-llm         # Structural-only changelog
697    ///   rfx pulse wiki --no-llm             # Generate wiki pages
698    ///   rfx pulse map                        # Architecture map (mermaid)
699    ///   rfx pulse generate --no-llm          # Full static site (Zola)
700    Pulse {
701        #[command(subcommand)]
702        command: PulseSubcommand,
703    },
704
705    /// Manage LLM provider configuration (shared by `ask` and `pulse`)
706    ///
707    /// Examples:
708    ///   rfx llm config                       # Launch interactive setup wizard
709    ///   rfx llm status                       # Show current LLM configuration
710    Llm {
711        #[command(subcommand)]
712        command: LlmSubcommand,
713    },
714}
715
716#[derive(Subcommand, Debug)]
717pub enum SnapshotSubcommand {
718    /// Compare two snapshots
719    ///
720    /// Defaults to latest vs previous snapshot.
721    Diff {
722        /// Baseline snapshot ID (defaults to second-most-recent)
723        #[arg(long)]
724        baseline: Option<String>,
725
726        /// Current snapshot ID (defaults to most recent)
727        #[arg(long)]
728        current: Option<String>,
729
730        /// Output as JSON
731        #[arg(long)]
732        json: bool,
733
734        /// Pretty-print JSON output
735        #[arg(long)]
736        pretty: bool,
737    },
738
739    /// List available snapshots
740    List {
741        /// Output as JSON
742        #[arg(long)]
743        json: bool,
744
745        /// Pretty-print JSON output
746        #[arg(long)]
747        pretty: bool,
748    },
749
750    /// Run snapshot garbage collection
751    Gc {
752        /// Output as JSON
753        #[arg(long)]
754        json: bool,
755    },
756}
757
758#[derive(Subcommand, Debug)]
759pub enum PulseSubcommand {
760    /// Generate a product-level changelog from recent commits
761    Changelog {
762        /// Number of recent commits to include (default: 20)
763        #[arg(long, default_value = "20")]
764        count: usize,
765
766        /// Skip LLM narration (structural content only)
767        #[arg(long)]
768        no_llm: bool,
769
770        /// Output as JSON
771        #[arg(long)]
772        json: bool,
773
774        /// Pretty-print JSON output
775        #[arg(long)]
776        pretty: bool,
777    },
778
779    /// Generate living wiki pages
780    Wiki {
781        /// Skip LLM narration
782        #[arg(long)]
783        no_llm: bool,
784
785        /// Output directory for markdown files
786        #[arg(short, long)]
787        output: Option<PathBuf>,
788
789        /// Output as JSON
790        #[arg(long)]
791        json: bool,
792    },
793
794    /// Export an architecture map
795    Map {
796        /// Output format (mermaid, d2)
797        #[arg(short, long, default_value = "mermaid")]
798        format: String,
799
800        /// Output file (prints to stdout if not set)
801        #[arg(short, long)]
802        output: Option<PathBuf>,
803
804        /// Zoom level: repo (default) or module path
805        #[arg(short, long)]
806        zoom: Option<String>,
807    },
808
809    /// Generate a complete static site (Zola project + HTML build)
810    ///
811    /// Creates a Zola project with markdown content, templates, and CSS,
812    /// then downloads Zola and builds it into a static HTML site.
813    /// The --base-url maps to Zola's base_url config.
814    Generate {
815        /// Output directory for the Zola project
816        #[arg(short, long, default_value = "pulse-site")]
817        output: PathBuf,
818
819        /// Base URL for the site (maps to Zola's base_url)
820        #[arg(long, default_value = "/")]
821        base_url: String,
822
823        /// Site title
824        #[arg(long)]
825        title: Option<String>,
826
827        /// Surfaces to include (comma-separated: wiki,changelog,map,onboard,timeline,glossary,explorer)
828        #[arg(long)]
829        include: Option<String>,
830
831        /// Skip LLM narration
832        #[arg(long)]
833        no_llm: bool,
834
835        /// Clean output directory before generating
836        #[arg(long)]
837        clean: bool,
838
839        /// Force re-narration (ignore LLM cache)
840        #[arg(long)]
841        force_renarrate: bool,
842
843        /// Maximum concurrent LLM requests (0 = unlimited, default)
844        #[arg(long, default_value = "0")]
845        concurrency: usize,
846
847        /// Maximum directory depth for module discovery (1=top-level only, 2=default)
848        #[arg(long, default_value = "2")]
849        depth: u8,
850
851        /// Minimum file count for a module to be included
852        #[arg(long, default_value = "1")]
853        min_files: usize,
854    },
855
856    /// Serve the generated site locally
857    ///
858    /// Starts a local development server for the Pulse site.
859    /// Uses Zola's built-in server with live reload.
860    Serve {
861        /// Directory containing the generated Zola project
862        #[arg(short, long, default_value = "pulse-site")]
863        output: PathBuf,
864
865        /// Port to serve on
866        #[arg(short, long, default_value = "1111")]
867        port: u16,
868
869        /// Open browser automatically
870        #[arg(long, default_value = "true")]
871        open: bool,
872    },
873
874    /// Generate a developer onboarding guide
875    Onboard {
876        /// Skip LLM narration
877        #[arg(long)]
878        no_llm: bool,
879
880        /// Output as JSON
881        #[arg(long)]
882        json: bool,
883    },
884
885    /// Show development timeline from git history
886    Timeline {
887        /// Output as JSON
888        #[arg(long)]
889        json: bool,
890    },
891
892    /// Generate cross-cutting symbol glossary
893    Glossary {
894        /// Output as JSON
895        #[arg(long)]
896        json: bool,
897    },
898}
899
900#[derive(Subcommand, Debug)]
901pub enum LlmSubcommand {
902    /// Launch interactive configuration wizard for AI provider and API key
903    Config,
904    /// Show current LLM configuration status
905    Status,
906}
907
908/// Format a byte count into a human-readable string (B, KB, MB, GB, TB).
909fn format_bytes(bytes: u64) -> String {
910    const KB: u64 = 1024;
911    const MB: u64 = KB * 1024;
912    const GB: u64 = MB * 1024;
913    const TB: u64 = GB * 1024;
914
915    if bytes >= TB {
916        format!("{:.2} TB", bytes as f64 / TB as f64)
917    } else if bytes >= GB {
918        format!("{:.2} GB", bytes as f64 / GB as f64)
919    } else if bytes >= MB {
920        format!("{:.2} MB", bytes as f64 / MB as f64)
921    } else if bytes >= KB {
922        format!("{:.2} KB", bytes as f64 / KB as f64)
923    } else if bytes > 0 {
924        format!("{} bytes", bytes)
925    } else {
926        "< 1 KB".to_string()
927    }
928}
929
930/// Try to run background cache compaction if needed
931///
932/// Checks if 24+ hours have passed since last compaction.
933/// If yes, spawns a non-blocking background thread to compact the cache.
934/// Main command continues immediately without waiting for compaction.
935///
936/// Compaction is skipped for commands that don't need it:
937/// - Clear (will delete the cache anyway)
938/// - Mcp (long-running server process)
939/// - Watch (long-running watcher process)
940/// - Serve (long-running HTTP server)
941fn try_background_compact(cache: &CacheManager, command: &Command) {
942    // Skip compaction for certain commands
943    match command {
944        Command::Clear { .. } => {
945            log::debug!("Skipping compaction for Clear command");
946            return;
947        }
948        Command::Mcp => {
949            log::debug!("Skipping compaction for Mcp command");
950            return;
951        }
952        Command::Watch { .. } => {
953            log::debug!("Skipping compaction for Watch command");
954            return;
955        }
956        Command::Serve { .. } => {
957            log::debug!("Skipping compaction for Serve command");
958            return;
959        }
960        _ => {}
961    }
962
963    // Check if compaction should run
964    let should_compact = match cache.should_compact() {
965        Ok(true) => true,
966        Ok(false) => {
967            log::debug!("Compaction not needed yet (last run <24h ago)");
968            return;
969        }
970        Err(e) => {
971            log::warn!("Failed to check compaction status: {}", e);
972            return;
973        }
974    };
975
976    if !should_compact {
977        return;
978    }
979
980    log::info!("Starting background cache compaction...");
981
982    // Clone cache path for background thread
983    let cache_path = cache.path().to_path_buf();
984
985    // Spawn background thread for compaction
986    std::thread::spawn(move || {
987        let cache = CacheManager::new(
988            cache_path
989                .parent()
990                .expect("Cache should have parent directory"),
991        );
992
993        match cache.compact() {
994            Ok(report) => {
995                log::info!(
996                    "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
997                    report.files_removed,
998                    report.space_saved_bytes as f64 / 1_048_576.0,
999                    report.duration_ms
1000                );
1001            }
1002            Err(e) => {
1003                log::warn!("Background compaction failed: {}", e);
1004            }
1005        }
1006    });
1007
1008    log::debug!("Background compaction thread spawned - main command continuing");
1009}
1010
1011impl Cli {
1012    /// Execute the CLI command
1013    pub fn execute(self) -> Result<()> {
1014        // Setup logging based on verbosity
1015        let log_level = match self.verbose {
1016            0 => "warn",  // Default: only warnings and errors
1017            1 => "info",  // -v: show info messages
1018            2 => "debug", // -vv: show debug messages
1019            _ => "trace", // -vvv: show trace messages
1020        };
1021        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
1022            .init();
1023
1024        // Try background compaction (non-blocking) before command execution
1025        if let Some(ref command) = self.command {
1026            // Use current directory as default cache location
1027            let cache = CacheManager::new(".");
1028            try_background_compact(&cache, command);
1029        }
1030
1031        // Execute the subcommand, or show help if no command provided
1032        match self.command {
1033            None => {
1034                // No subcommand: show help
1035                Cli::command().print_help()?;
1036                println!(); // Add newline after help
1037                Ok(())
1038            }
1039            Some(Command::Index {
1040                path,
1041                force,
1042                languages,
1043                quiet,
1044                command,
1045            }) => {
1046                match command {
1047                    None => {
1048                        // Default: run index build
1049                        index::handle_index_build(&path, &force, &languages, &quiet)
1050                    }
1051                    Some(IndexSubcommand::Status) => index::handle_index_status(),
1052                    Some(IndexSubcommand::Compact { json, pretty }) => {
1053                        index::handle_index_compact(&json, &pretty)
1054                    }
1055                }
1056            }
1057            Some(Command::Query {
1058                pattern,
1059                symbols,
1060                lang,
1061                kind,
1062                ast,
1063                regex,
1064                json,
1065                pretty,
1066                ai,
1067                limit,
1068                offset,
1069                expand,
1070                file,
1071                exact,
1072                contains,
1073                count,
1074                timeout,
1075                plain,
1076                glob,
1077                exclude,
1078                paths,
1079                no_truncate,
1080                context,
1081                all,
1082                force,
1083                dependencies,
1084            }) => {
1085                // If no pattern provided, launch interactive mode (REF-68: require TTY)
1086                match pattern {
1087                    None => {
1088                        use crossterm::tty::IsTty;
1089                        if !std::io::stdin().is_tty() {
1090                            eprintln!("error: interactive mode requires a terminal (TTY).");
1091                            eprintln!("Use 'rfx query <pattern>' for non-interactive search.");
1092                            std::process::exit(1);
1093                        }
1094                        query::handle_interactive()
1095                    }
1096                    Some(pattern) => query::handle_query(
1097                        pattern,
1098                        symbols,
1099                        lang,
1100                        kind,
1101                        ast,
1102                        regex,
1103                        json,
1104                        pretty,
1105                        ai,
1106                        limit,
1107                        offset,
1108                        expand,
1109                        file,
1110                        exact,
1111                        contains,
1112                        count,
1113                        timeout,
1114                        plain,
1115                        glob,
1116                        exclude,
1117                        paths,
1118                        no_truncate,
1119                        context,
1120                        all,
1121                        force,
1122                        dependencies,
1123                    ),
1124                }
1125            }
1126            Some(Command::Serve { port, host }) => serve::handle_serve(port, host),
1127            Some(Command::Stats { json, pretty }) => misc::handle_stats(json, pretty),
1128            Some(Command::Clear { yes }) => misc::handle_clear(yes),
1129            Some(Command::ListFiles {
1130                json,
1131                pretty,
1132                lang,
1133                glob,
1134            }) => misc::handle_list_files(json, pretty, lang, glob),
1135            Some(Command::Watch {
1136                path,
1137                debounce,
1138                quiet,
1139            }) => watch::handle_watch(path, debounce, quiet),
1140            Some(Command::Mcp) => misc::handle_mcp(),
1141            Some(Command::Analyze {
1142                circular,
1143                hotspots,
1144                min_dependents,
1145                unused,
1146                islands,
1147                min_island_size,
1148                max_island_size,
1149                format,
1150                json,
1151                pretty,
1152                count,
1153                all,
1154                plain,
1155                glob,
1156                exclude,
1157                force,
1158                limit,
1159                offset,
1160                sort,
1161            }) => deps::handle_analyze(
1162                circular,
1163                hotspots,
1164                min_dependents,
1165                unused,
1166                islands,
1167                min_island_size,
1168                max_island_size,
1169                format,
1170                json,
1171                pretty,
1172                count,
1173                all,
1174                plain,
1175                glob,
1176                exclude,
1177                force,
1178                limit,
1179                offset,
1180                sort,
1181            ),
1182            Some(Command::Deps {
1183                file,
1184                reverse,
1185                depth,
1186                format,
1187                json,
1188                pretty,
1189            }) => deps::handle_deps(file, reverse, depth, format, json, pretty),
1190            Some(Command::Ask {
1191                question,
1192                execute,
1193                provider,
1194                json,
1195                pretty,
1196                additional_context,
1197                configure,
1198                agentic,
1199                max_iterations,
1200                no_eval,
1201                show_reasoning,
1202                verbose,
1203                quiet,
1204                answer,
1205                interactive,
1206                debug,
1207            }) => ask::handle_ask(
1208                question,
1209                execute,
1210                provider,
1211                json,
1212                pretty,
1213                additional_context,
1214                configure,
1215                agentic,
1216                max_iterations,
1217                no_eval,
1218                show_reasoning,
1219                verbose,
1220                quiet,
1221                answer,
1222                interactive,
1223                debug,
1224            ),
1225            Some(Command::Context {
1226                structure,
1227                path,
1228                file_types,
1229                project_type,
1230                framework,
1231                entry_points,
1232                test_layout,
1233                config_files,
1234                depth,
1235                json,
1236            }) => misc::handle_context(
1237                structure,
1238                path,
1239                file_types,
1240                project_type,
1241                framework,
1242                entry_points,
1243                test_layout,
1244                config_files,
1245                depth,
1246                json,
1247            ),
1248            Some(Command::IndexSymbolsInternal { cache_dir }) => {
1249                index::handle_index_symbols_internal(cache_dir)
1250            }
1251            Some(Command::Snapshot { command }) => match command {
1252                None => snapshot::handle_snapshot_create(),
1253                Some(SnapshotSubcommand::List { json, pretty }) => {
1254                    snapshot::handle_snapshot_list(json, pretty)
1255                }
1256                Some(SnapshotSubcommand::Diff {
1257                    baseline,
1258                    current,
1259                    json,
1260                    pretty,
1261                }) => snapshot::handle_snapshot_diff(baseline, current, json, pretty),
1262                Some(SnapshotSubcommand::Gc { json }) => snapshot::handle_snapshot_gc(json),
1263            },
1264            Some(Command::Pulse { command }) => match command {
1265                PulseSubcommand::Changelog {
1266                    count,
1267                    no_llm,
1268                    json,
1269                    pretty,
1270                } => pulse::handle_pulse_changelog(count, no_llm, json, pretty),
1271                PulseSubcommand::Wiki {
1272                    no_llm,
1273                    output,
1274                    json,
1275                } => pulse::handle_pulse_wiki(no_llm, output, json),
1276                PulseSubcommand::Map {
1277                    format,
1278                    output,
1279                    zoom,
1280                } => pulse::handle_pulse_map(format, output, zoom),
1281                PulseSubcommand::Generate {
1282                    output,
1283                    base_url,
1284                    title,
1285                    include,
1286                    no_llm,
1287                    clean,
1288                    force_renarrate,
1289                    concurrency,
1290                    depth,
1291                    min_files,
1292                } => pulse::handle_pulse_generate(
1293                    output,
1294                    base_url,
1295                    title,
1296                    include,
1297                    no_llm,
1298                    clean,
1299                    force_renarrate,
1300                    concurrency,
1301                    depth,
1302                    min_files,
1303                ),
1304                PulseSubcommand::Serve { output, port, open } => {
1305                    pulse::handle_pulse_serve(output, port, open)
1306                }
1307                PulseSubcommand::Onboard { no_llm, json } => {
1308                    pulse::handle_pulse_onboard(no_llm, json)
1309                }
1310                PulseSubcommand::Timeline { json } => pulse::handle_pulse_timeline(json),
1311                PulseSubcommand::Glossary { json } => pulse::handle_pulse_glossary(json),
1312            },
1313            Some(Command::Llm { command }) => match command {
1314                LlmSubcommand::Config => llm::handle_llm_config(),
1315                LlmSubcommand::Status => llm::handle_llm_status(),
1316            },
1317        }
1318    }
1319}