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