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    pub fn normalize(&mut self) -> Result<(), Box<dyn Error>> {
43        // Normalize calendar path
44        self.calendar_path = expand_path(&self.calendar_path)?;
45
46        // Normalize state directory
47        match &self.state_dir {
48            Some(a) => {
49                self.state_dir = Some(
50                    expand_path(a)
51                        .map_err(|e| format!("Failed to expand state directory path: {e}"))?,
52                )
53            }
54
55            None => match get_state_dir() {
56                Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
57                Err(e) => log::warn!("Failed to get state directory: {e}"),
58            },
59        };
60
61        Ok(())
62    }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ConfigDue(Duration);
67
68impl ConfigDue {
69    pub fn datetime<Tz: TimeZone>(&self, now: DateTime<Tz>) -> DateTime<Tz> {
70        now + self.0
71    }
72}
73
74impl<'de> serde::Deserialize<'de> for ConfigDue {
75    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76    where
77        D: serde::Deserializer<'de>,
78    {
79        struct DueVisitor;
80
81        impl<'de> de::Visitor<'de> for DueVisitor {
82            type Value = ConfigDue;
83
84            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
85                formatter.write_str(r#"a duration string like "1d", "24h", "60m", or "1800s""#)
86            }
87
88            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
89            where
90                E: de::Error,
91            {
92                parse_duration(value)
93                    .map(ConfigDue)
94                    .map_err(|e| de::Error::custom(e.to_string()))
95            }
96        }
97
98        deserializer.deserialize_str(DueVisitor)
99    }
100}
101
102/// Handle tilde (~) and environment variables in the path
103fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
104    if path.is_absolute() {
105        return Ok(path.to_owned());
106    }
107
108    let path = path.to_str().ok_or("Invalid path")?;
109
110    // Handle tilde and home directory
111    let home_prefixes: &[&str] = if cfg!(unix) {
112        &["~/", "$HOME/", "${HOME}/"]
113    } else {
114        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
115    };
116    for prefix in home_prefixes {
117        if let Some(stripped) = path.strip_prefix(prefix) {
118            return Ok(get_home_dir()?.join(stripped));
119        }
120    }
121
122    // Handle config directories
123    let config_prefixes: &[&str] = if cfg!(unix) {
124        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
125    } else {
126        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
127    };
128    for prefix in config_prefixes {
129        if let Some(stripped) = path.strip_prefix(prefix) {
130            return Ok(get_config_dir()?.join(stripped));
131        }
132    }
133
134    Ok(path.into())
135}
136
137fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
138    dirs::home_dir().ok_or("User-specific home directory not found".into())
139}
140
141fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
142    #[cfg(unix)]
143    let config_dir = xdg::BaseDirectories::new().get_config_home();
144    #[cfg(windows)]
145    let config_dir = dirs::config_dir();
146    config_dir.ok_or("User-specific home directory not found".into())
147}
148
149fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
150    #[cfg(unix)]
151    let state_dir = xdg::BaseDirectories::new().get_state_home();
152    #[cfg(windows)]
153    let state_dir = dirs::data_dir();
154    state_dir.ok_or("User-specific state directory not found".into())
155}
156
157/// Parse a duration string in the format "HH:MM" / "1d" / "24h" / "60m" / "1800s".
158fn parse_duration(s: &str) -> Result<Duration, Box<dyn Error>> {
159    // Try to parse "HH:MM" format
160    if let Some((h, m)) = s.split_once(':') {
161        // TODO: remove in v0.5.0
162        let hours: i64 = h.trim().parse()?;
163        let minutes: i64 = m.trim().parse()?;
164        let minutes = hours * 60 + minutes;
165        println!("Deprecated duration format: {s}. Use '{minutes}m' instead.");
166        Ok(Duration::minutes(minutes))
167    }
168    // Match suffix-based formats
169    else if let Some(rest) = s.strip_suffix("d") {
170        let days: i64 = rest.trim().parse()?;
171        Ok(Duration::days(days))
172    } else if let Some(rest) = s.strip_suffix("h") {
173        let hours: i64 = rest.trim().parse()?;
174        Ok(Duration::hours(hours))
175    } else if let Some(rest) = s.strip_suffix("m") {
176        let minutes: i64 = rest.trim().parse()?;
177        Ok(Duration::minutes(minutes))
178    } else if let Some(rest) = s.strip_suffix("s") {
179        let minutes: i64 = rest.trim().parse()?;
180        Ok(Duration::seconds(minutes))
181    } else {
182        Err(format!("Invalid duration format: {s}").into())
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_config_toml() {
192        const TOML: &str = r#"
193calendar_path = "calendar"
194state_dir = "state"
195default_due = "1d"
196default_priority = "high"
197default_priority_none_fist = true
198"#;
199
200        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
201        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
202        assert_eq!(config.state_dir, Some(PathBuf::from("state")));
203        assert_eq!(config.default_due, Some(ConfigDue(Duration::days(1))));
204        assert_eq!(config.default_priority, Priority::P2);
205        assert!(config.default_priority_none_fist);
206    }
207
208    #[test]
209    fn test_config_default() {
210        const TOML: &str = r#"
211calendar_path = "calendar"
212"#;
213
214        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
215        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
216        assert_eq!(config.state_dir, None);
217        assert_eq!(config.default_due, None);
218        assert_eq!(config.default_priority, Priority::None);
219        assert!(!config.default_priority_none_fist);
220    }
221
222    #[test]
223    fn test_expand_path_home_env() {
224        let home = get_home_dir().unwrap();
225        let home_prefixes: &[&str] = if cfg!(unix) {
226            &["~", "$HOME", "${HOME}"]
227        } else {
228            &[r"~", r"%UserProfile%"]
229        };
230        for prefix in home_prefixes {
231            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
232            assert_eq!(result, home.join("Documents"));
233            assert!(result.is_absolute());
234        }
235    }
236
237    #[test]
238    fn test_expand_path_config() {
239        let config_dir = get_config_dir().unwrap();
240        let config_prefixes: &[&str] = if cfg!(unix) {
241            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
242        } else {
243            &[r"%LOCALAPPDATA%"]
244        };
245        for prefix in config_prefixes {
246            let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
247            assert_eq!(result, config_dir.join("config.toml"));
248            assert!(result.is_absolute());
249        }
250    }
251
252    #[test]
253    fn test_expand_path_absolute() {
254        let absolute_path = PathBuf::from("/etc/passwd");
255        let result = expand_path(&absolute_path).unwrap();
256        assert_eq!(result, absolute_path);
257    }
258
259    #[test]
260    fn test_expand_path_relative() {
261        let relative_path = PathBuf::from("relative/path/to/file");
262        let result = expand_path(&relative_path).unwrap();
263        assert_eq!(result, relative_path);
264    }
265
266    #[test]
267    fn test_parse_duration_colon_format() {
268        let d = parse_duration("01:30").unwrap();
269        assert_eq!(d, Duration::minutes(90));
270
271        let d = parse_duration("00:00").unwrap();
272        assert_eq!(d, Duration::minutes(0));
273
274        let d = parse_duration("12:00").unwrap();
275        assert_eq!(d, Duration::hours(12));
276    }
277
278    #[test]
279    fn test_parse_duration_suffix_format() {
280        assert_eq!(parse_duration("1d").unwrap(), Duration::days(1));
281        assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
282        assert_eq!(parse_duration("45m").unwrap(), Duration::minutes(45));
283        assert_eq!(parse_duration("1800s").unwrap(), Duration::seconds(1800));
284    }
285
286    #[test]
287    fn test_parse_duration_invalid_format() {
288        assert!(parse_duration("abc").is_err());
289        assert!(parse_duration("99x").is_err());
290        assert!(parse_duration("12:xx").is_err());
291        assert!(parse_duration("12:").is_err());
292        assert!(parse_duration("12").is_err());
293    }
294}