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::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    #[tracing::instrument(skip(self))]
39    pub fn normalize(&mut self) -> Result<(), Box<dyn Error>> {
40        // Normalize calendar path
41        self.calendar_path = expand_path(&self.calendar_path)?;
42
43        // Normalize state directory
44        match &self.state_dir {
45            Some(a) => {
46                let state_dir = expand_path(a)
47                    .map_err(|e| format!("Failed to expand state directory path: {e}"))?;
48                self.state_dir = Some(state_dir);
49            }
50            None => match get_state_dir() {
51                Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
52                Err(err) => tracing::warn!(err, "failed to get state directory"),
53            },
54        };
55
56        Ok(())
57    }
58}
59
60/// Handle tilde (~) and environment variables in the path
61fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
62    if path.is_absolute() {
63        return Ok(path.to_owned());
64    }
65
66    let path = path.to_str().ok_or("Invalid path")?;
67
68    // Handle tilde and home directory
69    let home_prefixes: &[&str] = if cfg!(unix) {
70        &["~/", "$HOME/", "${HOME}/"]
71    } else {
72        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
73    };
74    for prefix in home_prefixes {
75        if let Some(stripped) = path.strip_prefix(prefix) {
76            return Ok(get_home_dir()?.join(stripped));
77        }
78    }
79
80    // Handle config directories
81    let config_prefixes: &[&str] = if cfg!(unix) {
82        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
83    } else {
84        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
85    };
86    for prefix in config_prefixes {
87        if let Some(stripped) = path.strip_prefix(prefix) {
88            return Ok(get_config_dir()?.join(stripped));
89        }
90    }
91
92    Ok(path.into())
93}
94
95fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
96    dirs::home_dir().ok_or("User-specific home directory not found".into())
97}
98
99fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
100    #[cfg(unix)]
101    let config_dir = xdg::BaseDirectories::new().get_config_home();
102    #[cfg(windows)]
103    let config_dir = dirs::config_dir();
104    config_dir.ok_or("User-specific home directory not found".into())
105}
106
107fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
108    #[cfg(unix)]
109    let state_dir = xdg::BaseDirectories::new().get_state_home();
110    #[cfg(windows)]
111    let state_dir = dirs::data_dir();
112    state_dir.ok_or("User-specific state directory not found".into())
113}
114
115#[cfg(test)]
116mod tests {
117    use std::str::FromStr;
118
119    use super::*;
120
121    #[test]
122    fn test_config_toml() {
123        const TOML: &str = r#"
124calendar_path = "calendar"
125state_dir = "state"
126default_due = "1d"
127default_priority = "high"
128default_priority_none_fist = true
129"#;
130
131        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
132        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
133        assert_eq!(config.state_dir, Some(PathBuf::from("state")));
134        assert_eq!(config.default_due, Some(DateTimeAnchor::InDays(1)));
135        assert_eq!(config.default_priority, Priority::P2);
136        assert!(config.default_priority_none_fist);
137    }
138
139    #[test]
140    fn test_config_default() {
141        const TOML: &str = r#"
142calendar_path = "calendar"
143"#;
144
145        let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
146        assert_eq!(config.calendar_path, PathBuf::from("calendar"));
147        assert_eq!(config.state_dir, None);
148        assert_eq!(config.default_due, None);
149        assert_eq!(config.default_priority, Priority::None);
150        assert!(!config.default_priority_none_fist);
151    }
152
153    #[test]
154    fn test_expand_path_home_env() {
155        let home = get_home_dir().unwrap();
156        let home_prefixes: &[&str] = if cfg!(unix) {
157            &["~", "$HOME", "${HOME}"]
158        } else {
159            &[r"~", r"%UserProfile%"]
160        };
161        for prefix in home_prefixes {
162            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
163            assert_eq!(result, home.join("Documents"));
164            assert!(result.is_absolute());
165        }
166    }
167
168    #[test]
169    fn test_expand_path_config() {
170        let config_dir = get_config_dir().unwrap();
171        let config_prefixes: &[&str] = if cfg!(unix) {
172            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
173        } else {
174            &[r"%LOCALAPPDATA%"]
175        };
176        for prefix in config_prefixes {
177            let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
178            assert_eq!(result, config_dir.join("config.toml"));
179            assert!(result.is_absolute());
180        }
181    }
182
183    #[test]
184    fn test_expand_path_absolute() {
185        let absolute_path = PathBuf::from("/etc/passwd");
186        let result = expand_path(&absolute_path).unwrap();
187        assert_eq!(result, absolute_path);
188    }
189
190    #[test]
191    fn test_expand_path_relative() {
192        let relative_path = PathBuf::from("relative/path/to/file");
193        let result = expand_path(&relative_path).unwrap();
194        assert_eq!(result, relative_path);
195    }
196
197    #[test]
198    fn test_due_from_str_suffix_format() {
199        // TODO: compatibility test, remove after v0.10.0
200        assert_eq!(
201            DateTimeAnchor::from_str("1d").unwrap(),
202            DateTimeAnchor::InDays(1)
203        );
204        assert_eq!(
205            DateTimeAnchor::from_str("2h").unwrap(),
206            DateTimeAnchor::Relative(2 * 60 * 60)
207        );
208        assert_eq!(
209            DateTimeAnchor::from_str("45m").unwrap(),
210            DateTimeAnchor::Relative(45 * 60)
211        );
212        assert_eq!(
213            DateTimeAnchor::from_str("1800s").unwrap(),
214            DateTimeAnchor::Relative(1800)
215        );
216    }
217}