1#![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
18pub 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#[derive(Debug, Clone, serde::Deserialize)]
34#[serde(tag = "type")]
35pub enum StoreDef {
36 #[serde(rename = "local")]
38 Local {
39 calendar_path: Option<String>,
41 },
42 #[serde(rename = "caldav")]
44 Caldav {
45 base_url: String,
47 calendar_home: String,
49 auth: AuthMethod,
51 #[serde(default = "default_timeout_secs")]
53 timeout_secs: u64,
54 #[serde(default = "default_user_agent")]
56 user_agent: String,
57 },
58}
59
60#[derive(Debug, Clone, serde::Deserialize)]
65pub struct CalendarEntry {
66 pub id: String,
68 pub name: String,
70 pub store: String,
72 pub calendar_href: Option<String>,
74 pub calendar_path: Option<String>,
76 #[serde(default)]
78 pub priority: i32,
79 #[serde(default = "default_enabled")]
81 pub enabled: bool,
82}
83
84fn default_enabled() -> bool {
85 true
86}
87
88#[derive(Debug, Clone, serde::Deserialize)]
90pub struct Config {
91 #[serde(default)]
98 pub calendar_path: Option<PathBuf>,
99
100 #[serde(default)]
102 pub state_dir: Option<PathBuf>,
103
104 #[serde(default)]
106 pub default_due: Option<DateTimeAnchor>,
107
108 #[serde(default)]
110 pub default_priority: Priority,
111
112 #[serde(default)]
114 pub default_priority_none_fist: bool,
115
116 #[serde(skip)]
121 pub config_dir: Option<PathBuf>,
122
123 #[serde(skip)]
128 pub dev_mode: bool,
129
130 #[serde(default)]
132 pub stores: HashMap<String, StoreDef>,
133
134 #[serde(default)]
138 pub calendars: Vec<CalendarEntry>,
139
140 #[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 #[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 if let Some(ref calendar_path) = self.calendar_path {
160 self.calendar_path = Some(expand_path(calendar_path, config_parent)?);
161 }
162
163 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 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 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 #[must_use]
228 pub fn is_legacy_format(&self) -> bool {
229 self.calendars.is_empty()
231 }
232
233 #[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 #[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 #[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 #[must_use]
290 pub fn get_calendar(&self, id: &str) -> Option<&CalendarEntry> {
291 self.calendars.iter().find(|c| c.id == id)
292 }
293}
294
295fn 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 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 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 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 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 for calendar in &config.calendars {
706 assert_eq!(calendar.store, "radicale");
707 }
708 }
709}