1use 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#[derive(Debug, Deserialize)]
25pub struct Config {
26 pub format: String,
28 pub timezone: String,
30 pub formats: Option<HashMap<String, String>>,
32}
33
34impl Config {
35 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 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
66fn 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
81fn 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 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}