aimcal_cli/
config.rs

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