Skip to main content

fabryk_cli/
app.rs

1//! FabrykCli application framework.
2//!
3//! Provides the generic CLI application that domain crates instantiate
4//! with their own [`ConfigProvider`] implementation.
5
6use crate::cli::{BaseCommand, CliArgs, GraphSubcommand};
7use crate::config::FabrykConfig;
8use crate::{config_handlers, graph_handlers};
9use fabryk_core::Result;
10use fabryk_core::traits::ConfigProvider;
11use std::sync::Arc;
12use tracing_subscriber::EnvFilter;
13
14// ============================================================================
15// FabrykCli
16// ============================================================================
17
18/// Generic CLI application parameterized over a config provider.
19///
20/// Domain applications create a `FabrykCli<MyConfig>` and call `run()`.
21pub struct FabrykCli<C: ConfigProvider> {
22    name: String,
23    config: Arc<C>,
24    version: String,
25}
26
27impl FabrykCli<FabrykConfig> {
28    /// Create from CLI args, loading config from file/env.
29    pub fn from_args(name: impl Into<String>, args: &CliArgs) -> Result<Self> {
30        let config = FabrykConfig::load(args.config.as_deref())?;
31        Ok(Self::new(name, config))
32    }
33}
34
35impl<C: ConfigProvider> FabrykCli<C> {
36    /// Create a new CLI application.
37    pub fn new(name: impl Into<String>, config: C) -> Self {
38        Self {
39            name: name.into(),
40            config: Arc::new(config),
41            version: env!("CARGO_PKG_VERSION").to_string(),
42        }
43    }
44
45    /// Override the version string.
46    pub fn with_version(mut self, version: impl Into<String>) -> Self {
47        self.version = version.into();
48        self
49    }
50
51    /// Get a reference to the config provider.
52    pub fn config(&self) -> &C {
53        &self.config
54    }
55
56    /// Initialise tracing-based logging.
57    ///
58    /// Uses `RUST_LOG` env var if set, otherwise defaults based on verbosity flags.
59    pub fn init_logging(&self, verbose: bool, quiet: bool) {
60        let filter = if std::env::var("RUST_LOG").is_ok() {
61            EnvFilter::from_default_env()
62        } else if quiet {
63            EnvFilter::new("warn")
64        } else if verbose {
65            EnvFilter::new("debug")
66        } else {
67            EnvFilter::new("info")
68        };
69
70        // Ignore error if a subscriber is already set (e.g. in tests).
71        let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
72    }
73
74    /// Run the CLI with the given arguments.
75    pub async fn run(&self, args: CliArgs) -> Result<()> {
76        self.init_logging(args.verbose, args.quiet);
77
78        match args.command {
79            Some(BaseCommand::Version) => {
80                println!("{} {}", self.name, self.version);
81                Ok(())
82            }
83            Some(BaseCommand::Health) => {
84                println!("{}: healthy", self.name);
85                Ok(())
86            }
87            Some(BaseCommand::Serve { port }) => {
88                println!("Starting {} server on port {}...", self.name, port);
89                // Placeholder — domain applications override serve behaviour.
90                Ok(())
91            }
92            Some(BaseCommand::Index { force, check }) => {
93                if check {
94                    println!("Checking index freshness...");
95                } else {
96                    println!("Building index{}...", if force { " (forced)" } else { "" });
97                }
98                // Placeholder — domain applications override index behaviour.
99                Ok(())
100            }
101            Some(BaseCommand::Graph(graph_cmd)) => self.handle_graph(graph_cmd.command).await,
102            Some(BaseCommand::Config(config_cmd)) => {
103                config_handlers::handle_config_command(args.config.as_deref(), config_cmd.command)
104            }
105            None => {
106                println!("{} {} — use --help for usage", self.name, self.version);
107                Ok(())
108            }
109        }
110    }
111
112    /// Dispatch graph subcommands to handlers.
113    async fn handle_graph(&self, command: GraphSubcommand) -> Result<()> {
114        match command {
115            GraphSubcommand::Build {
116                output: _,
117                dry_run: _,
118            } => {
119                // Build requires a domain-specific GraphExtractor, so we print
120                // a message indicating that the domain application should override.
121                println!(
122                    "Graph build requires a domain-specific extractor. \
123                     Override handle_graph() in your domain CLI."
124                );
125                Ok(())
126            }
127            GraphSubcommand::Validate => graph_handlers::handle_validate(&*self.config).await,
128            GraphSubcommand::Stats => graph_handlers::handle_stats(&*self.config).await,
129            GraphSubcommand::Query { id, query_type, to } => {
130                let options = graph_handlers::QueryOptions { id, query_type, to };
131                graph_handlers::handle_query(&*self.config, options).await
132            }
133        }
134    }
135}
136
137// ============================================================================
138// Tests
139// ============================================================================
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::cli::CliArgs;
145    use clap::Parser;
146    use std::path::PathBuf;
147
148    #[derive(Clone)]
149    struct TestConfig {
150        base: PathBuf,
151    }
152
153    impl ConfigProvider for TestConfig {
154        fn project_name(&self) -> &str {
155            "test-app"
156        }
157
158        fn base_path(&self) -> Result<PathBuf> {
159            Ok(self.base.clone())
160        }
161
162        fn content_path(&self, content_type: &str) -> Result<PathBuf> {
163            Ok(self.base.join(content_type))
164        }
165    }
166
167    fn test_config() -> TestConfig {
168        TestConfig {
169            base: PathBuf::from("/tmp/test"),
170        }
171    }
172
173    #[test]
174    fn test_fabryk_cli_new() {
175        let cli = FabrykCli::new("my-app", test_config());
176        assert_eq!(cli.name, "my-app");
177        assert_eq!(cli.config().project_name(), "test-app");
178    }
179
180    #[test]
181    fn test_fabryk_cli_with_version() {
182        let cli = FabrykCli::new("my-app", test_config()).with_version("1.2.3");
183        assert_eq!(cli.version, "1.2.3");
184    }
185
186    #[test]
187    fn test_fabryk_cli_config_access() {
188        let cli = FabrykCli::new("app", test_config());
189        assert_eq!(
190            cli.config().base_path().unwrap(),
191            PathBuf::from("/tmp/test")
192        );
193    }
194
195    #[tokio::test]
196    async fn test_run_version_command() {
197        let cli = FabrykCli::new("test-app", test_config()).with_version("0.1.0");
198        let args = CliArgs::parse_from(["test", "version"]);
199        let result = cli.run(args).await;
200        assert!(result.is_ok());
201    }
202
203    #[tokio::test]
204    async fn test_run_health_command() {
205        let cli = FabrykCli::new("test-app", test_config());
206        let args = CliArgs::parse_from(["test", "health"]);
207        let result = cli.run(args).await;
208        assert!(result.is_ok());
209    }
210
211    #[tokio::test]
212    async fn test_run_no_command() {
213        let cli = FabrykCli::new("test-app", test_config()).with_version("0.1.0");
214        let args = CliArgs::parse_from(["test"]);
215        let result = cli.run(args).await;
216        assert!(result.is_ok());
217    }
218
219    #[tokio::test]
220    async fn test_run_serve_command() {
221        let cli = FabrykCli::new("test-app", test_config());
222        let args = CliArgs::parse_from(["test", "serve", "--port", "9090"]);
223        let result = cli.run(args).await;
224        assert!(result.is_ok());
225    }
226
227    #[tokio::test]
228    async fn test_run_index_command() {
229        let cli = FabrykCli::new("test-app", test_config());
230        let args = CliArgs::parse_from(["test", "index"]);
231        let result = cli.run(args).await;
232        assert!(result.is_ok());
233    }
234
235    #[tokio::test]
236    async fn test_run_index_check() {
237        let cli = FabrykCli::new("test-app", test_config());
238        let args = CliArgs::parse_from(["test", "index", "--check"]);
239        let result = cli.run(args).await;
240        assert!(result.is_ok());
241    }
242
243    #[test]
244    fn test_init_logging_default() {
245        let cli = FabrykCli::new("test", test_config());
246        // Should not panic
247        cli.init_logging(false, false);
248    }
249
250    #[test]
251    fn test_init_logging_verbose() {
252        let cli = FabrykCli::new("test", test_config());
253        cli.init_logging(true, false);
254    }
255
256    #[test]
257    fn test_init_logging_quiet() {
258        let cli = FabrykCli::new("test", test_config());
259        cli.init_logging(false, true);
260    }
261
262    // ------------------------------------------------------------------------
263    // FabrykConfig integration tests
264    // ------------------------------------------------------------------------
265
266    #[test]
267    fn test_fabryk_cli_from_args_default() {
268        let args = CliArgs::parse_from(["test"]);
269        let cli = FabrykCli::from_args("test-app", &args).unwrap();
270        assert_eq!(cli.config().project_name(), "fabryk");
271    }
272
273    #[test]
274    fn test_fabryk_cli_from_args_with_file() {
275        let dir = tempfile::TempDir::new().unwrap();
276        let path = dir.path().join("config.toml");
277        std::fs::write(
278            &path,
279            r#"
280                project_name = "from-file"
281                [server]
282                port = 9090
283            "#,
284        )
285        .unwrap();
286
287        let args = CliArgs::parse_from(["test", "--config", path.to_str().unwrap()]);
288        let cli = FabrykCli::from_args("test-app", &args).unwrap();
289        assert_eq!(cli.config().project_name(), "from-file");
290    }
291
292    #[tokio::test]
293    async fn test_fabryk_cli_config_command_dispatch() {
294        let cli = FabrykCli::new("test-app", test_config());
295        let args = CliArgs::parse_from(["test", "config", "path"]);
296        let result = cli.run(args).await;
297        assert!(result.is_ok());
298    }
299}