Skip to main content

autopulse_service/settings/
mod.rs

1use 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
18/// App-specific settings
19///
20/// Example:
21///
22/// ```yml
23/// app:
24///   hostname: 0.0.0.0
25///   port: 1234
26///   database_url: sqlite://autopulse.db
27///   log_level: debug
28/// ```
29pub mod app;
30
31/// Authentication settings
32///
33/// Example:
34///
35/// ```yml
36/// auth:
37///   username: terry
38///   password: yogurt
39/// ```
40pub mod auth;
41
42/// Global settings
43///
44/// Example:
45///
46/// ```yml
47/// opts:
48///   check_path: true
49///   max_retries: 10
50///   default_timer_wait: 300
51///   cleanup_days: 7
52/// ```
53pub mod opts;
54
55/// Path-level include/exclude filters for triggers and targets.
56pub mod path_filter;
57
58/// Rewrite structure for triggers
59///
60/// Example:
61///
62/// ```yml
63/// triggers:
64///   sonarr:
65///     type: sonarr
66///     rewrite:
67///       from: /tv
68///       to: /media/tv
69pub use autopulse_utils::rewrite;
70
71/// Timer structure for triggers
72///
73/// Example:
74///
75/// ```yml
76/// triggers:
77///  sonarr:
78///   type: sonarr
79///   timer:
80///    wait: 300 # wait 5 minutes before processing
81/// ```
82pub mod timer;
83
84/// Trigger structure
85///
86/// [Triggers](triggers) for all triggers
87pub mod triggers;
88
89/// Target structure
90///
91/// [Targets](targets) for all targets
92pub mod targets;
93
94/// Webhook structure
95///
96/// [Webhooks](webhooks) for all webhooks
97pub 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    /// List of paths to anchor the service to
131    ///
132    /// This is useful to prevent the service notifying a target when the drive is not mounted or visible
133    /// The contents of the file/directory are not tampered with, only the presence of the file/directory is checked
134    ///
135    /// Example:
136    /// ```yml
137    /// anchors:
138    ///  - /mnt/media/tv # Directory
139    ///  - /mnt/media/anchor # File
140    /// ```
141    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    /// Candidate filenames probed when no explicit `--config` is given.
213    /// Order is significant — the first match wins. The set is what
214    /// figment's bundled providers handle natively; json5/ron/ini are
215    /// not supported (dropped from the prior `config` crate setup).
216    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        // Explicit --config path must exist. Silent fall-through to defaults
270        // when the user told us exactly where the file is would be the same
271        // bug class as #435.
272        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        // Env overrides file (last-write-wins).
301        fig = fig.merge(
302            Env::prefixed("AUTOPULSE__")
303                .filter(|key| !key.as_str().ends_with("__FILE"))
304                .split("__"),
305        );
306        // File-secret env vars override direct env vars, matching the old
307        // config source behavior without mutating the process environment.
308        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    /// Emits an INFO-level summary of the effective config shape at startup.
328    /// Just counts — keeps the line short and confirms the parse worked.
329    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}