aimcal_core/
config.rs

1// SPDX-FileCopyrightText: 2025 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::error::Error;
6use std::fmt;
7use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Duration, TimeZone};
10use serde::de;
11
12use crate::Priority;
13
14/// The name of the AIM application.
15pub const APP_NAME: &str = "aim";
16
17/// Configuration for the AIM application.
18#[derive(Debug, Clone, serde::Deserialize)]
19pub struct Config {
20    /// Path to the calendar directory.
21    pub calendar_path: PathBuf,
22
23    /// Directory for storing application state.
24    #[serde(default)]
25    pub state_dir: Option<PathBuf>,
26
27    /// Default due time for new tasks.
28    #[serde(default)]
29    pub default_due: Option<ConfigDue>,
30
31    /// Default priority for new tasks.
32    #[serde(default)]
33    pub default_priority: Priority,
34
35    /// If true, items with no priority will be listed first.
36    #[serde(default)]
37    pub default_priority_none_fist: bool,
38}
39
40impl Config {
41    /// Normalize the configuration.
42    #[tracing::instrument(skip(self))]
43    pub fn normalize(&mut self) -> Result<(), Box<dyn Error>> {
44        // Normalize calendar path
45        self.calendar_path = expand_path(&self.calendar_path)?;
46
47        // Normalize state directory
48        match &self.state_dir {
49            Some(a) => {
50                self.state_dir = Some(
51                    expand_path(a)
52                        .map_err(|e| format!("Failed to expand state directory path: {e}"))?,
53                )
54            }
55
56            None => match get_state_dir() {
57                Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
58                Err(err) => tracing::warn!(err, "failed to get state directory"),
59            },
60        };
61
62        Ok(())
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct ConfigDue(Duration);
68
69impl ConfigDue {
70    pub fn datetime<Tz: TimeZone>(&self, now: DateTime<Tz>) -> DateTime<Tz> {
71        now + self.0
72    }
73}
74
75impl<'de> serde::Deserialize<'de> for ConfigDue {
76    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77    where
78        D: serde::Deserializer<'de>,
79    {
80        struct DueVisitor;
81
82        impl<'de> de::Visitor<'de> for DueVisitor {
83            type Value = ConfigDue;
84
85            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
86                formatter.write_str(r#"a duration string like "1d", "24h", "60m", or "1800s""#)
87            }
88
89            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
90            where
91                E: de::Error,
92            {
93                parse_duration(value)
94                    .map(ConfigDue)
95                    .map_err(|e| de::Error::custom(e.to_string()))
96            }
97        }
98
99        deserializer.deserialize_str(DueVisitor)
100    }
101}
102
103/// Handle tilde (~) and environment variables in the path
104fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
105    if path.is_absolute() {
106        return Ok(path.to_owned());
107    }
108
109    let path = path.to_str().ok_or("Invalid path")?;
110
111    // Handle tilde and home directory
112    let home_prefixes: &[&str] = if cfg!(unix) {
113        &["~/", "$HOME/", "${HOME}/"]
114    } else {
115        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
116    };
117    for prefix in home_prefixes {
118        if let Some(stripped) = path.strip_prefix(prefix) {
119            return Ok(get_home_dir()?.join(stripped));
120        }
121    }
122
123    // Handle config directories
124    let config_prefixes: &[&str] = if cfg!(unix) {
125        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
126    } else {
127        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
128    };
129    for prefix in config_prefixes {
130        if let Some(stripped) = path.strip_prefix(prefix) {
131            return Ok(get_config_dir()?.join(stripped));
132        }
133    }
134
135    Ok(path.into())
136}
137
138fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
139    dirs::home_dir().ok_or("User-specific home directory not found".into())
140}
141
142fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
143    #[cfg(unix)]
144    let config_dir = xdg::BaseDirectories::new().get_config_home();
145    #[cfg(windows)]
146    let config_dir = dirs::config_dir();
147    config_dir.ok_or("User-specific home directory not found".into())
148}
149
150fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
151    #[cfg(unix)]
152    let state_dir = xdg::BaseDirectories::new().get_state_home();
153    #[cfg(windows)]
154    let state_dir = dirs::data_dir();
155    state_dir.ok_or("User-specific state directory not found".into())
156}
157
158/// Parse a duration string in the format "HH:MM" / "1d" / "24h" / "60m" / "1800s".
159fn parse_duration(s: &str) -> Result<Duration, Box<dyn Error>> {
160    // Match suffix-based formats
161    if let Some(rest) = s.strip_suffix("d") {
162        let days: i64 = rest.trim().parse()?;
163        Ok(Duration::days(days))
164    } else if let Some(rest) = s.strip_suffix("h") {
165        let hours: i64 = rest.trim().parse()?;
166        Ok(Duration::hours(hours))
167    } else if let Some(rest) = s.strip_suffix("m") {
168        let minutes: i64 = rest.trim().parse()?;
169        Ok(Duration::minutes(minutes))
170    } else if let Some(rest) = s.strip_suffix("s") {
171        let minutes: i64 = rest.trim().parse()?;
172        Ok(Duration::seconds(minutes))
173    } else {
174        Err(format!("Invalid duration format: {s}").into())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_config_toml() {
184        const TOML: &str = r#"
185calendar_path = "calendar"
186state_dir = "state"
187default_due = "1d"
188default_priority = "high"
189default_priority_none_fist = true
190"#;
191
192        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
193        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
194        assert_eq!(config.state_dir, Some(PathBuf::from("state")));
195        assert_eq!(config.default_due, Some(ConfigDue(Duration::days(1))));
196        assert_eq!(config.default_priority, Priority::P2);
197        assert!(config.default_priority_none_fist);
198    }
199
200    #[test]
201    fn test_config_default() {
202        const TOML: &str = r#"
203calendar_path = "calendar"
204"#;
205
206        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
207        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
208        assert_eq!(config.state_dir, None);
209        assert_eq!(config.default_due, None);
210        assert_eq!(config.default_priority, Priority::None);
211        assert!(!config.default_priority_none_fist);
212    }
213
214    #[test]
215    fn test_expand_path_home_env() {
216        let home = get_home_dir().unwrap();
217        let home_prefixes: &[&str] = if cfg!(unix) {
218            &["~", "$HOME", "${HOME}"]
219        } else {
220            &[r"~", r"%UserProfile%"]
221        };
222        for prefix in home_prefixes {
223            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
224            assert_eq!(result, home.join("Documents"));
225            assert!(result.is_absolute());
226        }
227    }
228
229    #[test]
230    fn test_expand_path_config() {
231        let config_dir = get_config_dir().unwrap();
232        let config_prefixes: &[&str] = if cfg!(unix) {
233            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
234        } else {
235            &[r"%LOCALAPPDATA%"]
236        };
237        for prefix in config_prefixes {
238            let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
239            assert_eq!(result, config_dir.join("config.toml"));
240            assert!(result.is_absolute());
241        }
242    }
243
244    #[test]
245    fn test_expand_path_absolute() {
246        let absolute_path = PathBuf::from("/etc/passwd");
247        let result = expand_path(&absolute_path).unwrap();
248        assert_eq!(result, absolute_path);
249    }
250
251    #[test]
252    fn test_expand_path_relative() {
253        let relative_path = PathBuf::from("relative/path/to/file");
254        let result = expand_path(&relative_path).unwrap();
255        assert_eq!(result, relative_path);
256    }
257
258    #[test]
259    fn test_parse_duration_suffix_format() {
260        assert_eq!(parse_duration("1d").unwrap(), Duration::days(1));
261        assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
262        assert_eq!(parse_duration("45m").unwrap(), Duration::minutes(45));
263        assert_eq!(parse_duration("1800s").unwrap(), Duration::seconds(1800));
264    }
265
266    #[test]
267    fn test_parse_duration_invalid_format() {
268        assert!(parse_duration("abc").is_err());
269        assert!(parse_duration("99x").is_err());
270        assert!(parse_duration("12:xx").is_err());
271        assert!(parse_duration("12:").is_err());
272        assert!(parse_duration("12").is_err());
273    }
274}