tardis_cli/
config.rs

1//! Configuration loading and helpers for **TARDIS**.
2//!
3//! * Reads `config.toml` from the user-specific config directory
4//!   (`$XDG_CONFIG_HOME/tardis` or OS default).
5//! * Overlays values from environment variables prefixed with **`TARDIS_`**.
6//! * Automatically bootstraps the file from an embedded template on first run.
7
8use std::{
9    collections::HashMap,
10    env, fs,
11    path::{Path, PathBuf},
12};
13
14use config::{Environment, File};
15use serde::Deserialize;
16
17use crate::{Error, Result, core::Preset, errors::SystemError, system_error};
18
19const APP_DIR: &str = "tardis";
20const CONFIG_FILE: &str = "config.toml";
21const TEMPLATE: &str = include_str!("../assets/config_template.toml");
22
23/// In-memory representation of the user configuration.
24#[derive(Debug, Deserialize)]
25pub struct Config {
26    /// Default output format (ISO-8601 by default).
27    pub format: String,
28    /// Time-zone identifier recognised by `chrono-tz` (e.g. `"America/Sao_Paulo"`).
29    pub timezone: String,
30    /// User-defined named formats.
31    pub formats: Option<HashMap<String, String>>,
32}
33
34impl Config {
35    /// Load the effective configuration, creating the file from the embedded
36    /// template if it does not yet exist.
37    pub fn load() -> Result<Self> {
38        let path = config_path()?;
39        create_config_if_missing(&path)?;
40
41        config::Config::builder()
42            .add_source(File::from(path))
43            .add_source(
44                Environment::with_prefix("TARDIS")
45                    .separator("_")
46                    .ignore_empty(true),
47            )
48            .build()?
49            .try_deserialize()
50            .map_err(Into::into)
51    }
52
53    /// Convert the `[formats]` table into a list of [`Preset`]s.
54    pub fn presets(&self) -> Vec<Preset> {
55        self.formats
56            .as_ref()
57            .map(|m| {
58                m.iter()
59                    .map(|(name, fmt)| Preset::new(name.clone(), fmt.clone()))
60                    .collect()
61            })
62            .unwrap_or_default()
63    }
64}
65
66/// Resolve the absolute path to `config.toml`.
67fn config_path() -> Result<PathBuf> {
68    let base_dir = env::var_os("XDG_CONFIG_HOME")
69        .map(PathBuf::from)
70        .or_else(dirs::config_dir)
71        .ok_or_else(|| {
72            system_error!(
73                Config,
74                "Could not locate configuration directory; set $XDG_CONFIG_HOME or ensure the OS default exists."
75            )
76        })?;
77
78    Ok(base_dir.join(APP_DIR).join(CONFIG_FILE))
79}
80
81/// Create the configuration file (and parent directory) if it is missing.
82fn create_config_if_missing(path: &Path) -> Result<()> {
83    if path.exists() {
84        return Ok(());
85    }
86
87    if let Some(parent) = path.parent() {
88        fs::create_dir_all(parent)?;
89    }
90
91    fs::write(path, TEMPLATE.trim_start())?;
92    Ok(())
93}
94
95impl From<std::io::Error> for Error {
96    fn from(e: std::io::Error) -> Self {
97        Error::System(SystemError::Io(e))
98    }
99}
100
101impl From<config::ConfigError> for Error {
102    fn from(e: config::ConfigError) -> Self {
103        system_error!(Config, "{}", e)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    #![allow(clippy::expect_used)]
110    use super::*;
111    use assert_fs::{TempDir, prelude::*};
112    use serial_test::serial;
113    use std::{env, ffi::OsString, fs};
114
115    struct EnvGuard {
116        key: &'static str,
117        prior: Option<OsString>,
118    }
119
120    impl EnvGuard {
121        /// Set env var to `value`, returning a guard that restores it later.
122        fn set(key: &'static str, value: impl Into<OsString>) -> Self {
123            let prior = env::var_os(key);
124
125            unsafe { env::set_var(key, value.into()) };
126            Self { key, prior }
127        }
128    }
129
130    impl Drop for EnvGuard {
131        fn drop(&mut self) {
132            match &self.prior {
133                Some(val) => unsafe { env::set_var(self.key, val) },
134                None => unsafe { env::remove_var(self.key) },
135            }
136        }
137    }
138
139    fn write_config(tmp: &TempDir, contents: &str) {
140        let dir = tmp.child("tardis");
141        dir.create_dir_all().unwrap();
142        dir.child("config.toml").write_str(contents).unwrap();
143    }
144
145    #[test]
146    #[serial]
147    fn config_path_respects_xdg_config_home() {
148        let tmp = TempDir::new().unwrap();
149        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
150
151        let path = super::config_path().expect("path resolution failed");
152        assert!(path.starts_with(tmp.path()));
153        assert!(path.ends_with("tardis/config.toml"));
154    }
155
156    #[test]
157    #[serial]
158    fn load_creates_file_if_missing() {
159        let tmp = TempDir::new().unwrap();
160        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
161
162        let cfg_path = super::config_path().unwrap();
163        assert!(!cfg_path.exists());
164
165        let cfg = Config::load().expect("load must succeed");
166        assert!(cfg_path.exists());
167        let contents = fs::read_to_string(&cfg_path).unwrap();
168        assert!(!contents.is_empty(), "template should be written");
169        assert!(!cfg.format.is_empty());
170        assert!(cfg.timezone.is_empty());
171    }
172
173    #[test]
174    #[serial]
175    fn load_reads_existing_file() {
176        let tmp = TempDir::new().unwrap();
177        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
178
179        write_config(
180            &tmp,
181            r#"
182format   = "%Y"
183timezone = "UTC"
184
185[formats]
186short = "%H:%M"
187"#,
188        );
189        let cfg = Config::load().unwrap();
190        assert_eq!(cfg.format, "%Y");
191        assert_eq!(cfg.timezone, "UTC");
192        assert_eq!(cfg.presets().len(), 1);
193        assert_eq!(cfg.presets()[0].name, "short");
194    }
195
196    #[test]
197    #[serial]
198    fn env_vars_override_config_file() {
199        let tmp = TempDir::new().unwrap();
200        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
201        write_config(
202            &tmp,
203            r#"
204        format = "%Y"
205        timezone = "UTC"
206        "#,
207        );
208
209        let _fmt = EnvGuard::set("TARDIS_FORMAT", "%d");
210
211        let cfg = Config::load().unwrap();
212        assert_eq!(cfg.format, "%d");
213    }
214
215    #[test]
216    #[serial]
217    fn blank_env_var_is_ignored() {
218        let tmp = TempDir::new().unwrap();
219        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
220        write_config(
221            &tmp,
222            r#"
223        format = "%d"
224        timezone = "UTC"
225        "#,
226        );
227
228        let _tz = EnvGuard::set("TARDIS_TIMEZONE", "");
229
230        let cfg = Config::load().unwrap();
231        assert_eq!(cfg.timezone, "UTC");
232    }
233
234    #[test]
235    fn presets_conversion_from_formats_table() {
236        let cfg = Config {
237            format: "%Y".into(),
238            timezone: "UTC".into(),
239            formats: Some(
240                [
241                    ("iso".to_string(), "%Y-%m-%d".to_string()),
242                    ("time".to_string(), "%H:%M".to_string()),
243                ]
244                .into_iter()
245                .collect(),
246            ),
247        };
248        let presets = cfg.presets();
249        assert_eq!(presets.len(), 2);
250        assert!(presets.iter().any(|p| p.name == "iso"));
251        assert!(presets.iter().any(|p| p.format == "%H:%M"));
252    }
253
254    #[test]
255    fn presets_empty_when_none() {
256        let cfg = Config {
257            format: "%Y".into(),
258            timezone: "UTC".into(),
259            formats: None,
260        };
261        assert!(cfg.presets().is_empty());
262    }
263
264    #[test]
265    #[serial]
266    fn load_fails_on_invalid_toml() {
267        let tmp = TempDir::new().unwrap();
268        let _home = EnvGuard::set("XDG_CONFIG_HOME", tmp.path());
269        write_config(&tmp, "not toml at all");
270
271        assert!(Config::load().is_err());
272    }
273
274    #[test]
275    fn create_config_is_noop_if_file_exists() {
276        let tmp = TempDir::new().unwrap();
277        let file = tmp.child("config.toml");
278        file.write_str("format=\"%Y\"").unwrap();
279
280        let before = fs::read_to_string(&file).unwrap();
281        super::create_config_if_missing(file.path()).unwrap();
282        let after = fs::read_to_string(&file).unwrap();
283        assert_eq!(before, after);
284    }
285}