autopulse_service/settings/
mod.rs1use anyhow::Context;
2use app::App;
3use auth::Auth;
4use figment::{
5 providers::{Env, Format, Json, Serialized, Toml, Yaml},
6 Figment,
7};
8use opts::Opts;
9use serde::{Deserialize, Serialize};
10use std::env;
11use std::path::Path;
12use std::{collections::HashMap, path::PathBuf};
13use targets::Target;
14use triggers::manual::Manual;
15use triggers::Trigger;
16use webhooks::Webhook;
17
18pub mod app;
30
31pub mod auth;
41
42pub mod opts;
54
55pub mod path_filter;
57
58pub use autopulse_utils::rewrite;
70
71pub mod timer;
83
84pub mod triggers;
88
89pub mod targets;
93
94pub mod webhooks;
98
99#[doc(hidden)]
100pub fn default_triggers() -> HashMap<String, Trigger> {
101 let mut triggers = HashMap::new();
102
103 triggers.insert(
104 "manual".to_string(),
105 Trigger::Manual(Manual {
106 rewrite: None,
107 timer: None,
108 excludes: vec![],
109 filter: Default::default(),
110 }),
111 );
112
113 triggers
114}
115
116#[derive(Serialize, Deserialize, Clone)]
117#[serde(default)]
118pub struct Settings {
119 pub app: App,
120
121 pub auth: Auth,
122
123 pub opts: Opts,
124
125 pub triggers: HashMap<String, Trigger>,
126 pub targets: HashMap<String, Target>,
127
128 pub webhooks: HashMap<String, Webhook>,
129
130 pub anchors: Vec<PathBuf>,
142}
143
144pub struct LoadedSettings {
145 pub settings: Settings,
146 pub diagnostics: Vec<ConfigDiagnostic>,
147}
148
149impl LoadedSettings {
150 pub fn log_diagnostics(&self) {
151 for diagnostic in &self.diagnostics {
152 diagnostic.log();
153 }
154 }
155}
156
157pub enum ConfigDiagnostic {
158 LoadedFile(PathBuf),
159 LoadedFileEnv {
160 count: usize,
161 },
162 MissingConfig {
163 cwd: PathBuf,
164 searched: Vec<PathBuf>,
165 },
166}
167
168impl ConfigDiagnostic {
169 fn log(&self) {
170 match self {
171 Self::LoadedFile(path) => {
172 tracing::info!(target: "autopulse", "loaded config from {}", path.display());
173 }
174 Self::LoadedFileEnv { count } => {
175 tracing::info!(
176 target: "autopulse",
177 "loaded {count} config override(s) from AUTOPULSE__...__FILE"
178 );
179 }
180 Self::MissingConfig { cwd, searched } => {
181 tracing::warn!(
182 target: "autopulse",
183 "no config file found in {}. Searched: {:?}. \
184 Using defaults + environment overrides only. \
185 Pass --config /path/to/config.toml or place one of the candidate files in the cwd.",
186 cwd.display(),
187 searched
188 .iter()
189 .map(|p| p.display().to_string())
190 .collect::<Vec<_>>(),
191 );
192 }
193 }
194 }
195}
196
197impl Default for Settings {
198 fn default() -> Self {
199 Self {
200 app: App::default(),
201 auth: Auth::default(),
202 opts: Opts::default(),
203 triggers: default_triggers(),
204 targets: HashMap::new(),
205 webhooks: HashMap::new(),
206 anchors: vec![],
207 }
208 }
209}
210
211impl Settings {
212 pub const CONFIG_CANDIDATES: &'static [&'static str] =
217 &["config.toml", "config.yaml", "config.yml", "config.json"];
218
219 pub fn resolved_config_path(cwd: &Path) -> Option<PathBuf> {
220 Self::CONFIG_CANDIDATES
221 .iter()
222 .map(|name| cwd.join(name))
223 .find(|p| p.is_file())
224 }
225
226 pub fn searched_paths(cwd: &Path) -> Vec<PathBuf> {
227 Self::CONFIG_CANDIDATES
228 .iter()
229 .map(|n| cwd.join(n))
230 .collect()
231 }
232
233 fn file_env_overrides_from(
234 vars: impl IntoIterator<Item = (String, String)>,
235 ) -> anyhow::Result<Vec<(String, String)>> {
236 const PREFIX: &str = "AUTOPULSE__";
237 const SUFFIX: &str = "__FILE";
238
239 vars.into_iter()
240 .filter_map(|(key, path)| {
241 let key_path = key
242 .strip_prefix(PREFIX)?
243 .strip_suffix(SUFFIX)?
244 .replace("__", ".")
245 .to_ascii_lowercase();
246 Some((key, key_path, path))
247 })
248 .map(|(key, key_path, path)| {
249 let contents = std::fs::read_to_string(&path)
250 .with_context(|| format!("failed to read file referenced by {key}: {path}"))?;
251
252 Ok((key_path, contents.trim().to_string()))
253 })
254 .collect()
255 }
256
257 fn file_env_overrides() -> anyhow::Result<Vec<(String, String)>> {
258 Self::file_env_overrides_from(env::vars())
259 }
260
261 pub fn get_settings(optional_config_file: Option<String>) -> anyhow::Result<LoadedSettings> {
262 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
263
264 let explicit = optional_config_file.is_some();
265 let chosen = optional_config_file
266 .map(PathBuf::from)
267 .or_else(|| Self::resolved_config_path(&cwd));
268
269 if explicit {
273 if let Some(p) = &chosen {
274 if !p.is_file() {
275 anyhow::bail!("--config {} does not exist", p.display());
276 }
277 }
278 }
279
280 let mut diagnostics = vec![];
281 let mut fig = Figment::new();
282 if let Some(p) = chosen.as_deref() {
283 fig = match p.extension().and_then(|s| s.to_str()) {
284 Some("toml") => fig.merge(Toml::file(p)),
285 Some("yaml") | Some("yml") => fig.merge(Yaml::file(p)),
286 Some("json") => fig.merge(Json::file(p)),
287 other => anyhow::bail!(
288 "unsupported config format {:?} for {} (supported: toml, yaml, yml, json)",
289 other.unwrap_or(""),
290 p.display()
291 ),
292 };
293 diagnostics.push(ConfigDiagnostic::LoadedFile(p.to_path_buf()));
294 } else if !explicit {
295 diagnostics.push(ConfigDiagnostic::MissingConfig {
296 cwd: cwd.clone(),
297 searched: Self::searched_paths(&cwd),
298 });
299 }
300 fig = fig.merge(
302 Env::prefixed("AUTOPULSE__")
303 .filter(|key| !key.as_str().ends_with("__FILE"))
304 .split("__"),
305 );
306 let file_overrides = Self::file_env_overrides()?;
309 if !file_overrides.is_empty() {
310 diagnostics.push(ConfigDiagnostic::LoadedFileEnv {
311 count: file_overrides.len(),
312 });
313 }
314 for (key, value) in file_overrides {
315 fig = fig.merge(Serialized::default(&key, value));
316 }
317
318 let mut settings: Self = fig.extract().map_err(|e| anyhow::anyhow!(e))?;
319 settings.normalize()?;
320
321 Ok(LoadedSettings {
322 settings,
323 diagnostics,
324 })
325 }
326
327 pub fn log_summary(&self) {
330 tracing::info!(
331 target: "autopulse",
332 "effective config: triggers={} targets={} webhooks={} anchors={}",
333 self.triggers.len(),
334 self.targets.len(),
335 self.webhooks.len(),
336 self.anchors.len(),
337 );
338 }
339
340 pub fn normalize(&mut self) -> anyhow::Result<()> {
341 self.add_default_manual_trigger()?;
342
343 Ok(())
344 }
345
346 pub fn add_default_manual_trigger(&mut self) -> anyhow::Result<()> {
347 if !self.triggers.contains_key("manual") {
348 self.triggers.insert(
349 "manual".to_string(),
350 Trigger::Manual(Manual {
351 rewrite: None,
352 timer: None,
353 excludes: vec![],
354 filter: Default::default(),
355 }),
356 );
357 }
358
359 Ok(())
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::io::Write;
367
368 #[test]
369 fn resolved_config_path_picks_toml_when_present() {
370 let dir = tempfile::tempdir().unwrap();
371 let p = dir.path().join("config.toml");
372 std::fs::File::create(&p).unwrap().write_all(b"").unwrap();
373 assert_eq!(Settings::resolved_config_path(dir.path()), Some(p));
374 }
375
376 #[test]
377 fn resolved_config_path_returns_none_when_empty_dir() {
378 let dir = tempfile::tempdir().unwrap();
379 assert!(Settings::resolved_config_path(dir.path()).is_none());
380 }
381
382 #[test]
383 fn resolved_config_path_prefers_toml_over_yaml() {
384 let dir = tempfile::tempdir().unwrap();
385 std::fs::File::create(dir.path().join("config.yaml")).unwrap();
386 std::fs::File::create(dir.path().join("config.toml")).unwrap();
387 assert_eq!(
388 Settings::resolved_config_path(dir.path())
389 .unwrap()
390 .file_name()
391 .unwrap(),
392 "config.toml"
393 );
394 }
395
396 #[test]
397 fn file_env_overrides_read_autopulse_secret_files() {
398 let dir = tempfile::tempdir().unwrap();
399 let secret = dir.path().join("password");
400 std::fs::write(&secret, "secret\n").unwrap();
401
402 let vars = vec![
403 (
404 "AUTOPULSE__AUTH__PASSWORD__FILE".to_string(),
405 secret.display().to_string(),
406 ),
407 ("OTHER__PASSWORD__FILE".to_string(), "ignored".to_string()),
408 ];
409
410 let overrides = Settings::file_env_overrides_from(vars).unwrap();
411
412 assert_eq!(
413 overrides,
414 vec![("auth.password".to_string(), "secret".to_string())]
415 );
416 }
417
418 #[test]
419 fn file_env_overrides_key_paths_deserialize_into_settings() {
420 let dir = tempfile::tempdir().unwrap();
421 let secret = dir.path().join("password");
422 std::fs::write(&secret, "secret\n").unwrap();
423
424 let overrides = Settings::file_env_overrides_from(vec![(
425 "AUTOPULSE__AUTH__PASSWORD__FILE".to_string(),
426 secret.display().to_string(),
427 )])
428 .unwrap();
429 let mut fig = Figment::new();
430 for (key, value) in overrides {
431 fig = fig.merge(Serialized::default(&key, value));
432 }
433
434 let settings = fig.extract::<Settings>().unwrap();
435
436 assert_eq!(settings.auth.password, "secret");
437 }
438
439 #[test]
440 fn file_env_overrides_error_when_secret_file_cannot_be_read() {
441 let vars = vec![(
442 "AUTOPULSE__AUTH__PASSWORD__FILE".to_string(),
443 "/tmp/missing-autopulse-secret".to_string(),
444 )];
445
446 let err = Settings::file_env_overrides_from(vars).unwrap_err();
447
448 assert!(
449 err.to_string()
450 .contains("failed to read file referenced by AUTOPULSE__AUTH__PASSWORD__FILE"),
451 "{err:?}"
452 );
453 }
454
455 #[test]
456 fn empty_settings_deserialize_like_rust_default() {
457 let settings: Settings = serde_json::from_str("{}").expect("empty settings should load");
458 let default = Settings::default();
459
460 assert_eq!(
461 serde_json::to_value(&settings).expect("settings serialize"),
462 serde_json::to_value(&default).expect("default settings serialize")
463 );
464 assert_eq!(settings.auth.enabled, default.auth.enabled);
465 assert!(matches!(
466 settings.triggers.get("manual"),
467 Some(Trigger::Manual(_))
468 ));
469 }
470
471 #[test]
472 fn normalize_adds_manual_trigger_to_present_empty_trigger_map() {
473 let mut settings: Settings =
474 serde_json::from_str(r#"{"triggers":{}}"#).expect("settings should load");
475
476 assert!(!settings.triggers.contains_key("manual"));
477
478 settings.normalize().expect("settings should normalize");
479
480 assert!(matches!(
481 settings.triggers.get("manual"),
482 Some(Trigger::Manual(_))
483 ));
484 }
485}