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