Skip to main content

fabryk_cli/
config.rs

1//! Configuration for Fabryk CLI applications.
2//!
3//! Provides the [`FabrykConfig`] struct that loads from TOML files,
4//! environment variables, and defaults using the `confyg` crate.
5//!
6//! # Loading Priority
7//!
8//! 1. Explicit `--config <path>` flag
9//! 2. `FABRYK_CONFIG` environment variable
10//! 3. XDG default: `~/.config/fabryk/config.toml`
11//! 4. Built-in defaults
12
13use confyg::{Confygery, env};
14use fabryk_core::traits::ConfigProvider;
15use fabryk_core::{Error, Result};
16use serde::{Deserialize, Serialize};
17use std::path::PathBuf;
18
19// ============================================================================
20// Configuration structs
21// ============================================================================
22
23/// Main configuration for Fabryk CLI applications.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(default)]
26pub struct FabrykConfig {
27    /// Project name, used for env var prefixes and default paths.
28    pub project_name: String,
29
30    /// Base path for all project data.
31    pub base_path: Option<String>,
32
33    /// Content-related configuration.
34    pub content: ContentConfig,
35
36    /// Graph-related configuration.
37    pub graph: GraphConfig,
38
39    /// Server configuration.
40    pub server: ServerConfig,
41}
42
43/// Content storage configuration.
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45#[serde(default)]
46pub struct ContentConfig {
47    /// Path to content directory.
48    pub path: Option<String>,
49}
50
51/// Graph storage configuration.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(default)]
54pub struct GraphConfig {
55    /// Output path for graph files.
56    pub output_path: Option<String>,
57}
58
59/// Server configuration.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct ServerConfig {
63    /// Port to listen on.
64    pub port: u16,
65
66    /// Host address to bind to.
67    pub host: String,
68}
69
70// ============================================================================
71// Default implementations
72// ============================================================================
73
74impl Default for FabrykConfig {
75    fn default() -> Self {
76        Self {
77            project_name: "fabryk".to_string(),
78            base_path: None,
79            content: ContentConfig::default(),
80            graph: GraphConfig::default(),
81            server: ServerConfig::default(),
82        }
83    }
84}
85
86impl Default for ServerConfig {
87    fn default() -> Self {
88        Self {
89            port: 3000,
90            host: "127.0.0.1".to_string(),
91        }
92    }
93}
94
95// ============================================================================
96// Config loading
97// ============================================================================
98
99impl FabrykConfig {
100    /// Load configuration from file, environment, and defaults.
101    ///
102    /// Loading priority:
103    /// 1. Explicit `config_path` (from `--config` flag)
104    /// 2. `FABRYK_CONFIG` env var
105    /// 3. XDG default: `~/.config/fabryk/config.toml`
106    /// 4. Built-in defaults
107    pub fn load(config_path: Option<&str>) -> Result<Self> {
108        let mut builder =
109            Confygery::new().map_err(|e| Error::config(format!("config init: {e}")))?;
110
111        if let Some(path) = Self::resolve_config_path(config_path)
112            && path.exists()
113        {
114            builder
115                .add_file(&path.to_string_lossy())
116                .map_err(|e| Error::config(format!("config file: {e}")))?;
117        }
118
119        let mut env_opts = env::Options::with_top_level("FABRYK");
120        env_opts.add_section("content");
121        env_opts.add_section("graph");
122        env_opts.add_section("server");
123        builder
124            .add_env(env_opts)
125            .map_err(|e| Error::config(format!("config env: {e}")))?;
126
127        let config: Self = builder
128            .build()
129            .map_err(|e| Error::config(format!("config build: {e}")))?;
130
131        Ok(config)
132    }
133
134    /// Resolve the config file path from explicit flag, env var, or XDG default.
135    pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
136        // 1. Explicit --config flag
137        if let Some(path) = explicit {
138            return Some(PathBuf::from(path));
139        }
140
141        // 2. FABRYK_CONFIG env var
142        if let Ok(path) = std::env::var("FABRYK_CONFIG") {
143            return Some(PathBuf::from(path));
144        }
145
146        // 3. XDG default
147        Self::default_config_path()
148    }
149
150    /// Return the XDG default config path.
151    pub fn default_config_path() -> Option<PathBuf> {
152        dirs::config_dir().map(|d| d.join("fabryk").join("config.toml"))
153    }
154
155    /// Serialize this config to a pretty-printed TOML string.
156    pub fn to_toml_string(&self) -> Result<String> {
157        toml::to_string_pretty(self).map_err(|e| Error::config(e.to_string()))
158    }
159
160    /// Flatten this config into environment variable pairs with `FABRYK_` prefix.
161    pub fn to_env_vars(&self) -> Result<Vec<(String, String)>> {
162        let value: toml::Value =
163            toml::Value::try_from(self).map_err(|e| Error::config(e.to_string()))?;
164        let mut vars = Vec::new();
165        crate::config_utils::flatten_toml_value(&value, "FABRYK", &mut vars);
166        Ok(vars)
167    }
168}
169
170// ============================================================================
171// ConfigProvider implementation
172// ============================================================================
173
174impl fabryk_core::ConfigManager for FabrykConfig {
175    fn load(config_path: Option<&str>) -> Result<Self> {
176        FabrykConfig::load(config_path)
177    }
178
179    fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
180        FabrykConfig::resolve_config_path(explicit)
181    }
182
183    fn default_config_path() -> Option<PathBuf> {
184        FabrykConfig::default_config_path()
185    }
186
187    fn project_name() -> &'static str {
188        "fabryk"
189    }
190
191    fn to_toml_string(&self) -> Result<String> {
192        FabrykConfig::to_toml_string(self)
193    }
194
195    fn to_env_vars(&self) -> Result<Vec<(String, String)>> {
196        FabrykConfig::to_env_vars(self)
197    }
198}
199
200impl ConfigProvider for FabrykConfig {
201    fn project_name(&self) -> &str {
202        &self.project_name
203    }
204
205    fn base_path(&self) -> Result<PathBuf> {
206        match &self.base_path {
207            Some(p) => Ok(PathBuf::from(p)),
208            None => std::env::current_dir()
209                .map_err(|e| Error::config(format!("Could not determine base path: {e}"))),
210        }
211    }
212
213    fn content_path(&self, content_type: &str) -> Result<PathBuf> {
214        match &self.content.path {
215            Some(p) => Ok(PathBuf::from(p)),
216            None => Ok(self.base_path()?.join(content_type)),
217        }
218    }
219}
220
221// ============================================================================
222// Tests
223// ============================================================================
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::collections::HashMap;
229    use std::sync::Mutex;
230
231    /// Serializes tests that manipulate environment variables.
232    static ENV_MUTEX: Mutex<()> = Mutex::new(());
233
234    /// RAII guard for env var manipulation in tests.
235    struct EnvGuard {
236        key: String,
237        prev: Option<String>,
238    }
239
240    impl EnvGuard {
241        fn new(key: &str, value: &str) -> Self {
242            let prev = std::env::var(key).ok();
243            // SAFETY: Test-only helper; tests using this guard run serially.
244            unsafe { std::env::set_var(key, value) };
245            Self {
246                key: key.to_string(),
247                prev,
248            }
249        }
250
251        fn remove(key: &str) -> Self {
252            let prev = std::env::var(key).ok();
253            // SAFETY: Test-only helper; tests using this guard run serially.
254            unsafe { std::env::remove_var(key) };
255            Self {
256                key: key.to_string(),
257                prev,
258            }
259        }
260    }
261
262    impl Drop for EnvGuard {
263        fn drop(&mut self) {
264            // SAFETY: Test-only helper; tests using this guard run serially.
265            if let Some(val) = &self.prev {
266                unsafe { std::env::set_var(&self.key, val) };
267            } else {
268                unsafe { std::env::remove_var(&self.key) };
269            }
270        }
271    }
272
273    // ------------------------------------------------------------------------
274    // Default tests
275    // ------------------------------------------------------------------------
276
277    #[test]
278    fn test_fabryk_config_default() {
279        let config = FabrykConfig::default();
280        assert_eq!(config.project_name, "fabryk");
281        assert!(config.base_path.is_none());
282        assert!(config.content.path.is_none());
283        assert!(config.graph.output_path.is_none());
284        assert_eq!(config.server.port, 3000);
285        assert_eq!(config.server.host, "127.0.0.1");
286    }
287
288    // ------------------------------------------------------------------------
289    // Serialization tests
290    // ------------------------------------------------------------------------
291
292    #[test]
293    fn test_fabryk_config_from_toml() {
294        let toml_str = r#"
295            project_name = "my-app"
296            base_path = "/data"
297
298            [content]
299            path = "/data/content"
300
301            [graph]
302            output_path = "/data/graphs"
303
304            [server]
305            port = 8080
306            host = "0.0.0.0"
307        "#;
308
309        let config: FabrykConfig = toml::from_str(toml_str).unwrap();
310        assert_eq!(config.project_name, "my-app");
311        assert_eq!(config.base_path.as_deref(), Some("/data"));
312        assert_eq!(config.content.path.as_deref(), Some("/data/content"));
313        assert_eq!(config.graph.output_path.as_deref(), Some("/data/graphs"));
314        assert_eq!(config.server.port, 8080);
315        assert_eq!(config.server.host, "0.0.0.0");
316    }
317
318    #[test]
319    fn test_fabryk_config_to_toml() {
320        let config = FabrykConfig::default();
321        let toml_str = config.to_toml_string().unwrap();
322        assert!(toml_str.contains("project_name = \"fabryk\""));
323        assert!(toml_str.contains("[server]"));
324        assert!(toml_str.contains("port = 3000"));
325
326        // Round-trip
327        let parsed: FabrykConfig = toml::from_str(&toml_str).unwrap();
328        assert_eq!(parsed.project_name, config.project_name);
329        assert_eq!(parsed.server.port, config.server.port);
330    }
331
332    // ------------------------------------------------------------------------
333    // Loading tests
334    // ------------------------------------------------------------------------
335
336    #[test]
337    fn test_fabryk_config_load_from_file() {
338        let dir = tempfile::TempDir::new().unwrap();
339        let path = dir.path().join("config.toml");
340        std::fs::write(
341            &path,
342            r#"
343                project_name = "loaded-app"
344                [server]
345                port = 9090
346            "#,
347        )
348        .unwrap();
349
350        let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
351        assert_eq!(config.project_name, "loaded-app");
352        assert_eq!(config.server.port, 9090);
353    }
354
355    #[test]
356    fn test_fabryk_config_load_defaults() {
357        // Load with a nonexistent file falls back to defaults
358        let config = FabrykConfig::load(Some("/nonexistent/config.toml")).unwrap();
359        assert_eq!(config.project_name, "fabryk");
360        assert_eq!(config.server.port, 3000);
361    }
362
363    #[test]
364    fn test_fabryk_config_load_env_overlay() {
365        let _lock = ENV_MUTEX.lock().unwrap();
366        let dir = tempfile::TempDir::new().unwrap();
367        let path = dir.path().join("config.toml");
368        std::fs::write(
369            &path,
370            r#"
371                project_name = "file-app"
372                [server]
373                host = "127.0.0.1"
374            "#,
375        )
376        .unwrap();
377
378        // Env vars override file values (confyg passes env values as strings,
379        // so we test with a string field — numeric fields require manual handling).
380        let _guard = EnvGuard::new("FABRYK_SERVER_HOST", "0.0.0.0");
381        let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
382        assert_eq!(config.server.host, "0.0.0.0");
383    }
384
385    // ------------------------------------------------------------------------
386    // resolve_config_path tests
387    // ------------------------------------------------------------------------
388
389    #[test]
390    fn test_fabryk_config_resolve_config_path_explicit() {
391        let path = FabrykConfig::resolve_config_path(Some("/explicit/config.toml"));
392        assert_eq!(path, Some(PathBuf::from("/explicit/config.toml")));
393    }
394
395    #[test]
396    fn test_fabryk_config_resolve_config_path_env() {
397        let _lock = ENV_MUTEX.lock().unwrap();
398        let _guard = EnvGuard::new("FABRYK_CONFIG", "/env/config.toml");
399        let path = FabrykConfig::resolve_config_path(None);
400        assert_eq!(path, Some(PathBuf::from("/env/config.toml")));
401    }
402
403    #[test]
404    fn test_fabryk_config_resolve_config_path_default() {
405        let _lock = ENV_MUTEX.lock().unwrap();
406        let _guard = EnvGuard::remove("FABRYK_CONFIG");
407        let path = FabrykConfig::resolve_config_path(None);
408        assert!(path.is_some());
409        let p = path.unwrap();
410        assert!(p.to_str().unwrap().contains("fabryk"));
411        assert!(p.to_str().unwrap().ends_with("config.toml"));
412    }
413
414    // ------------------------------------------------------------------------
415    // ConfigProvider tests
416    // ------------------------------------------------------------------------
417
418    #[test]
419    fn test_fabryk_config_provider_project_name() {
420        let config = FabrykConfig {
421            project_name: "test-project".into(),
422            ..Default::default()
423        };
424        assert_eq!(config.project_name(), "test-project");
425    }
426
427    #[test]
428    fn test_fabryk_config_provider_base_path() {
429        let config = FabrykConfig {
430            base_path: Some("/my/data".into()),
431            ..Default::default()
432        };
433        assert_eq!(config.base_path().unwrap(), PathBuf::from("/my/data"));
434    }
435
436    #[test]
437    fn test_fabryk_config_provider_base_path_default() {
438        let config = FabrykConfig::default();
439        let base = config.base_path().unwrap();
440        // Falls back to cwd
441        assert_eq!(base, std::env::current_dir().unwrap());
442    }
443
444    #[test]
445    fn test_fabryk_config_provider_content_path() {
446        let config = FabrykConfig {
447            base_path: Some("/project".into()),
448            ..Default::default()
449        };
450        let path = config.content_path("concepts").unwrap();
451        assert_eq!(path, PathBuf::from("/project/concepts"));
452    }
453
454    #[test]
455    fn test_fabryk_config_provider_content_path_explicit() {
456        let config = FabrykConfig {
457            content: ContentConfig {
458                path: Some("/custom/content".into()),
459            },
460            ..Default::default()
461        };
462        let path = config.content_path("anything").unwrap();
463        assert_eq!(path, PathBuf::from("/custom/content"));
464    }
465
466    // ------------------------------------------------------------------------
467    // to_env_vars tests
468    // ------------------------------------------------------------------------
469
470    #[test]
471    fn test_fabryk_config_to_env_vars() {
472        let config = FabrykConfig::default();
473        let vars = config.to_env_vars().unwrap();
474        let map: HashMap<_, _> = vars.into_iter().collect();
475        assert_eq!(map.get("FABRYK_PROJECT_NAME").unwrap(), "fabryk");
476        assert_eq!(map.get("FABRYK_SERVER_PORT").unwrap(), "3000");
477        assert_eq!(map.get("FABRYK_SERVER_HOST").unwrap(), "127.0.0.1");
478    }
479
480    // ------------------------------------------------------------------------
481    // Clone + Send + Sync
482    // ------------------------------------------------------------------------
483
484    #[test]
485    fn test_fabryk_config_is_clone() {
486        let config = FabrykConfig::default();
487        let cloned = config.clone();
488        assert_eq!(config.project_name, cloned.project_name);
489    }
490
491    #[test]
492    fn test_fabryk_config_send_sync() {
493        fn assert_send_sync<T: Send + Sync>() {}
494        assert_send_sync::<FabrykConfig>();
495    }
496}