aimcal_core/
config.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::error::Error;
6use std::path::{Path, PathBuf};
7
8use crate::{DateTimeAnchor, Priority};
9
10/// The name of the AIM application.
11pub const APP_NAME: &str = "aim";
12
13/// Configuration for the AIM application.
14#[derive(Debug, Clone, serde::Deserialize)]
15pub struct Config {
16    /// Path to the calendar directory.
17    pub calendar_path: PathBuf,
18
19    /// Directory for storing application state.
20    #[serde(default)]
21    pub state_dir: Option<PathBuf>,
22
23    /// Default due time for new tasks.
24    #[serde(default)]
25    pub default_due: Option<DateTimeAnchor>,
26
27    /// Default priority for new tasks.
28    #[serde(default)]
29    pub default_priority: Priority,
30
31    /// If true, items with no priority will be listed first.
32    #[serde(default)]
33    pub default_priority_none_fist: bool,
34}
35
36impl Config {
37    /// Normalize the configuration.
38    ///
39    /// # Errors
40    /// If path normalization fails.
41    #[tracing::instrument(skip(self))]
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                let state_dir = expand_path(a)
50                    .map_err(|e| format!("Failed to expand state directory path: {e}"))?;
51                self.state_dir = Some(state_dir);
52            }
53            None => match get_state_dir() {
54                Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
55                Err(err) => tracing::warn!(err, "failed to get state directory"),
56            },
57        }
58
59        Ok(())
60    }
61}
62
63/// Handle tilde (~) and environment variables in the path
64fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
65    if path.is_absolute() {
66        return Ok(path.to_owned());
67    }
68
69    let path = path.to_str().ok_or("Invalid path")?;
70
71    // Handle tilde and home directory
72    let home_prefixes: &[&str] = if cfg!(unix) {
73        &["~/", "$HOME/", "${HOME}/"]
74    } else {
75        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
76    };
77    for prefix in home_prefixes {
78        if let Some(stripped) = path.strip_prefix(prefix) {
79            return Ok(get_home_dir()?.join(stripped));
80        }
81    }
82
83    // Handle config directories
84    let config_prefixes: &[&str] = if cfg!(unix) {
85        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
86    } else {
87        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
88    };
89    for prefix in config_prefixes {
90        if let Some(stripped) = path.strip_prefix(prefix) {
91            return Ok(get_config_dir()?.join(stripped));
92        }
93    }
94
95    Ok(path.into())
96}
97
98fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
99    dirs::home_dir().ok_or_else(|| "User-specific home directory not found".into())
100}
101
102fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
103    #[cfg(unix)]
104    let config_dir = xdg::BaseDirectories::new().get_config_home();
105    #[cfg(windows)]
106    let config_dir = dirs::config_dir();
107    config_dir.ok_or_else(|| "User-specific home directory not found".into())
108}
109
110fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
111    #[cfg(unix)]
112    let state_dir = xdg::BaseDirectories::new().get_state_home();
113    #[cfg(windows)]
114    let state_dir = dirs::data_dir();
115    state_dir.ok_or_else(|| "User-specific state directory not found".into())
116}
117
118#[cfg(test)]
119mod tests {
120    use std::str::FromStr;
121
122    use super::*;
123
124    #[test]
125    fn parses_full_toml_config() {
126        const TOML: &str = r#"
127calendar_path = "calendar"
128state_dir = "state"
129default_due = "1d"
130default_priority = "high"
131default_priority_none_fist = true
132"#;
133
134        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
135        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
136        assert_eq!(config.state_dir, Some(PathBuf::from("state")));
137        assert_eq!(config.default_due, Some(DateTimeAnchor::InDays(1)));
138        assert_eq!(config.default_priority, Priority::P2);
139        assert!(config.default_priority_none_fist);
140    }
141
142    #[test]
143    fn parses_minimal_toml_with_defaults() {
144        const TOML: &str = r#"
145calendar_path = "calendar"
146"#;
147
148        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
149        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
150        assert_eq!(config.state_dir, None);
151        assert_eq!(config.default_due, None);
152        assert_eq!(config.default_priority, Priority::None);
153        assert!(!config.default_priority_none_fist);
154    }
155
156    #[test]
157    fn expands_path_with_home_env_vars() {
158        let home = get_home_dir().unwrap();
159        let home_prefixes: &[&str] = if cfg!(unix) {
160            &["~", "$HOME", "${HOME}"]
161        } else {
162            &[r"~", r"%UserProfile%"]
163        };
164        for prefix in home_prefixes {
165            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
166            assert_eq!(result, home.join("Documents"));
167            assert!(result.is_absolute());
168        }
169    }
170
171    #[test]
172    fn expands_path_with_config_env_vars() {
173        let config_dir = get_config_dir().unwrap();
174        let config_prefixes: &[&str] = if cfg!(unix) {
175            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
176        } else {
177            &[r"%LOCALAPPDATA%"]
178        };
179        for prefix in config_prefixes {
180            let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
181            assert_eq!(result, config_dir.join("config.toml"));
182            assert!(result.is_absolute());
183        }
184    }
185
186    #[test]
187    fn preserves_absolute_path() {
188        let absolute_path = PathBuf::from("/etc/passwd");
189        let result = expand_path(&absolute_path).unwrap();
190        assert_eq!(result, absolute_path);
191    }
192
193    #[test]
194    fn preserves_relative_path() {
195        let relative_path = PathBuf::from("relative/path/to/file");
196        let result = expand_path(&relative_path).unwrap();
197        assert_eq!(result, relative_path);
198    }
199
200    #[test]
201    fn parses_datetime_anchor_with_suffix_format() {
202        // TODO: compatibility test, remove after v0.10.0
203        assert_eq!(
204            DateTimeAnchor::from_str("1d").unwrap(),
205            DateTimeAnchor::InDays(1)
206        );
207        assert_eq!(
208            DateTimeAnchor::from_str("2h").unwrap(),
209            DateTimeAnchor::Relative(2 * 60 * 60)
210        );
211        assert_eq!(
212            DateTimeAnchor::from_str("45m").unwrap(),
213            DateTimeAnchor::Relative(45 * 60)
214        );
215        assert_eq!(
216            DateTimeAnchor::from_str("1800s").unwrap(),
217            DateTimeAnchor::Relative(1800)
218        );
219    }
220}