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            #[cfg(feature = "vector-fastembed")]
106            Some(BaseCommand::Vectordb(cmd)) => {
107                crate::vectordb_handlers::handle_vectordb_command(cmd.command)
108            }
109            None => {
110                println!("{} {} — use --help for usage", self.name, self.version);
111                Ok(())
112            }
113        }
114    }
115
116    /// Dispatch graph subcommands to handlers.
117    async fn handle_graph(&self, command: GraphSubcommand) -> Result<()> {
118        match command {
119            GraphSubcommand::Build {
120                output: _,
121                dry_run: _,
122            } => {
123                // Build requires a domain-specific GraphExtractor, so we print
124                // a message indicating that the domain application should override.
125                println!(
126                    "Graph build requires a domain-specific extractor. \
127                     Override handle_graph() in your domain CLI."
128                );
129                Ok(())
130            }
131            GraphSubcommand::Validate => graph_handlers::handle_validate(&*self.config).await,
132            GraphSubcommand::Stats => graph_handlers::handle_stats(&*self.config).await,
133            GraphSubcommand::Query { id, query_type, to } => {
134                let options = graph_handlers::QueryOptions { id, query_type, to };
135                graph_handlers::handle_query(&*self.config, options).await
136            }
137        }
138    }
139}
140
141// ============================================================================
142// Tests
143// ============================================================================
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::cli::CliArgs;
149    use clap::Parser;
150    use std::path::PathBuf;
151
152    #[derive(Clone)]
153    struct TestConfig {
154        base: PathBuf,
155    }
156
157    impl ConfigProvider for TestConfig {
158        fn project_name(&self) -> &str {
159            "test-app"
160        }
161
162        fn base_path(&self) -> Result<PathBuf> {
163            Ok(self.base.clone())
164        }
165
166        fn content_path(&self, content_type: &str) -> Result<PathBuf> {
167            Ok(self.base.join(content_type))
168        }
169    }
170
171    fn test_config() -> TestConfig {
172        TestConfig {
173            base: PathBuf::from("/tmp/test"),
174        }
175    }
176
177    #[test]
178    fn test_fabryk_cli_new() {
179        let cli = FabrykCli::new("my-app", test_config());
180        assert_eq!(cli.name, "my-app");
181        assert_eq!(cli.config().project_name(), "test-app");
182    }
183
184    #[test]
185    fn test_fabryk_cli_with_version() {
186        let cli = FabrykCli::new("my-app", test_config()).with_version("1.2.3");
187        assert_eq!(cli.version, "1.2.3");
188    }
189
190    #[test]
191    fn test_fabryk_cli_config_access() {
192        let cli = FabrykCli::new("app", test_config());
193        assert_eq!(
194            cli.config().base_path().unwrap(),
195            PathBuf::from("/tmp/test")
196        );
197    }
198
199    #[tokio::test]
200    async fn test_run_version_command() {
201        let cli = FabrykCli::new("test-app", test_config()).with_version("0.1.0");
202        let args = CliArgs::parse_from(["test", "version"]);
203        let result = cli.run(args).await;
204        assert!(result.is_ok());
205    }
206
207    #[tokio::test]
208    async fn test_run_health_command() {
209        let cli = FabrykCli::new("test-app", test_config());
210        let args = CliArgs::parse_from(["test", "health"]);
211        let result = cli.run(args).await;
212        assert!(result.is_ok());
213    }
214
215    #[tokio::test]
216    async fn test_run_no_command() {
217        let cli = FabrykCli::new("test-app", test_config()).with_version("0.1.0");
218        let args = CliArgs::parse_from(["test"]);
219        let result = cli.run(args).await;
220        assert!(result.is_ok());
221    }
222
223    #[tokio::test]
224    async fn test_run_serve_command() {
225        let cli = FabrykCli::new("test-app", test_config());
226        let args = CliArgs::parse_from(["test", "serve", "--port", "9090"]);
227        let result = cli.run(args).await;
228        assert!(result.is_ok());
229    }
230
231    #[tokio::test]
232    async fn test_run_index_command() {
233        let cli = FabrykCli::new("test-app", test_config());
234        let args = CliArgs::parse_from(["test", "index"]);
235        let result = cli.run(args).await;
236        assert!(result.is_ok());
237    }
238
239    #[tokio::test]
240    async fn test_run_index_check() {
241        let cli = FabrykCli::new("test-app", test_config());
242        let args = CliArgs::parse_from(["test", "index", "--check"]);
243        let result = cli.run(args).await;
244        assert!(result.is_ok());
245    }
246
247    #[test]
248    fn test_init_logging_default() {
249        let cli = FabrykCli::new("test", test_config());
250        // Should not panic
251        cli.init_logging(false, false);
252    }
253
254    #[test]
255    fn test_init_logging_verbose() {
256        let cli = FabrykCli::new("test", test_config());
257        cli.init_logging(true, false);
258    }
259
260    #[test]
261    fn test_init_logging_quiet() {
262        let cli = FabrykCli::new("test", test_config());
263        cli.init_logging(false, true);
264    }
265
266    // ------------------------------------------------------------------------
267    // FabrykConfig integration tests
268    // ------------------------------------------------------------------------
269
270    #[test]
271    fn test_fabryk_cli_from_args_default() {
272        let args = CliArgs::parse_from(["test"]);
273        let cli = FabrykCli::from_args("test-app", &args).unwrap();
274        assert_eq!(cli.config().project_name(), "fabryk");
275    }
276
277    #[test]
278    fn test_fabryk_cli_from_args_with_file() {
279        let dir = tempfile::TempDir::new().unwrap();
280        let path = dir.path().join("config.toml");
281        std::fs::write(
282            &path,
283            r#"
284                project_name = "from-file"
285                [server]
286                port = 9090
287            "#,
288        )
289        .unwrap();
290
291        let args = CliArgs::parse_from(["test", "--config", path.to_str().unwrap()]);
292        let cli = FabrykCli::from_args("test-app", &args).unwrap();
293        assert_eq!(cli.config().project_name(), "from-file");
294    }
295
296    #[tokio::test]
297    async fn test_fabryk_cli_config_command_dispatch() {
298        let cli = FabrykCli::new("test-app", test_config());
299        let args = CliArgs::parse_from(["test", "config", "path"]);
300        let result = cli.run(args).await;
301        assert!(result.is_ok());
302    }
303}