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