1use 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#[must_use]
24#[non_exhaustive]
25#[derive(Debug, Deserialize)]
26pub struct Config {
27 pub format: String,
29 pub timezone: String,
31 pub formats: Option<HashMap<String, String>>,
33}
34
35impl Config {
36 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 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#[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
88fn 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 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}