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