Skip to main content

caldir_core/caldir/
config.rs

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")] // preserved for backwards-compatibility
20    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
39// Default config values (if empty file):
40impl 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    /// Writes the config while preserving comments and unrelated formatting in
108    /// an existing file.
109    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    /// Caldir config directory:
120    /// - Linux/BSD: `$XDG_CONFIG_HOME/caldir` or `~/.config/caldir`
121    /// - macOS: `~/.config/caldir`
122    /// - Windows: `%APPDATA%\caldir`
123    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        // The system config path is ~/.config/caldir/config.toml — and on a
321        // fresh machine the `caldir/` directory doesn't exist yet. write()
322        // needs to be tolerant of that, otherwise first-run `caldir connect`
323        // can't persist its choice of default_calendar.
324        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}