1use arc_swap::ArcSwap;
11use miette::Result;
12use std::sync::Arc;
13use std::sync::{LazyLock, Mutex};
14
15mod 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
34static GLOBAL_SETTINGS: LazyLock<ArcSwap<GeneratedSettings>> =
36 LazyLock::new(|| ArcSwap::from_pointee(GeneratedSettings::default()));
37
38static INITIALIZED: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
40
41#[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
52pub struct Settings;
54
55impl Settings {
56 pub fn get() -> Arc<GeneratedSettings> {
58 Self::try_get().expect("Failed to load configuration")
59 }
60
61 pub fn try_get() -> Result<Arc<GeneratedSettings>> {
63 Self::get_snapshot()
64 }
65
66 fn get_snapshot() -> Result<SettingsSnapshot> {
67 let mut initialized = INITIALIZED.lock().unwrap();
69 if !*initialized {
70 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); Ok(GLOBAL_SETTINGS.load_full())
80 }
81
82 pub fn set_cli_snapshot(snapshot: CliSnapshot) {
84 *CLI_SNAPSHOT.lock().unwrap() = Some(snapshot);
85 }
86
87 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 fn expand_path(path: &str) -> std::path::PathBuf {
98 shellexpand::tilde(path).into_owned().into()
99 }
100
101 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 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 }
133 }
134 break; }
136 }
137 }
138
139 Ok(map)
140 }
141
142 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 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 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 for (name, _meta) in SETTINGS_META.iter() {
196 let field = *name;
197
198 if let Some(sv) = env.get(field) {
200 set_value(&mut val, field, sv);
201 }
202
203 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 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 assert_eq!(
289 merged.age_key_file,
290 Some(std::path::PathBuf::from("/env/key.txt"))
291 );
292 assert_eq!(merged.profile, "default");
294 }
295
296 #[test]
297 fn test_expand_path_with_tilde() {
298 let expanded = Settings::expand_path("~/test/path");
300 let home = dirs::home_dir().unwrap();
301 assert_eq!(expanded, home.join("test/path"));
302
303 let expanded = Settings::expand_path("/absolute/path");
305 assert_eq!(expanded, std::path::PathBuf::from("/absolute/path"));
306 }
307}