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}