1use std::error::Error;
6use std::fmt;
7use std::path::{Path, PathBuf};
8
9use chrono::{DateTime, Duration, TimeZone};
10use serde::de;
11
12use crate::Priority;
13
14pub const APP_NAME: &str = "aim";
16
17#[derive(Debug, Clone, serde::Deserialize)]
19pub struct Config {
20 pub calendar_path: PathBuf,
22
23 #[serde(default)]
25 pub state_dir: Option<PathBuf>,
26
27 #[serde(default)]
29 pub default_due: Option<ConfigDue>,
30
31 #[serde(default)]
33 pub default_priority: Priority,
34
35 #[serde(default)]
37 pub default_priority_none_fist: bool,
38}
39
40impl Config {
41 pub fn normalize(&mut self) -> Result<(), Box<dyn Error>> {
43 self.calendar_path = expand_path(&self.calendar_path)?;
45
46 match &self.state_dir {
48 Some(a) => {
49 self.state_dir = Some(
50 expand_path(a)
51 .map_err(|e| format!("Failed to expand state directory path: {e}"))?,
52 )
53 }
54
55 None => match get_state_dir() {
56 Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
57 Err(e) => log::warn!("Failed to get state directory: {e}"),
58 },
59 };
60
61 Ok(())
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct ConfigDue(Duration);
67
68impl ConfigDue {
69 pub fn datetime<Tz: TimeZone>(&self, now: DateTime<Tz>) -> DateTime<Tz> {
70 now + self.0
71 }
72}
73
74impl<'de> serde::Deserialize<'de> for ConfigDue {
75 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76 where
77 D: serde::Deserializer<'de>,
78 {
79 struct DueVisitor;
80
81 impl<'de> de::Visitor<'de> for DueVisitor {
82 type Value = ConfigDue;
83
84 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
85 formatter.write_str(r#"a duration string like "1d", "24h", "60m", or "1800s""#)
86 }
87
88 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
89 where
90 E: de::Error,
91 {
92 parse_duration(value)
93 .map(ConfigDue)
94 .map_err(|e| de::Error::custom(e.to_string()))
95 }
96 }
97
98 deserializer.deserialize_str(DueVisitor)
99 }
100}
101
102fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
104 if path.is_absolute() {
105 return Ok(path.to_owned());
106 }
107
108 let path = path.to_str().ok_or("Invalid path")?;
109
110 let home_prefixes: &[&str] = if cfg!(unix) {
112 &["~/", "$HOME/", "${HOME}/"]
113 } else {
114 &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
115 };
116 for prefix in home_prefixes {
117 if let Some(stripped) = path.strip_prefix(prefix) {
118 return Ok(get_home_dir()?.join(stripped));
119 }
120 }
121
122 let config_prefixes: &[&str] = if cfg!(unix) {
124 &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
125 } else {
126 &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
127 };
128 for prefix in config_prefixes {
129 if let Some(stripped) = path.strip_prefix(prefix) {
130 return Ok(get_config_dir()?.join(stripped));
131 }
132 }
133
134 Ok(path.into())
135}
136
137fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
138 dirs::home_dir().ok_or("User-specific home directory not found".into())
139}
140
141fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
142 #[cfg(unix)]
143 let config_dir = xdg::BaseDirectories::new().get_config_home();
144 #[cfg(windows)]
145 let config_dir = dirs::config_dir();
146 config_dir.ok_or("User-specific home directory not found".into())
147}
148
149fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
150 #[cfg(unix)]
151 let state_dir = xdg::BaseDirectories::new().get_state_home();
152 #[cfg(windows)]
153 let state_dir = dirs::data_dir();
154 state_dir.ok_or("User-specific state directory not found".into())
155}
156
157fn parse_duration(s: &str) -> Result<Duration, Box<dyn Error>> {
159 if let Some((h, m)) = s.split_once(':') {
161 let hours: i64 = h.trim().parse()?;
163 let minutes: i64 = m.trim().parse()?;
164 let minutes = hours * 60 + minutes;
165 println!("Deprecated duration format: {s}. Use '{minutes}m' instead.");
166 Ok(Duration::minutes(minutes))
167 }
168 else if let Some(rest) = s.strip_suffix("d") {
170 let days: i64 = rest.trim().parse()?;
171 Ok(Duration::days(days))
172 } else if let Some(rest) = s.strip_suffix("h") {
173 let hours: i64 = rest.trim().parse()?;
174 Ok(Duration::hours(hours))
175 } else if let Some(rest) = s.strip_suffix("m") {
176 let minutes: i64 = rest.trim().parse()?;
177 Ok(Duration::minutes(minutes))
178 } else if let Some(rest) = s.strip_suffix("s") {
179 let minutes: i64 = rest.trim().parse()?;
180 Ok(Duration::seconds(minutes))
181 } else {
182 Err(format!("Invalid duration format: {s}").into())
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn test_config_toml() {
192 const TOML: &str = r#"
193calendar_path = "calendar"
194state_dir = "state"
195default_due = "1d"
196default_priority = "high"
197default_priority_none_fist = true
198"#;
199
200 let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
201 assert_eq!(config.calendar_path, PathBuf::from("calendar"));
202 assert_eq!(config.state_dir, Some(PathBuf::from("state")));
203 assert_eq!(config.default_due, Some(ConfigDue(Duration::days(1))));
204 assert_eq!(config.default_priority, Priority::P2);
205 assert!(config.default_priority_none_fist);
206 }
207
208 #[test]
209 fn test_config_default() {
210 const TOML: &str = r#"
211calendar_path = "calendar"
212"#;
213
214 let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
215 assert_eq!(config.calendar_path, PathBuf::from("calendar"));
216 assert_eq!(config.state_dir, None);
217 assert_eq!(config.default_due, None);
218 assert_eq!(config.default_priority, Priority::None);
219 assert!(!config.default_priority_none_fist);
220 }
221
222 #[test]
223 fn test_expand_path_home_env() {
224 let home = get_home_dir().unwrap();
225 let home_prefixes: &[&str] = if cfg!(unix) {
226 &["~", "$HOME", "${HOME}"]
227 } else {
228 &[r"~", r"%UserProfile%"]
229 };
230 for prefix in home_prefixes {
231 let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
232 assert_eq!(result, home.join("Documents"));
233 assert!(result.is_absolute());
234 }
235 }
236
237 #[test]
238 fn test_expand_path_config() {
239 let config_dir = get_config_dir().unwrap();
240 let config_prefixes: &[&str] = if cfg!(unix) {
241 &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
242 } else {
243 &[r"%LOCALAPPDATA%"]
244 };
245 for prefix in config_prefixes {
246 let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
247 assert_eq!(result, config_dir.join("config.toml"));
248 assert!(result.is_absolute());
249 }
250 }
251
252 #[test]
253 fn test_expand_path_absolute() {
254 let absolute_path = PathBuf::from("/etc/passwd");
255 let result = expand_path(&absolute_path).unwrap();
256 assert_eq!(result, absolute_path);
257 }
258
259 #[test]
260 fn test_expand_path_relative() {
261 let relative_path = PathBuf::from("relative/path/to/file");
262 let result = expand_path(&relative_path).unwrap();
263 assert_eq!(result, relative_path);
264 }
265
266 #[test]
267 fn test_parse_duration_colon_format() {
268 let d = parse_duration("01:30").unwrap();
269 assert_eq!(d, Duration::minutes(90));
270
271 let d = parse_duration("00:00").unwrap();
272 assert_eq!(d, Duration::minutes(0));
273
274 let d = parse_duration("12:00").unwrap();
275 assert_eq!(d, Duration::hours(12));
276 }
277
278 #[test]
279 fn test_parse_duration_suffix_format() {
280 assert_eq!(parse_duration("1d").unwrap(), Duration::days(1));
281 assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
282 assert_eq!(parse_duration("45m").unwrap(), Duration::minutes(45));
283 assert_eq!(parse_duration("1800s").unwrap(), Duration::seconds(1800));
284 }
285
286 #[test]
287 fn test_parse_duration_invalid_format() {
288 assert!(parse_duration("abc").is_err());
289 assert!(parse_duration("99x").is_err());
290 assert!(parse_duration("12:xx").is_err());
291 assert!(parse_duration("12:").is_err());
292 assert!(parse_duration("12").is_err());
293 }
294}