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            if path.exists() {
113                builder
114                    .add_file(&path.to_string_lossy())
115                    .map_err(|e| Error::config(format!("config file: {e}")))?;
116            }
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        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// Helper: flatten TOML to env vars
223// ============================================================================
224
225/// Recursively flatten a TOML value into `KEY=value` pairs.
226fn flatten_toml_value(value: &toml::Value, prefix: &str, out: &mut Vec<(String, String)>) {
227    match value {
228        toml::Value::Table(table) => {
229            for (key, val) in table {
230                let env_key = format!("{}_{}", prefix, key.to_uppercase());
231                flatten_toml_value(val, &env_key, out);
232            }
233        }
234        toml::Value::Array(arr) => {
235            if let Ok(json) = serde_json::to_string(arr) {
236                out.push((prefix.to_string(), json));
237            }
238        }
239        toml::Value::String(s) => {
240            out.push((prefix.to_string(), s.clone()));
241        }
242        toml::Value::Integer(i) => {
243            out.push((prefix.to_string(), i.to_string()));
244        }
245        toml::Value::Float(f) => {
246            out.push((prefix.to_string(), f.to_string()));
247        }
248        toml::Value::Boolean(b) => {
249            out.push((prefix.to_string(), b.to_string()));
250        }
251        toml::Value::Datetime(dt) => {
252            out.push((prefix.to_string(), dt.to_string()));
253        }
254    }
255}
256
257// ============================================================================
258// Tests
259// ============================================================================
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::collections::HashMap;
265    use std::sync::Mutex;
266
267    /// Serializes tests that manipulate environment variables.
268    static ENV_MUTEX: Mutex<()> = Mutex::new(());
269
270    /// RAII guard for env var manipulation in tests.
271    struct EnvGuard {
272        key: String,
273        prev: Option<String>,
274    }
275
276    impl EnvGuard {
277        fn new(key: &str, value: &str) -> Self {
278            let prev = std::env::var(key).ok();
279            // SAFETY: Test-only helper; tests using this guard run serially.
280            unsafe { std::env::set_var(key, value) };
281            Self {
282                key: key.to_string(),
283                prev,
284            }
285        }
286
287        fn remove(key: &str) -> Self {
288            let prev = std::env::var(key).ok();
289            // SAFETY: Test-only helper; tests using this guard run serially.
290            unsafe { std::env::remove_var(key) };
291            Self {
292                key: key.to_string(),
293                prev,
294            }
295        }
296    }
297
298    impl Drop for EnvGuard {
299        fn drop(&mut self) {
300            // SAFETY: Test-only helper; tests using this guard run serially.
301            if let Some(val) = &self.prev {
302                unsafe { std::env::set_var(&self.key, val) };
303            } else {
304                unsafe { std::env::remove_var(&self.key) };
305            }
306        }
307    }
308
309    // ------------------------------------------------------------------------
310    // Default tests
311    // ------------------------------------------------------------------------
312
313    #[test]
314    fn test_fabryk_config_default() {
315        let config = FabrykConfig::default();
316        assert_eq!(config.project_name, "fabryk");
317        assert!(config.base_path.is_none());
318        assert!(config.content.path.is_none());
319        assert!(config.graph.output_path.is_none());
320        assert_eq!(config.server.port, 3000);
321        assert_eq!(config.server.host, "127.0.0.1");
322    }
323
324    // ------------------------------------------------------------------------
325    // Serialization tests
326    // ------------------------------------------------------------------------
327
328    #[test]
329    fn test_fabryk_config_from_toml() {
330        let toml_str = r#"
331            project_name = "my-app"
332            base_path = "/data"
333
334            [content]
335            path = "/data/content"
336
337            [graph]
338            output_path = "/data/graphs"
339
340            [server]
341            port = 8080
342            host = "0.0.0.0"
343        "#;
344
345        let config: FabrykConfig = toml::from_str(toml_str).unwrap();
346        assert_eq!(config.project_name, "my-app");
347        assert_eq!(config.base_path.as_deref(), Some("/data"));
348        assert_eq!(config.content.path.as_deref(), Some("/data/content"));
349        assert_eq!(config.graph.output_path.as_deref(), Some("/data/graphs"));
350        assert_eq!(config.server.port, 8080);
351        assert_eq!(config.server.host, "0.0.0.0");
352    }
353
354    #[test]
355    fn test_fabryk_config_to_toml() {
356        let config = FabrykConfig::default();
357        let toml_str = config.to_toml_string().unwrap();
358        assert!(toml_str.contains("project_name = \"fabryk\""));
359        assert!(toml_str.contains("[server]"));
360        assert!(toml_str.contains("port = 3000"));
361
362        // Round-trip
363        let parsed: FabrykConfig = toml::from_str(&toml_str).unwrap();
364        assert_eq!(parsed.project_name, config.project_name);
365        assert_eq!(parsed.server.port, config.server.port);
366    }
367
368    // ------------------------------------------------------------------------
369    // Loading tests
370    // ------------------------------------------------------------------------
371
372    #[test]
373    fn test_fabryk_config_load_from_file() {
374        let dir = tempfile::TempDir::new().unwrap();
375        let path = dir.path().join("config.toml");
376        std::fs::write(
377            &path,
378            r#"
379                project_name = "loaded-app"
380                [server]
381                port = 9090
382            "#,
383        )
384        .unwrap();
385
386        let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
387        assert_eq!(config.project_name, "loaded-app");
388        assert_eq!(config.server.port, 9090);
389    }
390
391    #[test]
392    fn test_fabryk_config_load_defaults() {
393        // Load with a nonexistent file falls back to defaults
394        let config = FabrykConfig::load(Some("/nonexistent/config.toml")).unwrap();
395        assert_eq!(config.project_name, "fabryk");
396        assert_eq!(config.server.port, 3000);
397    }
398
399    #[test]
400    fn test_fabryk_config_load_env_overlay() {
401        let _lock = ENV_MUTEX.lock().unwrap();
402        let dir = tempfile::TempDir::new().unwrap();
403        let path = dir.path().join("config.toml");
404        std::fs::write(
405            &path,
406            r#"
407                project_name = "file-app"
408                [server]
409                host = "127.0.0.1"
410            "#,
411        )
412        .unwrap();
413
414        // Env vars override file values (confyg passes env values as strings,
415        // so we test with a string field — numeric fields require manual handling).
416        let _guard = EnvGuard::new("FABRYK_SERVER_HOST", "0.0.0.0");
417        let config = FabrykConfig::load(Some(path.to_str().unwrap())).unwrap();
418        assert_eq!(config.server.host, "0.0.0.0");
419    }
420
421    // ------------------------------------------------------------------------
422    // resolve_config_path tests
423    // ------------------------------------------------------------------------
424
425    #[test]
426    fn test_fabryk_config_resolve_config_path_explicit() {
427        let path = FabrykConfig::resolve_config_path(Some("/explicit/config.toml"));
428        assert_eq!(path, Some(PathBuf::from("/explicit/config.toml")));
429    }
430
431    #[test]
432    fn test_fabryk_config_resolve_config_path_env() {
433        let _lock = ENV_MUTEX.lock().unwrap();
434        let _guard = EnvGuard::new("FABRYK_CONFIG", "/env/config.toml");
435        let path = FabrykConfig::resolve_config_path(None);
436        assert_eq!(path, Some(PathBuf::from("/env/config.toml")));
437    }
438
439    #[test]
440    fn test_fabryk_config_resolve_config_path_default() {
441        let _lock = ENV_MUTEX.lock().unwrap();
442        let _guard = EnvGuard::remove("FABRYK_CONFIG");
443        let path = FabrykConfig::resolve_config_path(None);
444        assert!(path.is_some());
445        let p = path.unwrap();
446        assert!(p.to_str().unwrap().contains("fabryk"));
447        assert!(p.to_str().unwrap().ends_with("config.toml"));
448    }
449
450    // ------------------------------------------------------------------------
451    // ConfigProvider tests
452    // ------------------------------------------------------------------------
453
454    #[test]
455    fn test_fabryk_config_provider_project_name() {
456        let config = FabrykConfig {
457            project_name: "test-project".into(),
458            ..Default::default()
459        };
460        assert_eq!(config.project_name(), "test-project");
461    }
462
463    #[test]
464    fn test_fabryk_config_provider_base_path() {
465        let config = FabrykConfig {
466            base_path: Some("/my/data".into()),
467            ..Default::default()
468        };
469        assert_eq!(config.base_path().unwrap(), PathBuf::from("/my/data"));
470    }
471
472    #[test]
473    fn test_fabryk_config_provider_base_path_default() {
474        let config = FabrykConfig::default();
475        let base = config.base_path().unwrap();
476        // Falls back to cwd
477        assert_eq!(base, std::env::current_dir().unwrap());
478    }
479
480    #[test]
481    fn test_fabryk_config_provider_content_path() {
482        let config = FabrykConfig {
483            base_path: Some("/project".into()),
484            ..Default::default()
485        };
486        let path = config.content_path("concepts").unwrap();
487        assert_eq!(path, PathBuf::from("/project/concepts"));
488    }
489
490    #[test]
491    fn test_fabryk_config_provider_content_path_explicit() {
492        let config = FabrykConfig {
493            content: ContentConfig {
494                path: Some("/custom/content".into()),
495            },
496            ..Default::default()
497        };
498        let path = config.content_path("anything").unwrap();
499        assert_eq!(path, PathBuf::from("/custom/content"));
500    }
501
502    // ------------------------------------------------------------------------
503    // to_env_vars tests
504    // ------------------------------------------------------------------------
505
506    #[test]
507    fn test_fabryk_config_to_env_vars() {
508        let config = FabrykConfig::default();
509        let vars = config.to_env_vars().unwrap();
510        let map: HashMap<_, _> = vars.into_iter().collect();
511        assert_eq!(map.get("FABRYK_PROJECT_NAME").unwrap(), "fabryk");
512        assert_eq!(map.get("FABRYK_SERVER_PORT").unwrap(), "3000");
513        assert_eq!(map.get("FABRYK_SERVER_HOST").unwrap(), "127.0.0.1");
514    }
515
516    // ------------------------------------------------------------------------
517    // Clone + Send + Sync
518    // ------------------------------------------------------------------------
519
520    #[test]
521    fn test_fabryk_config_is_clone() {
522        let config = FabrykConfig::default();
523        let cloned = config.clone();
524        assert_eq!(config.project_name, cloned.project_name);
525    }
526
527    #[test]
528    fn test_fabryk_config_send_sync() {
529        fn assert_send_sync<T: Send + Sync>() {}
530        assert_send_sync::<FabrykConfig>();
531    }
532}