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
29#[derive(Debug)]
31pub struct Config {
32 pub core: CoreConfig,
34
35 pub state_dir: Option<PathBuf>,
37
38 pub default_due: Option<Duration>,
40
41 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 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 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
115fn 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 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 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
170fn parse_duration(s: &str) -> Result<Duration, Box<dyn Error>> {
172 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 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}