Skip to main content

fabryk_cli/
config_loader.rs

1//! Generic configuration loader that encapsulates the confyg build pattern.
2//!
3//! Eliminates the boilerplate that every Fabryk-based project duplicates:
4//! `Confygery::new()` → `add_file()` → `add_env(sections)` → `build()`.
5
6use confyg::{Confygery, env};
7use fabryk_core::{Error, Result};
8use serde::de::DeserializeOwned;
9use std::path::PathBuf;
10
11/// Builder for loading configuration from TOML files and environment variables.
12///
13/// # Example
14///
15/// ```no_run
16/// use fabryk_cli::config_loader::ConfigLoaderBuilder;
17/// use serde::Deserialize;
18///
19/// #[derive(Default, Deserialize)]
20/// struct MyConfig {
21///     port: u16,
22/// }
23///
24/// let resolve = |explicit: Option<&str>| -> Option<std::path::PathBuf> {
25///     explicit.map(std::path::PathBuf::from)
26/// };
27///
28/// let (config, path) = ConfigLoaderBuilder::new("myapp")
29///     .section("server")
30///     .section("logging")
31///     .port_env_override("PORT")
32///     .build::<MyConfig>(None, resolve)
33///     .unwrap();
34/// ```
35pub struct ConfigLoaderBuilder {
36    prefix: String,
37    sections: Vec<String>,
38    port_env_var: Option<String>,
39}
40
41impl ConfigLoaderBuilder {
42    /// Create a new builder with the given environment variable prefix.
43    ///
44    /// The prefix is used for env var namespacing (e.g., `"taproot"` →
45    /// `TAPROOT_*` environment variables).
46    pub fn new(prefix: &str) -> Self {
47        Self {
48            prefix: prefix.to_string(),
49            sections: Vec::new(),
50            port_env_var: None,
51        }
52    }
53
54    /// Register a config section for environment variable mapping.
55    ///
56    /// Each section becomes a namespace in the env var hierarchy.
57    /// For example, section `"bq"` maps `TAPROOT_BQ_PROJECT` → `bq.project`.
58    pub fn section(mut self, name: &str) -> Self {
59        self.sections.push(name.to_string());
60        self
61    }
62
63    /// Register a bare environment variable that overrides the `port` field.
64    ///
65    /// Cloud Run sets `PORT` (without prefix). This option lets the loader
66    /// check that env var and apply it as a post-load override.
67    ///
68    /// The override only applies if the env var exists and parses as `u16`.
69    /// The caller is responsible for applying the override to the correct
70    /// field after `build()` returns.
71    pub fn port_env_override(mut self, env_var: &str) -> Self {
72        self.port_env_var = Some(env_var.to_string());
73        self
74    }
75
76    /// Build the configuration, returning the deserialized struct and
77    /// the resolved config file path (if one was found).
78    ///
79    /// # Arguments
80    ///
81    /// * `config_path` — explicit config file path (e.g., from `--config` flag)
82    /// * `resolve_fn` — function to resolve the config path when not explicit
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the config file exists but cannot be parsed,
87    /// or if environment variables contain invalid values.
88    pub fn build<C: DeserializeOwned + Default>(
89        self,
90        config_path: Option<&str>,
91        resolve_fn: impl Fn(Option<&str>) -> Option<PathBuf>,
92    ) -> Result<(C, Option<PathBuf>)> {
93        let resolved_path = resolve_fn(config_path);
94
95        let mut builder =
96            Confygery::new().map_err(|e| Error::config(format!("config init: {e}")))?;
97
98        // Load config file if it exists
99        if let Some(ref path) = resolved_path
100            && path.exists()
101        {
102            builder
103                .add_file(&path.to_string_lossy())
104                .map_err(|e| Error::config(format!("config file: {e}")))?;
105        }
106
107        // Load environment variables with prefix and sections
108        let mut env_opts = env::Options::with_top_level(&self.prefix);
109        for section in &self.sections {
110            env_opts.add_section(section);
111        }
112        builder
113            .add_env(env_opts)
114            .map_err(|e| Error::config(format!("config env: {e}")))?;
115
116        let config: C = builder
117            .build()
118            .map_err(|e| Error::config(format!("config build: {e}")))?;
119
120        Ok((config, resolved_path))
121    }
122
123    /// Check the port environment variable override, if configured.
124    ///
125    /// Returns `Some(port)` if the env var exists and parses as `u16`.
126    /// Intended to be called after `build()` to apply the override.
127    pub fn check_port_override(&self) -> Option<u16> {
128        self.port_env_var
129            .as_ref()
130            .and_then(|var| std::env::var(var).ok())
131            .and_then(|val| val.parse::<u16>().ok())
132    }
133}
134
135// ============================================================================
136// Tests
137// ============================================================================
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use serde::Deserialize;
143
144    #[derive(Debug, Default, Deserialize, PartialEq)]
145    struct TestConfig {
146        #[serde(default)]
147        name: String,
148        #[serde(default)]
149        port: u16,
150    }
151
152    fn no_resolve(_: Option<&str>) -> Option<PathBuf> {
153        None
154    }
155
156    #[test]
157    fn test_config_loader_defaults() {
158        let (config, path) = ConfigLoaderBuilder::new("test_loader")
159            .build::<TestConfig>(None, no_resolve)
160            .unwrap();
161        assert_eq!(config.name, "");
162        assert_eq!(config.port, 0);
163        assert!(path.is_none());
164    }
165
166    #[test]
167    fn test_config_loader_from_file() {
168        let dir = tempfile::TempDir::new().unwrap();
169        let file = dir.path().join("config.toml");
170        std::fs::write(&file, "name = \"loaded\"\nport = 9090").unwrap();
171
172        let resolve = |explicit: Option<&str>| explicit.map(PathBuf::from);
173        let (config, path) = ConfigLoaderBuilder::new("test_loader")
174            .build::<TestConfig>(Some(file.to_str().unwrap()), resolve)
175            .unwrap();
176
177        assert_eq!(config.name, "loaded");
178        assert_eq!(config.port, 9090);
179        assert!(path.is_some());
180    }
181
182    #[test]
183    fn test_config_loader_missing_file_uses_defaults() {
184        let resolve = |explicit: Option<&str>| explicit.map(PathBuf::from);
185        let (config, _) = ConfigLoaderBuilder::new("test_loader")
186            .build::<TestConfig>(Some("/nonexistent/config.toml"), resolve)
187            .unwrap();
188        assert_eq!(config.name, "");
189    }
190
191    #[test]
192    fn test_config_loader_sections() {
193        // Just verify the builder accepts sections without error
194        let builder = ConfigLoaderBuilder::new("app")
195            .section("server")
196            .section("logging")
197            .section("oauth");
198        assert_eq!(builder.sections.len(), 3);
199    }
200
201    #[test]
202    fn test_config_loader_port_override_not_set() {
203        let builder = ConfigLoaderBuilder::new("app").port_env_override("PORT");
204        // PORT is unlikely to be set in test env
205        // Just verify the method works without panicking
206        let _ = builder.check_port_override();
207    }
208}