Skip to main content

reflex/cli/
mod.rs

1//! CLI argument parsing and command router
2
3use anyhow::Result;
4use clap::{CommandFactory, Parser, Subcommand};
5use std::path::PathBuf;
6use crate::cache::CacheManager;
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(cache_path.parent().expect("Cache should have parent directory"));
988
989        match cache.compact() {
990            Ok(report) => {
991                log::info!(
992                    "Background compaction completed: {} files removed, {:.2} MB saved, took {}ms",
993                    report.files_removed,
994                    report.space_saved_bytes as f64 / 1_048_576.0,
995                    report.duration_ms
996                );
997            }
998            Err(e) => {
999                log::warn!("Background compaction failed: {}", e);
1000            }
1001        }
1002    });
1003
1004    log::debug!("Background compaction thread spawned - main command continuing");
1005}
1006
1007impl Cli {
1008    /// Execute the CLI command
1009    pub fn execute(self) -> Result<()> {
1010        // Setup logging based on verbosity
1011        let log_level = match self.verbose {
1012            0 => "warn",   // Default: only warnings and errors
1013            1 => "info",   // -v: show info messages
1014            2 => "debug",  // -vv: show debug messages
1015            _ => "trace",  // -vvv: show trace messages
1016        };
1017        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level))
1018            .init();
1019
1020        // Try background compaction (non-blocking) before command execution
1021        if let Some(ref command) = self.command {
1022            // Use current directory as default cache location
1023            let cache = CacheManager::new(".");
1024            try_background_compact(&cache, command);
1025        }
1026
1027        // Execute the subcommand, or show help if no command provided
1028        match self.command {
1029            None => {
1030                // No subcommand: show help
1031                Cli::command().print_help()?;
1032                println!();  // Add newline after help
1033                Ok(())
1034            }
1035            Some(Command::Index { path, force, languages, quiet, command }) => {
1036                match command {
1037                    None => {
1038                        // Default: run index build
1039                        index::handle_index_build(&path, &force, &languages, &quiet)
1040                    }
1041                    Some(IndexSubcommand::Status) => {
1042                        index::handle_index_status()
1043                    }
1044                    Some(IndexSubcommand::Compact { json, pretty }) => {
1045                        index::handle_index_compact(&json, &pretty)
1046                    }
1047                }
1048            }
1049            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, context, all, force, dependencies }) => {
1050                // If no pattern provided, launch interactive mode (REF-68: require TTY)
1051                match pattern {
1052                    None => {
1053                        use crossterm::tty::IsTty;
1054                        if !std::io::stdin().is_tty() {
1055                            eprintln!("error: interactive mode requires a terminal (TTY).");
1056                            eprintln!("Use 'rfx query <pattern>' for non-interactive search.");
1057                            std::process::exit(1);
1058                        }
1059                        query::handle_interactive()
1060                    }
1061                    Some(pattern) => query::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, context, all, force, dependencies)
1062                }
1063            }
1064            Some(Command::Serve { port, host }) => {
1065                serve::handle_serve(port, host)
1066            }
1067            Some(Command::Stats { json, pretty }) => {
1068                misc::handle_stats(json, pretty)
1069            }
1070            Some(Command::Clear { yes }) => {
1071                misc::handle_clear(yes)
1072            }
1073            Some(Command::ListFiles { json, pretty, lang, glob }) => {
1074                misc::handle_list_files(json, pretty, lang, glob)
1075            }
1076            Some(Command::Watch { path, debounce, quiet }) => {
1077                watch::handle_watch(path, debounce, quiet)
1078            }
1079            Some(Command::Mcp) => {
1080                misc::handle_mcp()
1081            }
1082            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 }) => {
1083                deps::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)
1084            }
1085            Some(Command::Deps { file, reverse, depth, format, json, pretty }) => {
1086                deps::handle_deps(file, reverse, depth, format, json, pretty)
1087            }
1088            Some(Command::Ask { question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug }) => {
1089                ask::handle_ask(question, execute, provider, json, pretty, additional_context, configure, agentic, max_iterations, no_eval, show_reasoning, verbose, quiet, answer, interactive, debug)
1090            }
1091            Some(Command::Context { structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json }) => {
1092                misc::handle_context(structure, path, file_types, project_type, framework, entry_points, test_layout, config_files, depth, json)
1093            }
1094            Some(Command::IndexSymbolsInternal { cache_dir }) => {
1095                index::handle_index_symbols_internal(cache_dir)
1096            }
1097            Some(Command::Snapshot { command }) => {
1098                match command {
1099                    None => snapshot::handle_snapshot_create(),
1100                    Some(SnapshotSubcommand::List { json, pretty }) => {
1101                        snapshot::handle_snapshot_list(json, pretty)
1102                    }
1103                    Some(SnapshotSubcommand::Diff { baseline, current, json, pretty }) => {
1104                        snapshot::handle_snapshot_diff(baseline, current, json, pretty)
1105                    }
1106                    Some(SnapshotSubcommand::Gc { json }) => {
1107                        snapshot::handle_snapshot_gc(json)
1108                    }
1109                }
1110            }
1111            Some(Command::Pulse { command }) => {
1112                match command {
1113                    PulseSubcommand::Changelog { count, no_llm, json, pretty } => {
1114                        pulse::handle_pulse_changelog(count, no_llm, json, pretty)
1115                    }
1116                    PulseSubcommand::Wiki { no_llm, output, json } => {
1117                        pulse::handle_pulse_wiki(no_llm, output, json)
1118                    }
1119                    PulseSubcommand::Map { format, output, zoom } => {
1120                        pulse::handle_pulse_map(format, output, zoom)
1121                    }
1122                    PulseSubcommand::Generate { output, base_url, title, include, no_llm, clean, force_renarrate, concurrency, depth, min_files } => {
1123                        pulse::handle_pulse_generate(output, base_url, title, include, no_llm, clean, force_renarrate, concurrency, depth, min_files)
1124                    }
1125                    PulseSubcommand::Serve { output, port, open } => {
1126                        pulse::handle_pulse_serve(output, port, open)
1127                    }
1128                    PulseSubcommand::Onboard { no_llm, json } => {
1129                        pulse::handle_pulse_onboard(no_llm, json)
1130                    }
1131                    PulseSubcommand::Timeline { json } => {
1132                        pulse::handle_pulse_timeline(json)
1133                    }
1134                    PulseSubcommand::Glossary { json } => {
1135                        pulse::handle_pulse_glossary(json)
1136                    }
1137                }
1138            }
1139            Some(Command::Llm { command }) => {
1140                match command {
1141                    LlmSubcommand::Config => llm::handle_llm_config(),
1142                    LlmSubcommand::Status => llm::handle_llm_status(),
1143                }
1144            }
1145        }
1146    }
1147}