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