Skip to main content

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