Skip to main content

fnox_core/
settings.rs

1// Settings management for fnox
2// Based on the pattern from hk (https://github.com/jdx/hk)
3//
4// This module provides a centralized settings system that merges configuration from:
5// 1. Default values (lowest precedence)
6// 2. Config files (fnox.toml)
7// 3. Environment variables
8// 4. CLI flags (highest precedence)
9
10use arc_swap::ArcSwap;
11use miette::Result;
12use std::sync::Arc;
13use std::sync::{LazyLock, Mutex};
14
15// Include generated settings code
16mod generated {
17    pub(super) mod settings {
18        include!(concat!(env!("OUT_DIR"), "/generated/settings.rs"));
19    }
20    pub(super) mod settings_merge {
21        include!(concat!(env!("OUT_DIR"), "/generated/settings_merge.rs"));
22    }
23    pub(super) mod settings_meta {
24        include!(concat!(env!("OUT_DIR"), "/generated/settings_meta.rs"));
25    }
26}
27
28pub use generated::settings::Settings as GeneratedSettings;
29use generated::settings_merge::{SettingValue, SourceMap};
30use generated::settings_meta::SETTINGS_META;
31
32pub type SettingsSnapshot = Arc<GeneratedSettings>;
33
34// Global cached settings instance using ArcSwap for safe reloading
35static GLOBAL_SETTINGS: LazyLock<ArcSwap<GeneratedSettings>> =
36    LazyLock::new(|| ArcSwap::from_pointee(GeneratedSettings::default()));
37
38// Track whether we've initialized with real settings
39static INITIALIZED: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
40
41/// CLI snapshot captured from parsed command-line arguments
42#[derive(Debug, Clone, Default)]
43pub struct CliSnapshot {
44    pub age_key_file: Option<std::path::PathBuf>,
45    pub profile: Option<String>,
46    pub if_missing: Option<String>,
47    pub no_defaults: bool,
48}
49
50static CLI_SNAPSHOT: LazyLock<Mutex<Option<CliSnapshot>>> = LazyLock::new(|| Mutex::new(None));
51
52/// Main Settings interface
53pub struct Settings;
54
55impl Settings {
56    /// Get the current settings snapshot (panics on error)
57    pub fn get() -> Arc<GeneratedSettings> {
58        Self::try_get().expect("Failed to load configuration")
59    }
60
61    /// Try to get the current settings snapshot (returns error instead of panicking)
62    pub fn try_get() -> Result<Arc<GeneratedSettings>> {
63        Self::get_snapshot()
64    }
65
66    fn get_snapshot() -> Result<SettingsSnapshot> {
67        // Check if we need to initialize
68        let mut initialized = INITIALIZED.lock().unwrap();
69        if !*initialized {
70            // First access - initialize with all sources
71            let new_settings = Arc::new(Self::build_from_all_sources()?);
72            GLOBAL_SETTINGS.store(new_settings.clone());
73            *initialized = true;
74            return Ok(new_settings);
75        }
76        drop(initialized); // Release the lock early
77
78        // Already initialized - return the cached value
79        Ok(GLOBAL_SETTINGS.load_full())
80    }
81
82    /// Set the CLI snapshot (called after parsing CLI args)
83    pub fn set_cli_snapshot(snapshot: CliSnapshot) {
84        *CLI_SNAPSHOT.lock().unwrap() = Some(snapshot);
85    }
86
87    /// Build settings by merging all sources
88    fn build_from_all_sources() -> Result<GeneratedSettings> {
89        let defaults = GeneratedSettings::default();
90        let env_map = Self::collect_env_map()?;
91        let cli_map = Self::collect_cli_map();
92
93        Ok(Self::merge_settings(&defaults, &env_map, &cli_map))
94    }
95
96    /// Expand tilde (~) in path strings to the user's home directory
97    fn expand_path(path: &str) -> std::path::PathBuf {
98        shellexpand::tilde(path).into_owned().into()
99    }
100
101    /// Collect settings from environment variables
102    fn collect_env_map() -> Result<SourceMap> {
103        let mut map = SourceMap::new();
104
105        for (setting_name, meta) in SETTINGS_META.iter() {
106            for env_var in meta.sources.env {
107                if let Ok(val) = std::env::var(env_var) {
108                    match meta.typ {
109                        "string" => {
110                            map.insert(setting_name, SettingValue::String(val));
111                        }
112                        "option<string>" => {
113                            map.insert(setting_name, SettingValue::OptionString(Some(val)));
114                        }
115                        "path" => {
116                            map.insert(setting_name, SettingValue::Path(Self::expand_path(&val)));
117                        }
118                        "option<path>" => {
119                            map.insert(
120                                setting_name,
121                                SettingValue::OptionPath(Some(Self::expand_path(&val))),
122                            );
123                        }
124                        "bool" => {
125                            // Parse bool from env var (accept "true", "1", "yes", "on")
126                            let bool_val =
127                                matches!(val.to_lowercase().as_str(), "true" | "1" | "yes" | "on");
128                            map.insert(setting_name, SettingValue::Bool(bool_val));
129                        }
130                        _ => {
131                            // Ignore unknown types
132                        }
133                    }
134                    break; // First matching env var wins
135                }
136            }
137        }
138
139        Ok(map)
140    }
141
142    /// Collect settings from CLI snapshot
143    fn collect_cli_map() -> SourceMap {
144        let mut map = SourceMap::new();
145
146        if let Some(snapshot) = CLI_SNAPSHOT.lock().unwrap().clone() {
147            if let Some(age_key_file) = snapshot.age_key_file {
148                map.insert("age_key_file", SettingValue::OptionPath(Some(age_key_file)));
149            }
150
151            if let Some(profile) = snapshot.profile {
152                map.insert("profile", SettingValue::String(profile));
153            }
154
155            if let Some(if_missing) = snapshot.if_missing {
156                map.insert("if_missing", SettingValue::OptionString(Some(if_missing)));
157            }
158
159            if snapshot.no_defaults {
160                map.insert("no_defaults", SettingValue::Bool(true));
161            }
162        }
163
164        map
165    }
166
167    /// Merge settings from all sources
168    /// Precedence: CLI > Env > Defaults
169    fn merge_settings(
170        defaults: &GeneratedSettings,
171        env: &SourceMap,
172        cli: &SourceMap,
173    ) -> GeneratedSettings {
174        let mut val =
175            serde_json::to_value(defaults.clone()).unwrap_or_else(|_| serde_json::json!({}));
176
177        // Helper to set a value
178        fn set_value(val: &mut serde_json::Value, field: &str, v: &SettingValue) {
179            let new_v = match v {
180                SettingValue::String(s) => serde_json::json!(s),
181                SettingValue::OptionString(opt) => serde_json::json!(opt),
182                SettingValue::Path(p) => serde_json::json!(p.display().to_string()),
183                SettingValue::OptionPath(opt) => {
184                    serde_json::json!(opt.as_ref().map(|p| p.display().to_string()))
185                }
186                SettingValue::Bool(b) => serde_json::json!(b),
187            };
188
189            if let Some(obj) = val.as_object_mut() {
190                obj.insert(field.to_string(), new_v);
191            }
192        }
193
194        // Apply layers in precedence order (low to high): defaults < env < cli
195        for (name, _meta) in SETTINGS_META.iter() {
196            let field = *name;
197
198            // Apply env
199            if let Some(sv) = env.get(field) {
200                set_value(&mut val, field, sv);
201            }
202
203            // Apply cli (overrides env)
204            if let Some(sv) = cli.get(field) {
205                set_value(&mut val, field, sv);
206            }
207        }
208
209        serde_json::from_value(val).unwrap_or_else(|_| defaults.clone())
210    }
211
212    #[cfg(test)]
213    pub fn reset_for_tests() {
214        GLOBAL_SETTINGS.store(Arc::new(GeneratedSettings::default()));
215        *INITIALIZED.lock().unwrap() = false;
216        *CLI_SNAPSHOT.lock().unwrap() = None;
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_default_settings() {
226        let settings = GeneratedSettings::default();
227        assert_eq!(settings.profile, "default");
228        assert_eq!(settings.age_key_file, None);
229        assert!(!settings.no_defaults);
230    }
231
232    #[test]
233    fn test_settings_merge_precedence() {
234        let defaults = GeneratedSettings {
235            age_key_file: None,
236            profile: "default".to_string(),
237            no_defaults: false,
238            shell_integration_output: "normal".to_string(),
239            if_missing: None,
240            if_missing_default: None,
241            http_timeout: "30s".to_string(),
242        };
243
244        let mut env = SourceMap::new();
245        env.insert(
246            "age_key_file",
247            SettingValue::OptionPath(Some(std::path::PathBuf::from("/env/key.txt"))),
248        );
249
250        let mut cli = SourceMap::new();
251        cli.insert(
252            "age_key_file",
253            SettingValue::OptionPath(Some(std::path::PathBuf::from("/cli/key.txt"))),
254        );
255
256        let merged = Settings::merge_settings(&defaults, &env, &cli);
257
258        // CLI should win
259        assert_eq!(
260            merged.age_key_file,
261            Some(std::path::PathBuf::from("/cli/key.txt"))
262        );
263    }
264
265    #[test]
266    fn test_settings_merge_partial() {
267        let defaults = GeneratedSettings {
268            age_key_file: None,
269            profile: "default".to_string(),
270            no_defaults: false,
271            shell_integration_output: "normal".to_string(),
272            if_missing: None,
273            if_missing_default: None,
274            http_timeout: "30s".to_string(),
275        };
276
277        let mut env = SourceMap::new();
278        env.insert(
279            "age_key_file",
280            SettingValue::OptionPath(Some(std::path::PathBuf::from("/env/key.txt"))),
281        );
282
283        let cli = SourceMap::new();
284
285        let merged = Settings::merge_settings(&defaults, &env, &cli);
286
287        // Env should be used since CLI is empty
288        assert_eq!(
289            merged.age_key_file,
290            Some(std::path::PathBuf::from("/env/key.txt"))
291        );
292        // Default profile should remain
293        assert_eq!(merged.profile, "default");
294    }
295
296    #[test]
297    fn test_expand_path_with_tilde() {
298        // Test tilde expansion
299        let expanded = Settings::expand_path("~/test/path");
300        let home = dirs::home_dir().unwrap();
301        assert_eq!(expanded, home.join("test/path"));
302
303        // Test without tilde (should remain unchanged)
304        let expanded = Settings::expand_path("/absolute/path");
305        assert_eq!(expanded, std::path::PathBuf::from("/absolute/path"));
306    }
307}