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    /// Show the full configuration, or a specific value by dotted key.
88    Get {
89        /// Dotted key (e.g., "server.port"). Omit to show full config as TOML.
90        key: Option<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        /// Write output to a file (e.g., ".env") instead of stdout.
120        #[arg(long)]
121        file: Option<String>,
122    },
123}
124
125/// Graph-specific subcommands.
126#[derive(Parser, Debug)]
127pub struct GraphCommand {
128    /// Graph subcommand to execute.
129    #[command(subcommand)]
130    pub command: GraphSubcommand,
131}
132
133/// Available graph subcommands.
134#[derive(Subcommand, Debug)]
135pub enum GraphSubcommand {
136    /// Build the knowledge graph from content.
137    Build {
138        /// Output file path for the graph.
139        #[arg(short, long)]
140        output: Option<String>,
141
142        /// Show what would be built without writing.
143        #[arg(long)]
144        dry_run: bool,
145    },
146
147    /// Validate graph integrity.
148    Validate,
149
150    /// Show graph statistics.
151    Stats,
152
153    /// Query the graph.
154    Query {
155        /// Node ID to query.
156        #[arg(short, long)]
157        id: String,
158
159        /// Type of query: related, prerequisites, path.
160        #[arg(short = 't', long, default_value = "related")]
161        query_type: String,
162
163        /// Target node ID (for path queries).
164        #[arg(long)]
165        to: Option<String>,
166    },
167}
168
169// ============================================================================
170// Vectordb commands (feature-gated)
171// ============================================================================
172
173/// Vectordb-specific subcommands.
174#[cfg(feature = "vector-fastembed")]
175#[derive(Parser, Debug)]
176pub struct VectordbCommand {
177    /// Vectordb subcommand to execute.
178    #[command(subcommand)]
179    pub command: VectordbAction,
180}
181
182/// Available vectordb subcommands.
183#[cfg(feature = "vector-fastembed")]
184#[derive(Subcommand, Debug)]
185pub enum VectordbAction {
186    /// Download and cache the embedding model.
187    GetModel {
188        /// Embedding model name (e.g., "bge-small-en-v1.5").
189        #[arg(long)]
190        model: Option<String>,
191
192        /// Directory to cache the downloaded model.
193        #[arg(long)]
194        cache_dir: Option<String>,
195    },
196}
197
198// ============================================================================
199// CliExtension trait
200// ============================================================================
201
202/// Extension point for domain-specific CLI commands.
203///
204/// Domain applications implement this trait to add custom subcommands
205/// beyond the built-in base commands.
206pub trait CliExtension: Send + Sync {
207    /// The domain-specific command type.
208    type Command: Send + Sync;
209
210    /// Handle a domain-specific command.
211    fn handle_command(
212        &self,
213        command: Self::Command,
214    ) -> impl std::future::Future<Output = fabryk_core::Result<()>> + Send;
215}
216
217// ============================================================================
218// Tests
219// ============================================================================
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use clap::Parser;
225
226    #[test]
227    fn test_cli_args_default() {
228        let args = CliArgs::parse_from(["test"]);
229        assert!(args.config.is_none());
230        assert!(!args.verbose);
231        assert!(!args.quiet);
232        assert!(args.command.is_none());
233    }
234
235    #[test]
236    fn test_cli_args_verbose() {
237        let args = CliArgs::parse_from(["test", "--verbose"]);
238        assert!(args.verbose);
239        assert!(!args.quiet);
240    }
241
242    #[test]
243    fn test_cli_args_quiet() {
244        let args = CliArgs::parse_from(["test", "--quiet"]);
245        assert!(!args.verbose);
246        assert!(args.quiet);
247    }
248
249    #[test]
250    fn test_cli_args_config() {
251        let args = CliArgs::parse_from(["test", "--config", "/path/to/config.toml"]);
252        assert_eq!(args.config, Some("/path/to/config.toml".to_string()));
253    }
254
255    #[test]
256    fn test_serve_command() {
257        let args = CliArgs::parse_from(["test", "serve"]);
258        match args.command {
259            Some(BaseCommand::Serve { port }) => assert_eq!(port, 3000),
260            _ => panic!("Expected Serve command"),
261        }
262    }
263
264    #[test]
265    fn test_serve_command_custom_port() {
266        let args = CliArgs::parse_from(["test", "serve", "--port", "8080"]);
267        match args.command {
268            Some(BaseCommand::Serve { port }) => assert_eq!(port, 8080),
269            _ => panic!("Expected Serve command"),
270        }
271    }
272
273    #[test]
274    fn test_index_command() {
275        let args = CliArgs::parse_from(["test", "index"]);
276        match args.command {
277            Some(BaseCommand::Index { force, check }) => {
278                assert!(!force);
279                assert!(!check);
280            }
281            _ => panic!("Expected Index command"),
282        }
283    }
284
285    #[test]
286    fn test_index_command_force() {
287        let args = CliArgs::parse_from(["test", "index", "--force"]);
288        match args.command {
289            Some(BaseCommand::Index { force, check }) => {
290                assert!(force);
291                assert!(!check);
292            }
293            _ => panic!("Expected Index command with force"),
294        }
295    }
296
297    #[test]
298    fn test_version_command() {
299        let args = CliArgs::parse_from(["test", "version"]);
300        assert!(matches!(args.command, Some(BaseCommand::Version)));
301    }
302
303    #[test]
304    fn test_health_command() {
305        let args = CliArgs::parse_from(["test", "health"]);
306        assert!(matches!(args.command, Some(BaseCommand::Health)));
307    }
308
309    #[test]
310    fn test_graph_build_command() {
311        let args = CliArgs::parse_from(["test", "graph", "build"]);
312        match args.command {
313            Some(BaseCommand::Graph(GraphCommand {
314                command: GraphSubcommand::Build { output, dry_run },
315            })) => {
316                assert!(output.is_none());
317                assert!(!dry_run);
318            }
319            _ => panic!("Expected Graph Build command"),
320        }
321    }
322
323    #[test]
324    fn test_graph_build_dry_run() {
325        let args = CliArgs::parse_from(["test", "graph", "build", "--dry-run"]);
326        match args.command {
327            Some(BaseCommand::Graph(GraphCommand {
328                command: GraphSubcommand::Build { dry_run, .. },
329            })) => {
330                assert!(dry_run);
331            }
332            _ => panic!("Expected Graph Build command with dry_run"),
333        }
334    }
335
336    #[test]
337    fn test_graph_validate_command() {
338        let args = CliArgs::parse_from(["test", "graph", "validate"]);
339        match args.command {
340            Some(BaseCommand::Graph(GraphCommand {
341                command: GraphSubcommand::Validate,
342            })) => {}
343            _ => panic!("Expected Graph Validate command"),
344        }
345    }
346
347    #[test]
348    fn test_graph_stats_command() {
349        let args = CliArgs::parse_from(["test", "graph", "stats"]);
350        match args.command {
351            Some(BaseCommand::Graph(GraphCommand {
352                command: GraphSubcommand::Stats,
353            })) => {}
354            _ => panic!("Expected Graph Stats command"),
355        }
356    }
357
358    #[test]
359    fn test_graph_query_command() {
360        let args = CliArgs::parse_from(["test", "graph", "query", "--id", "node-1"]);
361        match args.command {
362            Some(BaseCommand::Graph(GraphCommand {
363                command: GraphSubcommand::Query { id, query_type, to },
364            })) => {
365                assert_eq!(id, "node-1");
366                assert_eq!(query_type, "related");
367                assert!(to.is_none());
368            }
369            _ => panic!("Expected Graph Query command"),
370        }
371    }
372
373    #[test]
374    fn test_graph_query_path() {
375        let args = CliArgs::parse_from([
376            "test",
377            "graph",
378            "query",
379            "--id",
380            "a",
381            "--query-type",
382            "path",
383            "--to",
384            "b",
385        ]);
386        match args.command {
387            Some(BaseCommand::Graph(GraphCommand {
388                command: GraphSubcommand::Query { id, query_type, to },
389            })) => {
390                assert_eq!(id, "a");
391                assert_eq!(query_type, "path");
392                assert_eq!(to, Some("b".to_string()));
393            }
394            _ => panic!("Expected Graph Query path command"),
395        }
396    }
397
398    // ------------------------------------------------------------------------
399    // Config command tests
400    // ------------------------------------------------------------------------
401
402    #[test]
403    fn test_config_path_command() {
404        let args = CliArgs::parse_from(["test", "config", "path"]);
405        match args.command {
406            Some(BaseCommand::Config(ConfigCommand {
407                command: ConfigAction::Path,
408            })) => {}
409            _ => panic!("Expected Config Path command"),
410        }
411    }
412
413    #[test]
414    fn test_config_get_command() {
415        let args = CliArgs::parse_from(["test", "config", "get", "server.port"]);
416        match args.command {
417            Some(BaseCommand::Config(ConfigCommand {
418                command: ConfigAction::Get { key },
419            })) => {
420                assert_eq!(key, Some("server.port".to_string()));
421            }
422            _ => panic!("Expected Config Get command"),
423        }
424    }
425
426    #[test]
427    fn test_config_get_no_key_dumps_all() {
428        let args = CliArgs::parse_from(["test", "config", "get"]);
429        match args.command {
430            Some(BaseCommand::Config(ConfigCommand {
431                command: ConfigAction::Get { key },
432            })) => {
433                assert!(key.is_none());
434            }
435            _ => panic!("Expected Config Get command"),
436        }
437    }
438
439    #[test]
440    fn test_config_set_command() {
441        let args = CliArgs::parse_from(["test", "config", "set", "server.port", "8080"]);
442        match args.command {
443            Some(BaseCommand::Config(ConfigCommand {
444                command: ConfigAction::Set { key, value },
445            })) => {
446                assert_eq!(key, "server.port");
447                assert_eq!(value, "8080");
448            }
449            _ => panic!("Expected Config Set command"),
450        }
451    }
452
453    #[test]
454    fn test_config_init_command() {
455        let args = CliArgs::parse_from(["test", "config", "init"]);
456        match args.command {
457            Some(BaseCommand::Config(ConfigCommand {
458                command: ConfigAction::Init { file, force },
459            })) => {
460                assert!(file.is_none());
461                assert!(!force);
462            }
463            _ => panic!("Expected Config Init command"),
464        }
465    }
466
467    #[test]
468    fn test_config_init_force() {
469        let args = CliArgs::parse_from(["test", "config", "init", "--force"]);
470        match args.command {
471            Some(BaseCommand::Config(ConfigCommand {
472                command: ConfigAction::Init { force, .. },
473            })) => {
474                assert!(force);
475            }
476            _ => panic!("Expected Config Init command with force"),
477        }
478    }
479
480    #[test]
481    fn test_config_export_command() {
482        let args = CliArgs::parse_from(["test", "config", "export"]);
483        match args.command {
484            Some(BaseCommand::Config(ConfigCommand {
485                command: ConfigAction::Export { docker_env, file },
486            })) => {
487                assert!(!docker_env);
488                assert!(file.is_none());
489            }
490            _ => panic!("Expected Config Export command"),
491        }
492    }
493
494    #[test]
495    fn test_config_export_docker_env() {
496        let args = CliArgs::parse_from(["test", "config", "export", "--docker-env"]);
497        match args.command {
498            Some(BaseCommand::Config(ConfigCommand {
499                command: ConfigAction::Export { docker_env, file },
500            })) => {
501                assert!(docker_env);
502                assert!(file.is_none());
503            }
504            _ => panic!("Expected Config Export command with docker_env"),
505        }
506    }
507
508    // ------------------------------------------------------------------------
509    // Vectordb command tests (feature-gated)
510    // ------------------------------------------------------------------------
511
512    #[cfg(feature = "vector-fastembed")]
513    #[test]
514    fn test_vectordb_get_model_command() {
515        use crate::cli::{VectordbAction, VectordbCommand};
516
517        let args = CliArgs::parse_from(["test", "vectordb", "get-model"]);
518        match args.command {
519            Some(BaseCommand::Vectordb(VectordbCommand {
520                command: VectordbAction::GetModel { model, cache_dir },
521            })) => {
522                assert!(model.is_none());
523                assert!(cache_dir.is_none());
524            }
525            _ => panic!("Expected Vectordb GetModel command"),
526        }
527    }
528
529    #[cfg(feature = "vector-fastembed")]
530    #[test]
531    fn test_vectordb_get_model_with_overrides() {
532        use crate::cli::{VectordbAction, VectordbCommand};
533
534        let args = CliArgs::parse_from([
535            "test",
536            "vectordb",
537            "get-model",
538            "--model",
539            "bge-large-en-v1.5",
540            "--cache-dir",
541            "/tmp/models",
542        ]);
543        match args.command {
544            Some(BaseCommand::Vectordb(VectordbCommand {
545                command: VectordbAction::GetModel { model, cache_dir },
546            })) => {
547                assert_eq!(model.as_deref(), Some("bge-large-en-v1.5"));
548                assert_eq!(cache_dir.as_deref(), Some("/tmp/models"));
549            }
550            _ => panic!("Expected Vectordb GetModel command with overrides"),
551        }
552    }
553}