1use 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: Option<String>,
24
25 default_priority: Option<Priority>,
27
28 default_priority_none_fist: Option<bool>,
30}
31
32#[derive(Debug)]
34pub struct Config {
35 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 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 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
110fn 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 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 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
165fn parse_duration(s: &str) -> Result<Duration, Box<dyn Error>> {
167 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 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}