1mod error;
2mod time_format;
3
4use crate::{
5 Reminder,
6 utils::{TomlFileError, expand_tilde, write_toml},
7};
8pub use error::CaldirConfigError;
9use serde::{Deserialize, Serialize};
10use std::{
11 fmt::Display,
12 path::{Path, PathBuf},
13};
14pub use time_format::TimeFormat;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(default)]
18pub struct CaldirConfig {
19 #[serde(rename = "calendar_dir")] data_dir: PathBuf,
21
22 #[serde(default)]
23 time_format: TimeFormat,
24
25 #[serde(rename = "default_calendar", skip_serializing_if = "Option::is_none")]
26 default_calendar_slug: Option<String>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
29 default_reminders: Option<Vec<Reminder>>,
30}
31
32impl Display for CaldirConfig {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 let toml = self.to_toml().map_err(|_| std::fmt::Error)?;
35 write!(f, "{toml}")
36 }
37}
38
39impl Default for CaldirConfig {
41 fn default() -> Self {
42 Self {
43 data_dir: PathBuf::from("~/caldir"),
44 time_format: TimeFormat::default(),
45 default_calendar_slug: None,
46 default_reminders: None,
47 }
48 }
49}
50
51impl CaldirConfig {
52 pub fn new(
53 data_dir: PathBuf,
54 time_format: TimeFormat,
55 default_calendar_slug: Option<String>,
56 default_reminders: Option<Vec<Reminder>>,
57 ) -> Self {
58 Self {
59 data_dir,
60 time_format,
61 default_calendar_slug,
62 default_reminders,
63 }
64 }
65
66 pub fn load_or_default(path: &Path) -> Result<Self, CaldirConfigError> {
67 if path.is_file() {
68 let config = Self::load(path)?;
69 Ok(config)
70 } else {
71 Ok(Self::default())
72 }
73 }
74
75 pub fn data_dir(&self) -> PathBuf {
76 expand_tilde(&self.data_dir)
77 }
78
79 pub fn time_format(&self) -> TimeFormat {
80 self.time_format
81 }
82
83 pub fn default_calendar_slug(&self) -> Option<&str> {
84 self.default_calendar_slug.as_deref()
85 }
86
87 pub fn set_default_calendar_slug(&mut self, slug: Option<String>) {
88 self.default_calendar_slug = slug;
89 }
90
91 pub fn default_reminders(&self) -> Option<Vec<Reminder>> {
92 self.default_reminders.clone()
93 }
94
95 pub fn set_data_dir(&mut self, path: std::path::PathBuf) {
96 self.data_dir = path;
97 }
98
99 pub fn set_time_format(&mut self, time_format: TimeFormat) {
100 self.time_format = time_format;
101 }
102
103 pub fn set_default_reminders(&mut self, reminders: Option<Vec<Reminder>>) {
104 self.default_reminders = reminders;
105 }
106
107 pub fn write(&self, path: &Path) -> Result<(), CaldirConfigError> {
110 write_toml(path, self).map_err(|err| match err {
111 TomlFileError::Read(err) => CaldirConfigError::Read(path.into(), err),
112 TomlFileError::Write(err) => CaldirConfigError::Write(path.into(), err),
113 TomlFileError::Deserialize(err) => CaldirConfigError::Deserialize(path.into(), err),
114 TomlFileError::Serialize(err) => CaldirConfigError::Serialize(err),
115 TomlFileError::Parse(err) => CaldirConfigError::Parse(path.into(), err),
116 })
117 }
118
119 pub fn default_system_config_path() -> Result<PathBuf, CaldirConfigError> {
124 let dir = Self::platform_config_dir()?.join("caldir");
125
126 Ok(dir.join("config.toml"))
127 }
128
129 fn load(path: &Path) -> Result<Self, CaldirConfigError> {
130 let contents = std::fs::read_to_string(path)
131 .map_err(|err| CaldirConfigError::Read(path.into(), err))?;
132
133 let config = Self::from_toml(&contents)
134 .map_err(|e| CaldirConfigError::Deserialize(path.into(), e))?;
135
136 Ok(config)
137 }
138
139 fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
140 toml::from_str(s)
141 }
142
143 fn to_toml(&self) -> Result<String, toml::ser::Error> {
144 toml::to_string(self)
145 }
146
147 fn platform_config_dir() -> Result<PathBuf, CaldirConfigError> {
148 crate::utils::paths::platform_config_dir().ok_or(CaldirConfigError::UnknownConfigDirectory)
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn default_has_expected_default_data_dir() {
158 let home = home::home_dir().unwrap();
159 let config = CaldirConfig::default();
160
161 assert_eq!(config.data_dir(), home.join("caldir"));
162 }
163
164 #[test]
165 fn load_or_default_uses_default_values_for_empty_file() {
166 let tmp = tempfile::TempDir::new().unwrap();
167 let path = tmp.path().join("config.toml");
168 std::fs::write(&path, "").unwrap();
169
170 let config = CaldirConfig::load_or_default(&path).unwrap();
171
172 assert_eq!(config, CaldirConfig::default());
173 }
174
175 #[test]
176 fn load_or_default_parses_user_file() {
177 let data_dir = "/tmp/my-calendar";
178 let tmp = tempfile::TempDir::new().unwrap();
179 let path = tmp.path().join("config.toml");
180 std::fs::write(
181 &path,
182 format!(
183 r#"
184 calendar_dir = "{data_dir}"
185 time_format = "12h"
186 default_calendar = "personal"
187 "#
188 ),
189 )
190 .unwrap();
191
192 let config = CaldirConfig::load_or_default(&path).unwrap();
193
194 assert_eq!(config.data_dir, PathBuf::from(data_dir));
195 assert_eq!(config.time_format, TimeFormat::H12);
196 assert_eq!(config.default_calendar_slug.as_deref(), Some("personal"));
197 }
198
199 #[test]
200 fn load_or_default_parses_default_reminders_as_human_durations() {
201 let tmp = tempfile::TempDir::new().unwrap();
202 let path = tmp.path().join("config.toml");
203 std::fs::write(
204 &path,
205 r#"
206 default_reminders = [
207 "30m",
208 "1h",
209 ]
210 "#,
211 )
212 .unwrap();
213
214 let config = CaldirConfig::load_or_default(&path).unwrap();
215
216 assert_eq!(
217 config.default_reminders,
218 Some(vec![Reminder::from_minutes(30), Reminder::from_minutes(60),])
219 );
220 }
221
222 #[test]
223 fn load_or_default_returns_default_on_missing_file() {
224 let tmp = tempfile::TempDir::new().unwrap();
225 let path = tmp.path().join("nonexistent.toml");
226
227 let config = CaldirConfig::load_or_default(&path).unwrap();
228
229 assert_eq!(config, CaldirConfig::default());
230 }
231
232 #[test]
233 fn load_or_default_errors_on_invalid_file() {
234 let tmp = tempfile::TempDir::new().unwrap();
235 let path = tmp.path().join("invalid.toml");
236 std::fs::write(&path, "not a valid toml").unwrap();
237
238 let result = CaldirConfig::load_or_default(&path);
239
240 assert!(matches!(
241 result.unwrap_err(),
242 CaldirConfigError::Deserialize(_, _)
243 ));
244 }
245
246 #[test]
247 #[cfg(target_os = "macos")]
248 fn default_system_path_uses_home_dot_config_on_macos() {
249 let home = PathBuf::from(std::env::var("HOME").unwrap());
250 let path = CaldirConfig::default_system_config_path().unwrap();
251 assert_eq!(path, home.join(".config/caldir/config.toml"));
252 }
253
254 #[test]
255 #[cfg(target_os = "linux")]
256 fn default_system_path_uses_xdg_config_dir_on_linux() {
257 let expected_parent = std::env::var("XDG_CONFIG_HOME")
258 .map(PathBuf::from)
259 .unwrap_or_else(|_| PathBuf::from(std::env::var("HOME").unwrap()).join(".config"));
260
261 let path = CaldirConfig::default_system_config_path().unwrap();
262
263 assert_eq!(path, expected_parent.join("caldir/config.toml"));
264 }
265
266 #[test]
267 #[cfg(target_os = "windows")]
268 fn default_system_path_uses_appdata_on_windows() {
269 let appdata = PathBuf::from(std::env::var("APPDATA").unwrap());
270 let path = CaldirConfig::default_system_config_path().unwrap();
271 assert_eq!(path, appdata.join("caldir/config.toml"));
272 }
273
274 #[test]
275 #[cfg(not(target_os = "windows"))]
276 fn default_system_config_path_is_dot_config_caldir_config_toml() {
277 let path = CaldirConfig::default_system_config_path().unwrap();
278 let expected_path = expand_tilde(&PathBuf::from("~/.config/caldir/config.toml"));
279
280 assert_eq!(path, expected_path);
281 }
282
283 #[test]
284 fn write_then_load_round_trips_through_disk() {
285 let tmp = tempfile::TempDir::new().unwrap();
286 let path = tmp.path().join("config.toml");
287
288 let config = CaldirConfig::new(
289 PathBuf::from("/tmp/round-trip"),
290 TimeFormat::H12,
291 Some("personal".to_string()),
292 Some(vec![Reminder::from_minutes(15)]),
293 );
294
295 config.write(&path).unwrap();
296
297 let reloaded = CaldirConfig::load_or_default(&path).unwrap();
298 assert_eq!(reloaded, config);
299 }
300
301 #[test]
302 fn write_preserves_existing_formatting_and_sparse_defaults() {
303 let tmp = tempfile::TempDir::new().unwrap();
304 let path = tmp.path().join("config.toml");
305 let before = "# caldir settings\ncalendar_dir = '/tmp/old' # local path\ndefault_reminders = [ \"60m\" ] # spelling\nunknown = 'kept'\n";
306 std::fs::write(&path, before).unwrap();
307 let mut config = CaldirConfig::load_or_default(&path).unwrap();
308 config.set_data_dir(PathBuf::from("/tmp/new"));
309
310 config.write(&path).unwrap();
311
312 assert_eq!(
313 std::fs::read_to_string(path).unwrap(),
314 "# caldir settings\ncalendar_dir = \"/tmp/new\" # local path\ndefault_reminders = [ \"60m\" ] # spelling\nunknown = 'kept'\n"
315 );
316 }
317
318 #[test]
319 fn write_creates_missing_parent_directories() {
320 let tmp = tempfile::TempDir::new().unwrap();
325 let path = tmp.path().join("nested").join("dir").join("config.toml");
326
327 let config = CaldirConfig::default();
328 config.write(&path).unwrap();
329
330 assert!(path.is_file(), "config file should exist after write");
331 }
332}