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 std::{
7    error::Error,
8    path::{Path, PathBuf},
9    str::FromStr,
10};
11
12pub const APP_NAME: &str = "aim";
13
14#[derive(Debug, serde::Deserialize)]
15struct ConfigRaw {
16    calendar_path: PathBuf,
17    state_dir: Option<PathBuf>,
18    default_priority: Option<Priority>,
19}
20
21/// Configuration for the Aim application.
22#[derive(Debug)]
23pub struct Config {
24    /// Core configuration for the calendar.
25    pub core: CoreConfig,
26
27    /// Directory for storing application state.
28    pub state_dir: Option<PathBuf>,
29
30    /// Default priority for new tasks.
31    pub default_priority: Priority,
32}
33
34impl TryFrom<ConfigRaw> for Config {
35    type Error = Box<dyn Error>;
36
37    fn try_from(raw: ConfigRaw) -> Result<Self, Self::Error> {
38        Ok(Self {
39            core: CoreConfig {
40                calendar_path: expand_path(&raw.calendar_path)?,
41            },
42            state_dir: match raw.state_dir {
43                Some(a) => Some(expand_path(&a)?),
44                None => get_state_dir().ok().map(|a| a.join(APP_NAME)),
45            },
46            default_priority: raw.default_priority.unwrap_or(Priority::None),
47        })
48    }
49}
50
51impl FromStr for Config {
52    type Err = Box<dyn Error>;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        toml::from_str::<ConfigRaw>(s)?.try_into()
56    }
57}
58
59impl Config {
60    /// Parse the configuration file.
61    pub async fn parse(path: Option<PathBuf>) -> Result<Config, Box<dyn Error>> {
62        let path = match path {
63            Some(path) => expand_path(&path)?,
64            None => {
65                // TODO: zero config should works
66                // TODO: search config in multiple locations
67                let config = get_config_dir()?.join(format!("{APP_NAME}/config.toml"));
68                if !config.exists() {
69                    return Err(format!("No config found at: {}", config.display()).into());
70                }
71                config
72            }
73        };
74
75        tokio::fs::read_to_string(path)
76            .await
77            .map_err(|e| format!("Failed to read config file: {e}"))?
78            .parse()
79    }
80}
81
82/// Handle tilde (~) and environment variables in the path
83fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
84    if path.is_absolute() {
85        return Ok(path.to_owned());
86    }
87
88    let path = path.to_str().ok_or("Invalid path")?;
89
90    // Handle tilde and home directory
91    let home_prefixes: &[&str] = if cfg!(unix) {
92        &["~/", "$HOME/", "${HOME}/"]
93    } else {
94        &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
95    };
96    for prefix in home_prefixes {
97        if let Some(stripped) = path.strip_prefix(prefix) {
98            return Ok(get_home_dir()?.join(stripped));
99        }
100    }
101
102    // Handle config directories
103    let config_prefixes: &[&str] = if cfg!(unix) {
104        &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
105    } else {
106        &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
107    };
108    for prefix in config_prefixes {
109        if let Some(stripped) = path.strip_prefix(prefix) {
110            return Ok(get_config_dir()?.join(stripped));
111        }
112    }
113
114    Ok(path.into())
115}
116
117fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
118    dirs::home_dir().ok_or("User-specific home directory not found".into())
119}
120
121fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
122    #[cfg(unix)]
123    let config_dir = xdg::BaseDirectories::new().get_config_home();
124    #[cfg(windows)]
125    let config_dir = dirs::config_dir();
126    config_dir.ok_or("User-specific home directory not found".into())
127}
128
129fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
130    #[cfg(unix)]
131    let state_dir = xdg::BaseDirectories::new().get_state_home();
132    #[cfg(windows)]
133    let state_dir = dirs::data_dir();
134    state_dir.ok_or("User-specific state directory not found".into())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_expand_path_home_env() {
143        let home = get_home_dir().unwrap();
144        let home_prefixes: &[&str] = if cfg!(unix) {
145            &["~", "$HOME", "${HOME}"]
146        } else {
147            &[r"~", r"%UserProfile%"]
148        };
149        for prefix in home_prefixes {
150            let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
151            assert_eq!(result, home.join("Documents"));
152            assert!(result.is_absolute());
153        }
154    }
155
156    #[test]
157    fn test_expand_path_config() {
158        let config_dir = get_config_dir().unwrap();
159        let config_prefixes: &[&str] = if cfg!(unix) {
160            &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
161        } else {
162            &[r"%LOCALAPPDATA%"]
163        };
164        for prefix in config_prefixes {
165            let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
166            assert_eq!(result, config_dir.join("config.toml"));
167            assert!(result.is_absolute());
168        }
169    }
170
171    #[test]
172    fn test_expand_path_absolute() {
173        let absolute_path = PathBuf::from("/etc/passwd");
174        let result = expand_path(&absolute_path).unwrap();
175        assert_eq!(result, absolute_path);
176    }
177
178    #[test]
179    fn test_expand_path_relative() {
180        let relative_path = PathBuf::from("relative/path/to/file");
181        let result = expand_path(&relative_path).unwrap();
182        assert_eq!(result, relative_path);
183    }
184}