Skip to main content

finance_core/
settings.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Settings — shared user preferences for the accounting suite.
3//
4// One config.toml at `Paths::config_file()` is read by every tool. Shared
5// fields like `default_issuer` let `invoice new`, `receipt add`, and
6// `ledger post` all default to the same company without reconfiguring.
7// Tool-specific fields (default_template for invoice PDFs, etc.) live at
8// the top level and are simply ignored by tools that don't care about them —
9// TOML's "unknown key" tolerance via serde is perfect for that.
10// ═══════════════════════════════════════════════════════════════════════════
11
12use std::path::Path;
13
14use figment::{
15    providers::{Env, Format, Serialized, Toml},
16    Figment,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::error::{CoreError, Result};
21use crate::paths::Paths;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct Settings {
25    /// Slug of the issuer (your company) to default to across the suite.
26    /// `invoice new` uses it when --as is omitted; future tools honour it too.
27    #[serde(default)]
28    pub default_issuer: Option<String>,
29
30    /// Default PDF / output template name. Honoured by tools that produce
31    /// rendered artifacts (invoice-cli, future receipt-cli).
32    #[serde(default = "default_template")]
33    pub default_template: String,
34
35    /// Whether to auto-open produced PDFs in the system viewer after render.
36    #[serde(default = "default_true")]
37    pub open_pdf: bool,
38
39    /// Whether self-updating is enabled. Each tool's `update` subcommand
40    /// respects this.
41    #[serde(default = "default_true")]
42    pub self_update: bool,
43}
44
45fn default_template() -> String {
46    "vienna".to_string()
47}
48
49fn default_true() -> bool {
50    true
51}
52
53impl Default for Settings {
54    fn default() -> Self {
55        Self {
56            default_issuer: None,
57            default_template: default_template(),
58            open_pdf: true,
59            self_update: true,
60        }
61    }
62}
63
64impl Settings {
65    /// Load settings from the shared config file + environment overrides.
66    /// Missing file → returns `Default` with env applied. Env prefix is
67    /// `PAPERFOOT_` (e.g. `PAPERFOOT_DEFAULT_ISSUER=acme`).
68    pub fn load(paths: &Paths) -> Result<Self> {
69        Self::load_from(&paths.config_file())
70    }
71
72    pub fn load_from(path: &Path) -> Result<Self> {
73        let settings: Settings = Figment::from(Serialized::defaults(Settings::default()))
74            .merge(Toml::file(path))
75            .merge(Env::prefixed("PAPERFOOT_"))
76            .extract()
77            .map_err(|e| CoreError::Config(format!("{e}")))?;
78        Ok(settings)
79    }
80
81    /// Persist settings to the shared config file.
82    pub fn save(&self, paths: &Paths) -> Result<()> {
83        self.save_to(&paths.config_file())
84    }
85
86    pub fn save_to(&self, path: &Path) -> Result<()> {
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent)?;
89        }
90        let serialized = toml::to_string_pretty(self)?;
91        std::fs::write(path, serialized)?;
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use tempfile::TempDir;
100
101    #[test]
102    fn defaults_when_missing() {
103        let tmp = TempDir::new().unwrap();
104        let path = tmp.path().join("config.toml");
105        let s = Settings::load_from(&path).unwrap();
106        assert_eq!(s.default_template, "vienna");
107        assert!(s.open_pdf);
108        assert!(s.self_update);
109        assert!(s.default_issuer.is_none());
110    }
111
112    #[test]
113    fn roundtrip_through_toml() {
114        let tmp = TempDir::new().unwrap();
115        let path = tmp.path().join("config.toml");
116        let written = Settings {
117            default_issuer: Some("acme".into()),
118            default_template: "boutique".into(),
119            open_pdf: false,
120            self_update: true,
121        };
122        written.save_to(&path).unwrap();
123        let read = Settings::load_from(&path).unwrap();
124        assert_eq!(read.default_issuer.as_deref(), Some("acme"));
125        assert_eq!(read.default_template, "boutique");
126        assert!(!read.open_pdf);
127        assert!(read.self_update);
128    }
129}