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