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
69/// Config-specific subcommands.
70#[derive(Parser, Debug)]
71pub struct ConfigCommand {
72    /// Config subcommand to execute.
73    #[command(subcommand)]
74    pub command: ConfigAction,
75}
76
77/// Available config subcommands.
78#[derive(Subcommand, Debug)]
79pub enum ConfigAction {
80    /// Show the resolved config file path.
81    Path,
82
83    /// Get a configuration value by dotted key.
84    Get {
85        /// Dotted key (e.g., "server.port").
86        key: String,
87    },
88
89    /// Set a configuration value by dotted key.
90    Set {
91        /// Dotted key (e.g., "server.port").
92        key: String,
93
94        /// Value to set.
95        value: String,
96    },
97
98    /// Create a default configuration file.
99    Init {
100        /// Output file path (defaults to XDG config path).
101        #[arg(short, long)]
102        file: Option<String>,
103
104        /// Overwrite existing file.
105        #[arg(long)]
106        force: bool,
107    },
108
109    /// Export configuration as environment variables.
110    Export {
111        /// Format as Docker --env flags.
112        #[arg(long)]
113        docker_env: bool,
114    },
115}
116
117/// Graph-specific subcommands.
118#[derive(Parser, Debug)]
119pub struct GraphCommand {
120    /// Graph subcommand to execute.
121    #[command(subcommand)]
122    pub command: GraphSubcommand,
123}
124
125/// Available graph subcommands.
126#[derive(Subcommand, Debug)]
127pub enum GraphSubcommand {
128    /// Build the knowledge graph from content.
129    Build {
130        /// Output file path for the graph.
131        #[arg(short, long)]
132        output: Option<String>,
133
134        /// Show what would be built without writing.
135        #[arg(long)]
136        dry_run: bool,
137    },
138
139    /// Validate graph integrity.
140    Validate,
141
142    /// Show graph statistics.
143    Stats,
144
145    /// Query the graph.
146    Query {
147        /// Node ID to query.
148        #[arg(short, long)]
149        id: String,
150
151        /// Type of query: related, prerequisites, path.
152        #[arg(short = 't', long, default_value = "related")]
153        query_type: String,
154
155        /// Target node ID (for path queries).
156        #[arg(long)]
157        to: Option<String>,
158    },
159}
160
161// ============================================================================
162// CliExtension trait
163// ============================================================================
164
165/// Extension point for domain-specific CLI commands.
166///
167/// Domain applications implement this trait to add custom subcommands
168/// beyond the built-in base commands.
169pub trait CliExtension: Send + Sync {
170    /// The domain-specific command type.
171    type Command: Send + Sync;
172
173    /// Handle a domain-specific command.
174    fn handle_command(
175        &self,
176        command: Self::Command,
177    ) -> impl std::future::Future<Output = fabryk_core::Result<()>> + Send;
178}
179
180// ============================================================================
181// Tests
182// ============================================================================
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use clap::Parser;
188
189    #[test]
190    fn test_cli_args_default() {
191        let args = CliArgs::parse_from(["test"]);
192        assert!(args.config.is_none());
193        assert!(!args.verbose);
194        assert!(!args.quiet);
195        assert!(args.command.is_none());
196    }
197
198    #[test]
199    fn test_cli_args_verbose() {
200        let args = CliArgs::parse_from(["test", "--verbose"]);
201        assert!(args.verbose);
202        assert!(!args.quiet);
203    }
204
205    #[test]
206    fn test_cli_args_quiet() {
207        let args = CliArgs::parse_from(["test", "--quiet"]);
208        assert!(!args.verbose);
209        assert!(args.quiet);
210    }
211
212    #[test]
213    fn test_cli_args_config() {
214        let args = CliArgs::parse_from(["test", "--config", "/path/to/config.toml"]);
215        assert_eq!(args.config, Some("/path/to/config.toml".to_string()));
216    }
217
218    #[test]
219    fn test_serve_command() {
220        let args = CliArgs::parse_from(["test", "serve"]);
221        match args.command {
222            Some(BaseCommand::Serve { port }) => assert_eq!(port, 3000),
223            _ => panic!("Expected Serve command"),
224        }
225    }
226
227    #[test]
228    fn test_serve_command_custom_port() {
229        let args = CliArgs::parse_from(["test", "serve", "--port", "8080"]);
230        match args.command {
231            Some(BaseCommand::Serve { port }) => assert_eq!(port, 8080),
232            _ => panic!("Expected Serve command"),
233        }
234    }
235
236    #[test]
237    fn test_index_command() {
238        let args = CliArgs::parse_from(["test", "index"]);
239        match args.command {
240            Some(BaseCommand::Index { force, check }) => {
241                assert!(!force);
242                assert!(!check);
243            }
244            _ => panic!("Expected Index command"),
245        }
246    }
247
248    #[test]
249    fn test_index_command_force() {
250        let args = CliArgs::parse_from(["test", "index", "--force"]);
251        match args.command {
252            Some(BaseCommand::Index { force, check }) => {
253                assert!(force);
254                assert!(!check);
255            }
256            _ => panic!("Expected Index command with force"),
257        }
258    }
259
260    #[test]
261    fn test_version_command() {
262        let args = CliArgs::parse_from(["test", "version"]);
263        assert!(matches!(args.command, Some(BaseCommand::Version)));
264    }
265
266    #[test]
267    fn test_health_command() {
268        let args = CliArgs::parse_from(["test", "health"]);
269        assert!(matches!(args.command, Some(BaseCommand::Health)));
270    }
271
272    #[test]
273    fn test_graph_build_command() {
274        let args = CliArgs::parse_from(["test", "graph", "build"]);
275        match args.command {
276            Some(BaseCommand::Graph(GraphCommand {
277                command: GraphSubcommand::Build { output, dry_run },
278            })) => {
279                assert!(output.is_none());
280                assert!(!dry_run);
281            }
282            _ => panic!("Expected Graph Build command"),
283        }
284    }
285
286    #[test]
287    fn test_graph_build_dry_run() {
288        let args = CliArgs::parse_from(["test", "graph", "build", "--dry-run"]);
289        match args.command {
290            Some(BaseCommand::Graph(GraphCommand {
291                command: GraphSubcommand::Build { dry_run, .. },
292            })) => {
293                assert!(dry_run);
294            }
295            _ => panic!("Expected Graph Build command with dry_run"),
296        }
297    }
298
299    #[test]
300    fn test_graph_validate_command() {
301        let args = CliArgs::parse_from(["test", "graph", "validate"]);
302        match args.command {
303            Some(BaseCommand::Graph(GraphCommand {
304                command: GraphSubcommand::Validate,
305            })) => {}
306            _ => panic!("Expected Graph Validate command"),
307        }
308    }
309
310    #[test]
311    fn test_graph_stats_command() {
312        let args = CliArgs::parse_from(["test", "graph", "stats"]);
313        match args.command {
314            Some(BaseCommand::Graph(GraphCommand {
315                command: GraphSubcommand::Stats,
316            })) => {}
317            _ => panic!("Expected Graph Stats command"),
318        }
319    }
320
321    #[test]
322    fn test_graph_query_command() {
323        let args = CliArgs::parse_from(["test", "graph", "query", "--id", "node-1"]);
324        match args.command {
325            Some(BaseCommand::Graph(GraphCommand {
326                command: GraphSubcommand::Query { id, query_type, to },
327            })) => {
328                assert_eq!(id, "node-1");
329                assert_eq!(query_type, "related");
330                assert!(to.is_none());
331            }
332            _ => panic!("Expected Graph Query command"),
333        }
334    }
335
336    #[test]
337    fn test_graph_query_path() {
338        let args = CliArgs::parse_from([
339            "test",
340            "graph",
341            "query",
342            "--id",
343            "a",
344            "--query-type",
345            "path",
346            "--to",
347            "b",
348        ]);
349        match args.command {
350            Some(BaseCommand::Graph(GraphCommand {
351                command: GraphSubcommand::Query { id, query_type, to },
352            })) => {
353                assert_eq!(id, "a");
354                assert_eq!(query_type, "path");
355                assert_eq!(to, Some("b".to_string()));
356            }
357            _ => panic!("Expected Graph Query path command"),
358        }
359    }
360
361    // ------------------------------------------------------------------------
362    // Config command tests
363    // ------------------------------------------------------------------------
364
365    #[test]
366    fn test_config_path_command() {
367        let args = CliArgs::parse_from(["test", "config", "path"]);
368        match args.command {
369            Some(BaseCommand::Config(ConfigCommand {
370                command: ConfigAction::Path,
371            })) => {}
372            _ => panic!("Expected Config Path command"),
373        }
374    }
375
376    #[test]
377    fn test_config_get_command() {
378        let args = CliArgs::parse_from(["test", "config", "get", "server.port"]);
379        match args.command {
380            Some(BaseCommand::Config(ConfigCommand {
381                command: ConfigAction::Get { key },
382            })) => {
383                assert_eq!(key, "server.port");
384            }
385            _ => panic!("Expected Config Get command"),
386        }
387    }
388
389    #[test]
390    fn test_config_set_command() {
391        let args = CliArgs::parse_from(["test", "config", "set", "server.port", "8080"]);
392        match args.command {
393            Some(BaseCommand::Config(ConfigCommand {
394                command: ConfigAction::Set { key, value },
395            })) => {
396                assert_eq!(key, "server.port");
397                assert_eq!(value, "8080");
398            }
399            _ => panic!("Expected Config Set command"),
400        }
401    }
402
403    #[test]
404    fn test_config_init_command() {
405        let args = CliArgs::parse_from(["test", "config", "init"]);
406        match args.command {
407            Some(BaseCommand::Config(ConfigCommand {
408                command: ConfigAction::Init { file, force },
409            })) => {
410                assert!(file.is_none());
411                assert!(!force);
412            }
413            _ => panic!("Expected Config Init command"),
414        }
415    }
416
417    #[test]
418    fn test_config_init_force() {
419        let args = CliArgs::parse_from(["test", "config", "init", "--force"]);
420        match args.command {
421            Some(BaseCommand::Config(ConfigCommand {
422                command: ConfigAction::Init { force, .. },
423            })) => {
424                assert!(force);
425            }
426            _ => panic!("Expected Config Init command with force"),
427        }
428    }
429
430    #[test]
431    fn test_config_export_command() {
432        let args = CliArgs::parse_from(["test", "config", "export"]);
433        match args.command {
434            Some(BaseCommand::Config(ConfigCommand {
435                command: ConfigAction::Export { docker_env },
436            })) => {
437                assert!(!docker_env);
438            }
439            _ => panic!("Expected Config Export command"),
440        }
441    }
442
443    #[test]
444    fn test_config_export_docker_env() {
445        let args = CliArgs::parse_from(["test", "config", "export", "--docker-env"]);
446        match args.command {
447            Some(BaseCommand::Config(ConfigCommand {
448                command: ConfigAction::Export { docker_env },
449            })) => {
450                assert!(docker_env);
451            }
452            _ => panic!("Expected Config Export command with docker_env"),
453        }
454    }
455}