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}