Skip to main content

aimcal_core/
config.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5#![allow(
6    clippy::indexing_slicing,
7    clippy::format_push_string,
8    clippy::missing_panics_doc
9)]
10
11use std::collections::HashMap;
12use std::error::Error;
13use std::path::{Path, PathBuf};
14
15use crate::{DateTimeAnchor, Priority};
16use aimcal_caldav::AuthMethod;
17
18/// The name of the AIM application.
19pub const APP_NAME: &str = "aim";
20
21fn default_timeout_secs() -> u64 {
22    30
23}
24
25fn default_user_agent() -> String {
26    "aimcal/0.11.0".to_string()
27}
28
29/// Store definition for shared connection configuration.
30///
31/// Stores define how to connect to a calendar storage. Multiple calendars
32/// can reference the same store, avoiding duplication of connection details.
33#[derive(Debug, Clone, serde::Deserialize)]
34#[serde(tag = "type")]
35pub enum StoreDef {
36    /// Local ICS file store.
37    #[serde(rename = "local")]
38    Local {
39        /// Path to the calendar directory for ICS files.
40        calendar_path: Option<String>,
41    },
42    /// `CalDAV` server store.
43    #[serde(rename = "caldav")]
44    Caldav {
45        /// Base URL of the `CalDAV` server.
46        base_url: String,
47        /// Calendar home path on the server.
48        calendar_home: String,
49        /// Authentication method.
50        auth: AuthMethod,
51        /// Request timeout in seconds.
52        #[serde(default = "default_timeout_secs")]
53        timeout_secs: u64,
54        /// User agent string for HTTP requests.
55        #[serde(default = "default_user_agent")]
56        user_agent: String,
57    },
58}
59
60/// Calendar entry in the TOML configuration.
61///
62/// Each calendar references a store by ID and provides calendar-specific fields
63/// such as the calendar href (for `CalDAV`) or an optional path override (for local).
64#[derive(Debug, Clone, serde::Deserialize)]
65pub struct CalendarEntry {
66    /// Unique calendar identifier.
67    pub id: String,
68    /// Display name for the calendar.
69    pub name: String,
70    /// Reference to a store definition in `stores`.
71    pub store: String,
72    /// Href of the calendar collection on the server (required for caldav stores).
73    pub calendar_href: Option<String>,
74    /// Path to the calendar directory (optional override for local backends).
75    pub calendar_path: Option<String>,
76    /// Priority for conflict resolution and display ordering.
77    #[serde(default)]
78    pub priority: i32,
79    /// Whether the calendar is enabled.
80    #[serde(default = "default_enabled")]
81    pub enabled: bool,
82}
83
84fn default_enabled() -> bool {
85    true
86}
87
88/// Configuration for the AIM application.
89#[derive(Debug, Clone, serde::Deserialize)]
90pub struct Config {
91    /// Path to the calendar directory (optional ICS export/import).
92    ///
93    /// Deprecated: Use `calendars` array instead.
94    ///
95    /// If set, AIM will sync events/todos with ICS files in this directory.
96    /// If not set, AIM will only use the `Db` for storage.
97    #[serde(default)]
98    pub calendar_path: Option<PathBuf>,
99
100    /// Directory for storing application state.
101    #[serde(default)]
102    pub state_dir: Option<PathBuf>,
103
104    /// Default due time for new tasks.
105    #[serde(default)]
106    pub default_due: Option<DateTimeAnchor>,
107
108    /// Default priority for new tasks.
109    #[serde(default)]
110    pub default_priority: Priority,
111
112    /// If true, items with no priority will be listed first.
113    #[serde(default)]
114    pub default_priority_none_fist: bool,
115
116    /// Parent directory of the config file.
117    ///
118    /// Set by the CLI layer after parsing. Used by `normalize()` to resolve
119    /// relative paths against the config file location rather than the CWD.
120    #[serde(skip)]
121    pub config_dir: Option<PathBuf>,
122
123    /// Whether development mode is active.
124    ///
125    /// When true, `normalize()` enforces explicit `state_dir` configuration
126    /// to prevent accidental use of the system default state directory.
127    #[serde(skip)]
128    pub dev_mode: bool,
129
130    /// Store definitions — shared connection configurations keyed by name.
131    #[serde(default)]
132    pub stores: HashMap<String, StoreDef>,
133
134    /// Multi-calendar configuration.
135    ///
136    /// If present, each entry references a store from `stores`.
137    #[serde(default)]
138    pub calendars: Vec<CalendarEntry>,
139
140    /// Default calendar ID for new items.
141    #[serde(default = "default_calendar_id")]
142    pub default_calendar: String,
143}
144
145fn default_calendar_id() -> String {
146    "default".to_string()
147}
148
149impl Config {
150    /// Normalize the configuration.
151    ///
152    /// # Errors
153    /// If path normalization fails.
154    #[tracing::instrument(skip(self))]
155    pub fn normalize(&mut self) -> Result<(), Box<dyn Error>> {
156        let config_parent = self.config_dir.as_deref();
157
158        // Normalize calendar path if set (legacy format)
159        if let Some(ref calendar_path) = self.calendar_path {
160            self.calendar_path = Some(expand_path(calendar_path, config_parent)?);
161        }
162
163        // Normalize state directory
164        if let Some(a) = &self.state_dir {
165            let state_dir = expand_path(a, config_parent)
166                .map_err(|e| format!("Failed to expand state directory path: {e}"))?;
167            self.state_dir = Some(state_dir);
168        } else {
169            if self.dev_mode {
170                return Err(
171                    "Development mode requires state_dir to be explicitly configured".into(),
172                );
173            }
174            match get_state_dir() {
175                Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
176                Err(err) => tracing::warn!(err, "failed to get state directory"),
177            }
178        }
179
180        // Normalize calendar paths for multi-calendar configuration
181        for i in 0..self.calendars.len() {
182            let calendar = self.calendars.get(i).unwrap();
183            let store_def = self.stores.get(&calendar.store);
184
185            // Only resolve calendar_path for local stores
186            let calendar_path =
187                if matches!(store_def, Some(StoreDef::Local { .. })) || calendar.store == "local" {
188                    if let Some(ref path) = calendar.calendar_path {
189                        let p = expand_path(&PathBuf::from(path), None)
190                            .map_err(|e| {
191                                format!("Failed to expand calendar path for {}: {e}", calendar.id)
192                            })?
193                            .to_string_lossy()
194                            .to_string();
195                        Some(p)
196                    } else if let Some(ref state_dir) = self.state_dir {
197                        let p = state_dir
198                            .join("calendar")
199                            .join(&calendar.id)
200                            .to_string_lossy()
201                            .to_string();
202                        Some(p)
203                    } else {
204                        calendar.calendar_path.clone()
205                    }
206                } else {
207                    calendar.calendar_path.clone()
208                };
209
210            self.calendars[i] = CalendarEntry {
211                id: calendar.id.clone(),
212                name: calendar.name.clone(),
213                store: calendar.store.clone(),
214                calendar_href: calendar.calendar_href.clone(),
215                calendar_path,
216                priority: calendar.priority,
217                enabled: calendar.enabled,
218            };
219        }
220
221        Ok(())
222    }
223
224    /// Check if the configuration uses legacy single-calendar format.
225    ///
226    /// Returns `true` if `calendars` array is empty (legacy or default mode).
227    #[must_use]
228    pub fn is_legacy_format(&self) -> bool {
229        // If calendars array is present and not empty, it's not legacy
230        self.calendars.is_empty()
231    }
232
233    /// Generate a warning message for legacy configuration.
234    ///
235    /// This provides helpful guidance to users about migrating to multi-calendar format.
236    #[must_use]
237    pub fn legacy_warning(&self) -> String {
238        if !self.calendars.is_empty() {
239            return String::new();
240        }
241
242        let mut warning =
243            String::from("Warning: Using legacy single-calendar configuration format.\n\n");
244
245        if self.calendar_path.is_some() {
246            warning += "Legacy 'calendar_path' is detected.\n";
247        }
248
249        warning +=
250            "The multi-calendar feature requires updating to the 'calendars' array format.\n\n";
251        warning += "Please update your aim.toml to use the following format:\n\n";
252        warning += "[stores.local]\n";
253        warning += "type = \"local\"\n\n";
254        warning += "[[calendars]]\n";
255        warning += "id = \"default\"\n";
256        warning += "name = \"Default\"\n";
257        warning += "store = \"local\"\n";
258        warning += "priority = 0\n";
259        warning += "enabled = true\n";
260
261        if let Some(ref path) = self.calendar_path {
262            warning.push_str("\n# Your existing path can be used as:\n");
263            warning.push_str(&format!("calendar_path = \"{}\"\n", path.display()));
264        }
265
266        warning += "\nSee documentation for full migration guide.\n";
267        warning
268    }
269
270    /// Get all enabled calendars ordered by priority.
271    #[must_use]
272    pub fn enabled_calendars(&self) -> Vec<&CalendarEntry> {
273        let mut calendars: Vec<_> = self.calendars.iter().filter(|c| c.enabled).collect();
274        calendars.sort_by_key(|c| c.priority);
275        calendars
276    }
277
278    /// Resolve the store definition for a calendar entry.
279    ///
280    /// Returns `None` if the calendar is not found or the store reference is invalid.
281    #[must_use]
282    pub fn resolve_store(&self, calendar_id: &str) -> Option<(&CalendarEntry, &StoreDef)> {
283        let entry = self.calendars.iter().find(|c| c.id == calendar_id)?;
284        let store = self.stores.get(&entry.store)?;
285        Some((entry, store))
286    }
287
288    /// Get a specific calendar entry by ID.
289    #[must_use]
290    pub fn get_calendar(&self, id: &str) -> Option<&CalendarEntry> {
291        self.calendars.iter().find(|c| c.id == id)
292    }
293}
294
295/// Handle tilde (~) and environment variables in the path.
296///
297/// Relative paths that don't match any special prefix are resolved against
298/// `config_parent` when provided, or returned as-is otherwise.
299fn expand_path(path: &Path, config_parent: Option<&Path>) -> Result<PathBuf, Box<dyn Error>> {
300    if path.is_absolute() {
301        return Ok(path.to_owned());
302    }
303
304    let path = path.to_str().ok_or("Invalid path")?;
305
306    // Handle tilde and home directory
307    let home_prefixes: &[&str] = if cfg!(unix) {
308        &["~/", "$HOME/", "${HOME}/"]
309    } else {
310        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
311    };
312
313    for prefix in home_prefixes {
314        if let Some(stripped) = path.strip_prefix(prefix) {
315            return Ok(get_home_dir()?.join(stripped));
316        }
317    }
318
319    // Handle config directories
320    let config_prefixes: &[&str] = if cfg!(unix) {
321        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
322    } else {
323        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%"]
324    };
325
326    for prefix in config_prefixes {
327        if let Some(stripped) = path.strip_prefix(prefix) {
328            return Ok(get_config_dir()?.join(stripped));
329        }
330    }
331
332    match config_parent {
333        Some(parent) => Ok(parent.join(path)),
334        None => Ok(path.into()),
335    }
336}
337
338fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
339    dirs::home_dir().ok_or_else(|| "User-specific home directory not found".into())
340}
341
342fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
343    #[cfg(unix)]
344    let config_dir = xdg::BaseDirectories::new().get_config_home();
345    #[cfg(windows)]
346    let config_dir = dirs::config_dir();
347
348    config_dir.ok_or_else(|| "User-specific home directory not found".into())
349}
350
351fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
352    #[cfg(unix)]
353    let state_dir = xdg::BaseDirectories::new().get_state_home();
354    #[cfg(windows)]
355    let state_dir = dirs::data_dir();
356
357    state_dir.ok_or_else(|| "User-specific state directory not found".into())
358}
359
360#[cfg(test)]
361mod tests {
362    use std::str::FromStr;
363
364    use super::*;
365
366    #[test]
367    fn parses_full_toml_config() {
368        const TOML: &str = r#"
369calendar_path = "calendar"
370state_dir = "state"
371default_due = "1d"
372default_priority = "high"
373default_priority_none_fist = true
374"#;
375
376        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
377        assert_eq!(config.calendar_path, Some(PathBuf::from("calendar")));
378        assert_eq!(config.state_dir, Some(PathBuf::from("state")));
379        assert_eq!(config.default_due, Some(DateTimeAnchor::InDays(1)));
380        assert_eq!(config.default_priority, Priority::P2);
381        assert!(config.default_priority_none_fist);
382    }
383
384    #[test]
385    #[allow(clippy::needless_raw_string_hashes)]
386    fn parses_minimal_toml_with_defaults() {
387        const TOML: &str = r#"
388"#;
389
390        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
391        assert_eq!(config.calendar_path, None);
392        assert_eq!(config.state_dir, None);
393        assert_eq!(config.default_due, None);
394        assert_eq!(config.default_priority, Priority::None);
395        assert!(!config.default_priority_none_fist);
396    }
397
398    #[test]
399    fn expands_path_with_home_env_vars() {
400        let home = get_home_dir().unwrap();
401        let home_prefixes: &[&str] = if cfg!(unix) {
402            &["~", "$HOME", "${HOME}"]
403        } else {
404            &[r"~", r"%UserProfile%", r"%UserProfile%"]
405        };
406
407        for prefix in home_prefixes {
408            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents")), None).unwrap();
409            assert_eq!(result, home.join("Documents"));
410            assert!(result.is_absolute());
411        }
412    }
413
414    #[test]
415    fn expands_path_with_config_env_vars() {
416        let config_dir = get_config_dir().unwrap();
417        let config_prefixes: &[&str] = if cfg!(unix) {
418            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
419        } else {
420            &[r"%LOCALAPPDATA%", "%LOCALAPPDATA%"]
421        };
422
423        for prefix in config_prefixes {
424            let result =
425                expand_path(&PathBuf::from(format!("{prefix}/config.toml")), None).unwrap();
426            assert_eq!(result, config_dir.join("config.toml"));
427            assert!(result.is_absolute());
428        }
429    }
430
431    #[test]
432    fn preserves_absolute_path() {
433        let absolute_path = PathBuf::from("/etc/passwd");
434        let result = expand_path(&absolute_path, None).unwrap();
435        assert_eq!(result, absolute_path);
436    }
437
438    #[test]
439    fn preserves_relative_path_without_config_parent() {
440        let relative_path = PathBuf::from("relative/path/to/file");
441        let result = expand_path(&relative_path, None).unwrap();
442        assert_eq!(result, relative_path);
443    }
444
445    #[test]
446    fn resolves_relative_path_against_config_parent() {
447        let relative_path = PathBuf::from("relative/path/to/file");
448        let config_parent = PathBuf::from("/etc/aim");
449
450        let result = expand_path(&relative_path, Some(&config_parent)).unwrap();
451        assert_eq!(result, PathBuf::from("/etc/aim/relative/path/to/file"));
452    }
453
454    #[test]
455    fn parses_datetime_anchor_with_suffix_format() {
456        // TODO: compatibility test, remove after v0.10.0
457        assert_eq!(
458            DateTimeAnchor::from_str("1d").unwrap(),
459            DateTimeAnchor::InDays(1)
460        );
461        assert_eq!(
462            DateTimeAnchor::from_str("2h").unwrap(),
463            DateTimeAnchor::Relative(2 * 60 * 60)
464        );
465        assert_eq!(
466            DateTimeAnchor::from_str("45m").unwrap(),
467            DateTimeAnchor::Relative(45 * 60)
468        );
469        assert_eq!(
470            DateTimeAnchor::from_str("1800s").unwrap(),
471            DateTimeAnchor::Relative(1800)
472        );
473    }
474
475    #[test]
476    fn parses_multi_calendar_config() {
477        const TOML: &str = r#"
478default_calendar = "personal"
479
480[stores.mylocal]
481type = "local"
482
483[stores.radicale]
484type = "caldav"
485base_url = "https://caldav.example.com"
486calendar_home = "/dav/calendars/user/"
487auth = { type = "basic", username = "user", password = "pass" }
488timeout_secs = 30
489user_agent = "aimcal/0.11.0"
490
491[[calendars]]
492id = "personal"
493name = "Personal"
494store ="mylocal"
495priority = 0
496enabled = true
497calendar_path = "~/personal"
498
499[[calendars]]
500id = "work"
501name = "Work"
502store ="radicale"
503priority = 1
504enabled = true
505calendar_href = "/dav/calendars/user/work/"
506"#;
507
508        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
509        assert_eq!(config.calendars.len(), 2);
510        assert_eq!(config.stores.len(), 2);
511
512        assert_eq!(config.calendars[0].id, "personal");
513        assert_eq!(config.calendars[0].name, "Personal");
514        assert_eq!(config.calendars[0].store, "mylocal");
515        assert_eq!(config.calendars[0].priority, 0);
516        assert!(config.calendars[0].enabled);
517
518        assert_eq!(config.calendars[1].id, "work");
519        assert_eq!(config.calendars[1].name, "Work");
520        assert_eq!(config.calendars[1].store, "radicale");
521        assert_eq!(
522            config.calendars[1].calendar_href,
523            Some("/dav/calendars/user/work/".to_string())
524        );
525        assert_eq!(config.calendars[1].priority, 1);
526        assert!(config.calendars[1].enabled);
527
528        assert_eq!(config.default_calendar, "personal");
529
530        // Verify backends
531        assert!(matches!(
532            config.stores.get("mylocal"),
533            Some(StoreDef::Local { .. })
534        ));
535        assert!(matches!(
536            config.stores.get("radicale"),
537            Some(StoreDef::Caldav { .. })
538        ));
539    }
540
541    #[test]
542    fn is_legacy_format_detects_legacy_config() {
543        const TOML: &str = r#"
544calendar_path = "calendar"
545state_dir = "state"
546"#;
547
548        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
549        assert!(config.is_legacy_format());
550    }
551
552    #[test]
553    fn is_legacy_format_returns_false_for_multi_calendar() {
554        const TOML: &str = r#"
555[stores.local]
556type = "local"
557
558[[calendars]]
559id = "personal"
560name = "Personal"
561store ="local"
562"#;
563
564        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
565        assert!(!config.is_legacy_format());
566    }
567
568    #[test]
569    fn legacy_warning_provides_helpful_message() {
570        const TOML: &str = r#"
571calendar_path = "calendar"
572"#;
573
574        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
575        let warning = config.legacy_warning();
576
577        assert!(warning.contains("Warning: Using legacy single-calendar configuration"));
578        assert!(warning.contains("calendars"));
579        assert!(warning.contains("id = \"default\""));
580    }
581
582    #[test]
583    fn enabled_calendars_returns_enabled_only() {
584        const TOML: &str = r#"
585[stores.local]
586type = "local"
587
588[[calendars]]
589id = "personal"
590name = "Personal"
591store ="local"
592enabled = true
593
594[[calendars]]
595id = "work"
596name = "Work"
597store ="local"
598enabled = false
599
600[[calendars]]
601id = "archive"
602name = "Archive"
603store ="local"
604enabled = true
605priority = 5
606"#;
607
608        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
609        let enabled = config.enabled_calendars();
610
611        assert_eq!(enabled.len(), 2);
612        assert!(
613            enabled
614                .iter()
615                .all(|c| c.id == "personal" || c.id == "archive")
616        );
617        assert!(!enabled.iter().any(|c| c.id == "work"));
618    }
619
620    #[test]
621    fn resolve_store_returns_entry_and_def() {
622        const TOML: &str = r#"
623[stores.radicale]
624type = "caldav"
625base_url = "https://caldav.example.com"
626calendar_home = "/dav/"
627auth = { type = "basic", username = "u", password = "p" }
628
629[[calendars]]
630id = "work"
631name = "Work"
632store ="radicale"
633calendar_href = "/dav/work/"
634"#;
635
636        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
637        let (entry, backend) = config.resolve_store("work").unwrap();
638        assert_eq!(entry.id, "work");
639        assert!(matches!(backend, StoreDef::Caldav { .. }));
640        assert!(config.resolve_store("nonexistent").is_none());
641    }
642
643    #[test]
644    fn get_calendar_returns_entry_by_id() {
645        const TOML: &str = r#"
646[stores.local]
647type = "local"
648
649[[calendars]]
650id = "personal"
651name = "Personal"
652store ="local"
653calendar_path = "~/personal"
654"#;
655
656        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
657        let personal = config.get_calendar("personal");
658        assert!(personal.is_some());
659        assert_eq!(
660            personal.unwrap().calendar_path.as_deref(),
661            Some("~/personal")
662        );
663
664        assert!(config.get_calendar("work").is_none());
665    }
666
667    #[test]
668    fn multiple_calendars_share_backend() {
669        const TOML: &str = r#"
670default_calendar = "home"
671
672[stores.radicale]
673type = "caldav"
674base_url = "https://caldav.example.com/"
675calendar_home = "/user/"
676auth = { type = "basic", username = "u", password = "p" }
677
678[[calendars]]
679id = "home"
680name = "Home"
681store ="radicale"
682calendar_href = "/user/home/"
683priority = 0
684
685[[calendars]]
686id = "work"
687name = "Work"
688store ="radicale"
689calendar_href = "/user/work/"
690priority = 1
691
692[[calendars]]
693id = "test"
694name = "Test"
695store ="radicale"
696calendar_href = "/user/test/"
697priority = 2
698"#;
699
700        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
701        assert_eq!(config.calendars.len(), 3);
702        assert_eq!(config.stores.len(), 1);
703
704        // All calendars reference the same store
705        for calendar in &config.calendars {
706            assert_eq!(calendar.store, "radicale");
707        }
708    }
709}