Skip to main content

fabryk_cli/
config_utils.rs

1//! Shared configuration utilities for Fabryk-based applications.
2//!
3//! Provides path resolution, TOML flattening, and environment variable
4//! generation utilities that eliminate boilerplate across projects.
5
6use std::path::{Path, PathBuf};
7
8// ============================================================================
9// Path resolution
10// ============================================================================
11
12/// Determine the base directory for resolving relative config paths.
13///
14/// Priority:
15/// 1. Environment variable named `env_var_name` (resolved against CWD if relative)
16/// 2. Config file's parent directory
17/// 3. Current working directory
18///
19/// # Arguments
20///
21/// * `env_var_name` — e.g., `"TAPROOT_BASE_DIR"` or `"KASU_BASE_DIR"`
22/// * `config_path` — the resolved config file path, if known
23///
24/// # Examples
25///
26/// ```
27/// use fabryk_cli::config_utils::resolve_base_dir;
28///
29/// let base = resolve_base_dir("MY_APP_BASE_DIR", None);
30/// assert!(base.is_absolute() || base == std::path::PathBuf::from("."));
31/// ```
32pub fn resolve_base_dir(env_var_name: &str, config_path: Option<&Path>) -> PathBuf {
33    // 1. Explicit env var override
34    if let Ok(env_dir) = std::env::var(env_var_name) {
35        let p = PathBuf::from(&env_dir);
36        if p.is_absolute() {
37            log::debug!("base_dir from {env_var_name}: {}", p.display());
38            return p;
39        }
40        // Relative env var → resolve against CWD
41        if let Ok(cwd) = std::env::current_dir() {
42            let resolved = cwd.join(&p);
43            log::debug!(
44                "base_dir from {env_var_name} (relative → absolute): {}",
45                resolved.display()
46            );
47            return resolved;
48        }
49    }
50
51    // 2. Config file's parent directory
52    if let Some(cfg) = config_path
53        && let Some(parent) = cfg.parent()
54        && !parent.as_os_str().is_empty()
55    {
56        log::debug!("base_dir from config file parent: {}", parent.display());
57        return parent.to_path_buf();
58    }
59
60    // 3. CWD fallback
61    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
62    log::debug!("base_dir from CWD fallback: {}", cwd.display());
63    cwd
64}
65
66/// Resolve a single path against a base directory.
67///
68/// - Empty strings pass through unchanged.
69/// - Absolute paths pass through unchanged.
70/// - Relative paths are joined with `base`.
71pub fn resolve_path(base: &Path, path: &str) -> String {
72    if path.is_empty() {
73        return path.to_string();
74    }
75    let p = Path::new(path);
76    if p.is_absolute() {
77        return path.to_string();
78    }
79    base.join(p).to_string_lossy().to_string()
80}
81
82/// Resolve an `Option<String>` path field in place, logging when a path changes.
83///
84/// Leaves `None` and empty strings unchanged. Resolves relative paths
85/// against `base` and logs the transformation at debug level.
86pub fn resolve_opt_path(field: &mut Option<String>, base: &Path, field_name: &str) {
87    if let Some(val) = field
88        && !val.is_empty()
89    {
90        let resolved = resolve_path(base, val);
91        if resolved != *val {
92            log::debug!("resolved {field_name}: {val} → {resolved}");
93            *val = resolved;
94        }
95    }
96}
97
98// ============================================================================
99// TOML → environment variable flattening
100// ============================================================================
101
102/// Recursively flatten a TOML value tree into `KEY=value` pairs.
103///
104/// Tables are expanded with `_` separators and keys are uppercased.
105/// Hyphens in keys are replaced with underscores (env vars cannot contain hyphens).
106/// Arrays are serialized as JSON (e.g., for table allowlists).
107///
108/// # Arguments
109///
110/// * `value` — the TOML value to flatten
111/// * `prefix` — the prefix for env var names (e.g., `"TAPROOT"`)
112/// * `out` — accumulator for `(KEY, VALUE)` pairs
113///
114/// # Examples
115///
116/// ```
117/// let val: toml::Value = toml::from_str("[bq]\nproject = \"my-project\"").unwrap();
118/// let mut vars = Vec::new();
119/// fabryk_cli::config_utils::flatten_toml_value(&val, "APP", &mut vars);
120/// assert_eq!(vars, vec![("APP_BQ_PROJECT".to_string(), "my-project".to_string())]);
121/// ```
122pub fn flatten_toml_value(value: &toml::Value, prefix: &str, out: &mut Vec<(String, String)>) {
123    match value {
124        toml::Value::Table(table) => {
125            for (key, val) in table {
126                let env_key = format!("{}_{}", prefix, key.to_uppercase().replace('-', "_"));
127                flatten_toml_value(val, &env_key, out);
128            }
129        }
130        toml::Value::Array(arr) => {
131            if let Ok(json) = serde_json::to_string(arr) {
132                out.push((prefix.to_string(), json));
133            }
134        }
135        toml::Value::String(s) => {
136            out.push((prefix.to_string(), s.clone()));
137        }
138        toml::Value::Integer(i) => {
139            out.push((prefix.to_string(), i.to_string()));
140        }
141        toml::Value::Float(f) => {
142            out.push((prefix.to_string(), f.to_string()));
143        }
144        toml::Value::Boolean(b) => {
145            out.push((prefix.to_string(), b.to_string()));
146        }
147        toml::Value::Datetime(dt) => {
148            out.push((prefix.to_string(), dt.to_string()));
149        }
150    }
151}
152
153// ============================================================================
154// Tests
155// ============================================================================
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::sync::Mutex;
161
162    /// Serialize env-mutating tests.
163    static ENV_MUTEX: Mutex<()> = Mutex::new(());
164
165    struct EnvGuard {
166        key: String,
167        prev: Option<String>,
168    }
169
170    impl EnvGuard {
171        fn new(key: &str, value: &str) -> Self {
172            let prev = std::env::var(key).ok();
173            unsafe { std::env::set_var(key, value) };
174            Self {
175                key: key.to_string(),
176                prev,
177            }
178        }
179
180        fn remove(key: &str) -> Self {
181            let prev = std::env::var(key).ok();
182            unsafe { std::env::remove_var(key) };
183            Self {
184                key: key.to_string(),
185                prev,
186            }
187        }
188    }
189
190    impl Drop for EnvGuard {
191        fn drop(&mut self) {
192            if let Some(val) = &self.prev {
193                unsafe { std::env::set_var(&self.key, val) };
194            } else {
195                unsafe { std::env::remove_var(&self.key) };
196            }
197        }
198    }
199
200    // -- resolve_base_dir tests --
201
202    #[test]
203    fn test_resolve_base_dir_from_env_var_absolute() {
204        let _lock = ENV_MUTEX.lock().unwrap();
205        let _guard = EnvGuard::new("TEST_BASE_DIR_ABS", "/explicit/base");
206        let base = resolve_base_dir("TEST_BASE_DIR_ABS", None);
207        assert_eq!(base, PathBuf::from("/explicit/base"));
208    }
209
210    #[test]
211    fn test_resolve_base_dir_from_env_var_relative() {
212        let _lock = ENV_MUTEX.lock().unwrap();
213        let _guard = EnvGuard::new("TEST_BASE_DIR_REL", "relative/path");
214        let base = resolve_base_dir("TEST_BASE_DIR_REL", None);
215        let cwd = std::env::current_dir().unwrap();
216        assert_eq!(base, cwd.join("relative/path"));
217    }
218
219    #[test]
220    fn test_resolve_base_dir_from_config_parent() {
221        let _lock = ENV_MUTEX.lock().unwrap();
222        let _guard = EnvGuard::remove("TEST_BASE_DIR_CFG");
223        let config_path = Path::new("/home/user/.config/app/config.toml");
224        let base = resolve_base_dir("TEST_BASE_DIR_CFG", Some(config_path));
225        assert_eq!(base, PathBuf::from("/home/user/.config/app"));
226    }
227
228    #[test]
229    fn test_resolve_base_dir_cwd_fallback() {
230        let _lock = ENV_MUTEX.lock().unwrap();
231        let _guard = EnvGuard::remove("TEST_BASE_DIR_CWD");
232        let base = resolve_base_dir("TEST_BASE_DIR_CWD", None);
233        let cwd = std::env::current_dir().unwrap();
234        assert_eq!(base, cwd);
235    }
236
237    // -- resolve_path tests --
238
239    #[test]
240    fn test_resolve_path_empty_passthrough() {
241        let base = Path::new("/base");
242        assert_eq!(resolve_path(base, ""), "");
243    }
244
245    #[test]
246    fn test_resolve_path_absolute_passthrough() {
247        let base = Path::new("/base");
248        assert_eq!(resolve_path(base, "/absolute/path"), "/absolute/path");
249    }
250
251    #[test]
252    fn test_resolve_path_relative_joined() {
253        let base = Path::new("/base");
254        assert_eq!(resolve_path(base, "relative/path"), "/base/relative/path");
255    }
256
257    #[test]
258    fn test_resolve_path_dot_prefix() {
259        let base = Path::new("/base");
260        assert_eq!(resolve_path(base, "./local"), "/base/./local");
261    }
262
263    #[test]
264    fn test_resolve_path_bare_filename() {
265        let base = Path::new("/config");
266        assert_eq!(resolve_path(base, "cert.pem"), "/config/cert.pem");
267    }
268
269    // -- resolve_opt_path tests --
270
271    #[test]
272    fn test_resolve_opt_path_none_unchanged() {
273        let mut field: Option<String> = None;
274        resolve_opt_path(&mut field, Path::new("/base"), "test");
275        assert!(field.is_none());
276    }
277
278    #[test]
279    fn test_resolve_opt_path_empty_unchanged() {
280        let mut field = Some(String::new());
281        resolve_opt_path(&mut field, Path::new("/base"), "test");
282        assert_eq!(field, Some(String::new()));
283    }
284
285    #[test]
286    fn test_resolve_opt_path_absolute_unchanged() {
287        let mut field = Some("/absolute/path".to_string());
288        resolve_opt_path(&mut field, Path::new("/base"), "test");
289        assert_eq!(field, Some("/absolute/path".to_string()));
290    }
291
292    #[test]
293    fn test_resolve_opt_path_relative_resolved() {
294        let mut field = Some("relative/file".to_string());
295        resolve_opt_path(&mut field, Path::new("/base"), "test");
296        assert_eq!(field, Some("/base/relative/file".to_string()));
297    }
298
299    // -- flatten_toml_value tests --
300
301    #[test]
302    fn test_flatten_toml_value_string() {
303        let val = toml::Value::String("hello".into());
304        let mut out = Vec::new();
305        flatten_toml_value(&val, "APP", &mut out);
306        assert_eq!(out, vec![("APP".to_string(), "hello".to_string())]);
307    }
308
309    #[test]
310    fn test_flatten_toml_value_nested_table() {
311        let val: toml::Value = toml::from_str("[bq]\nproject = \"my-project\"").unwrap();
312        let mut out = Vec::new();
313        flatten_toml_value(&val, "APP", &mut out);
314        assert_eq!(
315            out,
316            vec![("APP_BQ_PROJECT".to_string(), "my-project".to_string())]
317        );
318    }
319
320    #[test]
321    fn test_flatten_toml_value_array() {
322        let val: toml::Value = toml::from_str("items = [\"a\", \"b\"]").unwrap();
323        let mut out = Vec::new();
324        flatten_toml_value(&val, "APP", &mut out);
325        assert_eq!(
326            out,
327            vec![("APP_ITEMS".to_string(), "[\"a\",\"b\"]".to_string())]
328        );
329    }
330
331    #[test]
332    fn test_flatten_toml_value_boolean() {
333        let val: toml::Value = toml::from_str("enabled = true").unwrap();
334        let mut out = Vec::new();
335        flatten_toml_value(&val, "APP", &mut out);
336        assert_eq!(out, vec![("APP_ENABLED".to_string(), "true".to_string())]);
337    }
338
339    #[test]
340    fn test_flatten_toml_value_integer() {
341        let val: toml::Value = toml::from_str("port = 8080").unwrap();
342        let mut out = Vec::new();
343        flatten_toml_value(&val, "APP", &mut out);
344        assert_eq!(out, vec![("APP_PORT".to_string(), "8080".to_string())]);
345    }
346
347    #[test]
348    fn test_flatten_toml_value_hyphen_to_underscore() {
349        let val: toml::Value = toml::from_str("[my-section]\nmy-key = \"value\"").unwrap();
350        let mut out = Vec::new();
351        flatten_toml_value(&val, "APP", &mut out);
352        assert_eq!(
353            out,
354            vec![("APP_MY_SECTION_MY_KEY".to_string(), "value".to_string())]
355        );
356    }
357}