Skip to main content

fabryk_cli/
cli.rs

1//! CLI argument parsing and command definitions.
2//!
3//! Provides the common CLI structure that all Fabryk-based applications share:
4//! configuration, verbosity, and base commands (serve, index, version, health, graph).
5//!
6//! Domain applications extend this via the [`CliExtension`] trait.
7
8use clap::{Parser, Subcommand};
9
10// ============================================================================
11// CLI argument types
12// ============================================================================
13
14/// Top-level CLI arguments for Fabryk applications.
15#[derive(Parser, Debug)]
16#[command(author, about, long_about = None)]
17pub struct CliArgs {
18    /// Path to configuration file.
19    #[arg(short, long, env = "FABRYK_CONFIG")]
20    pub config: Option<String>,
21
22    /// Enable verbose output.
23    #[arg(short, long)]
24    pub verbose: bool,
25
26    /// Suppress non-essential output.
27    #[arg(short, long)]
28    pub quiet: bool,
29
30    /// Subcommand to execute.
31    #[command(subcommand)]
32    pub command: Option<BaseCommand>,
33}
34
35/// Built-in commands shared by all Fabryk applications.
36#[derive(Subcommand, Debug)]
37pub enum BaseCommand {
38    /// Start the MCP server.
39    Serve {
40        /// Port to listen on.
41        #[arg(short, long, default_value = "3000")]
42        port: u16,
43    },
44
45    /// Build or refresh the content index.
46    Index {
47        /// Force full re-index.
48        #[arg(short, long)]
49        force: bool,
50
51        /// Check index freshness without rebuilding.
52        #[arg(long)]
53        check: bool,
54    },
55
56    /// Print version information.
57    Version,
58
59    /// Check system health.
60    Health,
61
62    /// Graph operations.
63    Graph(GraphCommand),
64
65    /// Configuration operations.
66    Config(ConfigCommand),
67
68    /// Vector database operations.
69    #[cfg(feature = "vector-fastembed")]
70    Vectordb(VectordbCommand),
71}
72
73/// Config-specific subcommands.
74#[derive(Parser, Debug)]
75pub struct ConfigCommand {
76    /// Config subcommand to execute.
77    #[command(subcommand)]
78    pub command: ConfigAction,
79}
80
81/// Available config subcommands.
82#[derive(Subcommand, Debug)]
83pub enum ConfigAction {
84    /// Show the resolved config file path.
85    Path,
86
87    /// Get a configuration value by dotted key.
88    Get {
89        /// Dotted key (e.g., "server.port").
90        key: String,
91    },
92
93    /// Set a configuration value by dotted key.
94    Set {
95        /// Dotted key (e.g., "server.port").
96        key: String,
97
98        /// Value to set.
99        value: String,
100    },
101
102    /// Create a default configuration file.
103    Init {
104        /// Output file path (defaults to XDG config path).
105        #[arg(short, long)]
106        file: Option<String>,
107
108        /// Overwrite existing file.
109        #[arg(long)]
110        force: bool,
111    },
112
113    /// Export configuration as environment variables.
114    Export {
115        /// Format as Docker --env flags.
116        #[arg(long)]
117        docker_env: bool,
118    },
119}
120
121/// Graph-specific subcommands.
122#[derive(Parser, Debug)]
123pub struct GraphCommand {
124    /// Graph subcommand to execute.
125    #[command(subcommand)]
126    pub command: GraphSubcommand,
127}
128
129/// Available graph subcommands.
130#[derive(Subcommand, Debug)]
131pub enum GraphSubcommand {
132    /// Build the knowledge graph from content.
133    Build {
134        /// Output file path for the graph.
135        #[arg(short, long)]
136        output: Option<String>,
137
138        /// Show what would be built without writing.
139        #[arg(long)]
140        dry_run: bool,
141    },
142
143    /// Validate graph integrity.
144    Validate,
145
146    /// Show graph statistics.
147    Stats,
148
149    /// Query the graph.
150    Query {
151        /// Node ID to query.
152        #[arg(short, long)]
153        id: String,
154
155        /// Type of query: related, prerequisites, path.
156        #[arg(short = 't', long, default_value = "related")]
157        query_type: String,
158
159        /// Target node ID (for path queries).
160        #[arg(long)]
161        to: Option<String>,
162    },
163}
164
165// ============================================================================
166// Vectordb commands (feature-gated)
167// ============================================================================
168
169/// Vectordb-specific subcommands.
170#[cfg(feature = "vector-fastembed")]
171#[derive(Parser, Debug)]
172pub struct VectordbCommand {
173    /// Vectordb subcommand to execute.
174    #[command(subcommand)]
175    pub command: VectordbAction,
176}
177
178/// Available vectordb subcommands.
179#[cfg(feature = "vector-fastembed")]
180#[derive(Subcommand, Debug)]
181pub enum VectordbAction {
182    /// Download and cache the embedding model.
183    GetModel {
184        /// Embedding model name (e.g., "bge-small-en-v1.5").
185        #[arg(long)]
186        model: Option<String>,
187
188        /// Directory to cache the downloaded model.
189        #[arg(long)]
190        cache_dir: Option<String>,
191    },
192}
193
194// ============================================================================
195// CliExtension trait
196// ============================================================================
197
198/// Extension point for domain-specific CLI commands.
199///
200/// Domain applications implement this trait to add custom subcommands
201/// beyond the built-in base commands.
202pub trait CliExtension: Send + Sync {
203    /// The domain-specific command type.
204    type Command: Send + Sync;
205
206    /// Handle a domain-specific command.
207    fn handle_command(
208        &self,
209        command: Self::Command,
210    ) -> impl std::future::Future<Output = fabryk_core::Result<()>> + Send;
211}
212
213// ============================================================================
214// Tests
215// ============================================================================
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use clap::Parser;
221
222    #[test]
223    fn test_cli_args_default() {
224        let args = CliArgs::parse_from(["test"]);
225        assert!(args.config.is_none());
226        assert!(!args.verbose);
227        assert!(!args.quiet);
228        assert!(args.command.is_none());
229    }
230
231    #[test]
232    fn test_cli_args_verbose() {
233        let args = CliArgs::parse_from(["test", "--verbose"]);
234        assert!(args.verbose);
235        assert!(!args.quiet);
236    }
237
238    #[test]
239    fn test_cli_args_quiet() {
240        let args = CliArgs::parse_from(["test", "--quiet"]);
241        assert!(!args.verbose);
242        assert!(args.quiet);
243    }
244
245    #[test]
246    fn test_cli_args_config() {
247        let args = CliArgs::parse_from(["test", "--config", "/path/to/config.toml"]);
248        assert_eq!(args.config, Some("/path/to/config.toml".to_string()));
249    }
250
251    #[test]
252    fn test_serve_command() {
253        let args = CliArgs::parse_from(["test", "serve"]);
254        match args.command {
255            Some(BaseCommand::Serve { port }) => assert_eq!(port, 3000),
256            _ => panic!("Expected Serve command"),
257        }
258    }
259
260    #[test]
261    fn test_serve_command_custom_port() {
262        let args = CliArgs::parse_from(["test", "serve", "--port", "8080"]);
263        match args.command {
264            Some(BaseCommand::Serve { port }) => assert_eq!(port, 8080),
265            _ => panic!("Expected Serve command"),
266        }
267    }
268
269    #[test]
270    fn test_index_command() {
271        let args = CliArgs::parse_from(["test", "index"]);
272        match args.command {
273            Some(BaseCommand::Index { force, check }) => {
274                assert!(!force);
275                assert!(!check);
276            }
277            _ => panic!("Expected Index command"),
278        }
279    }
280
281    #[test]
282    fn test_index_command_force() {
283        let args = CliArgs::parse_from(["test", "index", "--force"]);
284        match args.command {
285            Some(BaseCommand::Index { force, check }) => {
286                assert!(force);
287                assert!(!check);
288            }
289            _ => panic!("Expected Index command with force"),
290        }
291    }
292
293    #[test]
294    fn test_version_command() {
295        let args = CliArgs::parse_from(["test", "version"]);
296        assert!(matches!(args.command, Some(BaseCommand::Version)));
297    }
298
299    #[test]
300    fn test_health_command() {
301        let args = CliArgs::parse_from(["test", "health"]);
302        assert!(matches!(args.command, Some(BaseCommand::Health)));
303    }
304
305    #[test]
306    fn test_graph_build_command() {
307        let args = CliArgs::parse_from(["test", "graph", "build"]);
308        match args.command {
309            Some(BaseCommand::Graph(GraphCommand {
310                command: GraphSubcommand::Build { output, dry_run },
311            })) => {
312                assert!(output.is_none());
313                assert!(!dry_run);
314            }
315            _ => panic!("Expected Graph Build command"),
316        }
317    }
318
319    #[test]
320    fn test_graph_build_dry_run() {
321        let args = CliArgs::parse_from(["test", "graph", "build", "--dry-run"]);
322        match args.command {
323            Some(BaseCommand::Graph(GraphCommand {
324                command: GraphSubcommand::Build { dry_run, .. },
325            })) => {
326                assert!(dry_run);
327            }
328            _ => panic!("Expected Graph Build command with dry_run"),
329        }
330    }
331
332    #[test]
333    fn test_graph_validate_command() {
334        let args = CliArgs::parse_from(["test", "graph", "validate"]);
335        match args.command {
336            Some(BaseCommand::Graph(GraphCommand {
337                command: GraphSubcommand::Validate,
338            })) => {}
339            _ => panic!("Expected Graph Validate command"),
340        }
341    }
342
343    #[test]
344    fn test_graph_stats_command() {
345        let args = CliArgs::parse_from(["test", "graph", "stats"]);
346        match args.command {
347            Some(BaseCommand::Graph(GraphCommand {
348                command: GraphSubcommand::Stats,
349            })) => {}
350            _ => panic!("Expected Graph Stats command"),
351        }
352    }
353
354    #[test]
355    fn test_graph_query_command() {
356        let args = CliArgs::parse_from(["test", "graph", "query", "--id", "node-1"]);
357        match args.command {
358            Some(BaseCommand::Graph(GraphCommand {
359                command: GraphSubcommand::Query { id, query_type, to },
360            })) => {
361                assert_eq!(id, "node-1");
362                assert_eq!(query_type, "related");
363                assert!(to.is_none());
364            }
365            _ => panic!("Expected Graph Query command"),
366        }
367    }
368
369    #[test]
370    fn test_graph_query_path() {
371        let args = CliArgs::parse_from([
372            "test",
373            "graph",
374            "query",
375            "--id",
376            "a",
377            "--query-type",
378            "path",
379            "--to",
380            "b",
381        ]);
382        match args.command {
383            Some(BaseCommand::Graph(GraphCommand {
384                command: GraphSubcommand::Query { id, query_type, to },
385            })) => {
386                assert_eq!(id, "a");
387                assert_eq!(query_type, "path");
388                assert_eq!(to, Some("b".to_string()));
389            }
390            _ => panic!("Expected Graph Query path command"),
391        }
392    }
393
394    // ------------------------------------------------------------------------
395    // Config command tests
396    // ------------------------------------------------------------------------
397
398    #[test]
399    fn test_config_path_command() {
400        let args = CliArgs::parse_from(["test", "config", "path"]);
401        match args.command {
402            Some(BaseCommand::Config(ConfigCommand {
403                command: ConfigAction::Path,
404            })) => {}
405            _ => panic!("Expected Config Path command"),
406        }
407    }
408
409    #[test]
410    fn test_config_get_command() {
411        let args = CliArgs::parse_from(["test", "config", "get", "server.port"]);
412        match args.command {
413            Some(BaseCommand::Config(ConfigCommand {
414                command: ConfigAction::Get { key },
415            })) => {
416                assert_eq!(key, "server.port");
417            }
418            _ => panic!("Expected Config Get command"),
419        }
420    }
421
422    #[test]
423    fn test_config_set_command() {
424        let args = CliArgs::parse_from(["test", "config", "set", "server.port", "8080"]);
425        match args.command {
426            Some(BaseCommand::Config(ConfigCommand {
427                command: ConfigAction::Set { key, value },
428            })) => {
429                assert_eq!(key, "server.port");
430                assert_eq!(value, "8080");
431            }
432            _ => panic!("Expected Config Set command"),
433        }
434    }
435
436    #[test]
437    fn test_config_init_command() {
438        let args = CliArgs::parse_from(["test", "config", "init"]);
439        match args.command {
440            Some(BaseCommand::Config(ConfigCommand {
441                command: ConfigAction::Init { file, force },
442            })) => {
443                assert!(file.is_none());
444                assert!(!force);
445            }
446            _ => panic!("Expected Config Init command"),
447        }
448    }
449
450    #[test]
451    fn test_config_init_force() {
452        let args = CliArgs::parse_from(["test", "config", "init", "--force"]);
453        match args.command {
454            Some(BaseCommand::Config(ConfigCommand {
455                command: ConfigAction::Init { force, .. },
456            })) => {
457                assert!(force);
458            }
459            _ => panic!("Expected Config Init command with force"),
460        }
461    }
462
463    #[test]
464    fn test_config_export_command() {
465        let args = CliArgs::parse_from(["test", "config", "export"]);
466        match args.command {
467            Some(BaseCommand::Config(ConfigCommand {
468                command: ConfigAction::Export { docker_env },
469            })) => {
470                assert!(!docker_env);
471            }
472            _ => panic!("Expected Config Export command"),
473        }
474    }
475
476    #[test]
477    fn test_config_export_docker_env() {
478        let args = CliArgs::parse_from(["test", "config", "export", "--docker-env"]);
479        match args.command {
480            Some(BaseCommand::Config(ConfigCommand {
481                command: ConfigAction::Export { docker_env },
482            })) => {
483                assert!(docker_env);
484            }
485            _ => panic!("Expected Config Export command with docker_env"),
486        }
487    }
488
489    // ------------------------------------------------------------------------
490    // Vectordb command tests (feature-gated)
491    // ------------------------------------------------------------------------
492
493    #[cfg(feature = "vector-fastembed")]
494    #[test]
495    fn test_vectordb_get_model_command() {
496        use crate::cli::{VectordbAction, VectordbCommand};
497
498        let args = CliArgs::parse_from(["test", "vectordb", "get-model"]);
499        match args.command {
500            Some(BaseCommand::Vectordb(VectordbCommand {
501                command: VectordbAction::GetModel { model, cache_dir },
502            })) => {
503                assert!(model.is_none());
504                assert!(cache_dir.is_none());
505            }
506            _ => panic!("Expected Vectordb GetModel command"),
507        }
508    }
509
510    #[cfg(feature = "vector-fastembed")]
511    #[test]
512    fn test_vectordb_get_model_with_overrides() {
513        use crate::cli::{VectordbAction, VectordbCommand};
514
515        let args = CliArgs::parse_from([
516            "test",
517            "vectordb",
518            "get-model",
519            "--model",
520            "bge-large-en-v1.5",
521            "--cache-dir",
522            "/tmp/models",
523        ]);
524        match args.command {
525            Some(BaseCommand::Vectordb(VectordbCommand {
526                command: VectordbAction::GetModel { model, cache_dir },
527            })) => {
528                assert_eq!(model.as_deref(), Some("bge-large-en-v1.5"));
529                assert_eq!(cache_dir.as_deref(), Some("/tmp/models"));
530            }
531            _ => panic!("Expected Vectordb GetModel command with overrides"),
532        }
533    }
534}