1use std::error::Error;
6use std::path::{Path, PathBuf};
7
8use crate::{DateTimeAnchor, Priority};
9
10pub const APP_NAME: &str = "aim";
12
13#[derive(Debug, Clone, serde::Deserialize)]
15pub struct Config {
16 pub calendar_path: PathBuf,
18
19 #[serde(default)]
21 pub state_dir: Option<PathBuf>,
22
23 #[serde(default)]
25 pub default_due: Option<DateTimeAnchor>,
26
27 #[serde(default)]
29 pub default_priority: Priority,
30
31 #[serde(default)]
33 pub default_priority_none_fist: bool,
34}
35
36impl Config {
37 #[tracing::instrument(skip(self))]
42 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 let state_dir = expand_path(a)
50 .map_err(|e| format!("Failed to expand state directory path: {e}"))?;
51 self.state_dir = Some(state_dir);
52 }
53 None => match get_state_dir() {
54 Ok(a) => self.state_dir = Some(a.join(APP_NAME)),
55 Err(err) => tracing::warn!(err, "failed to get state directory"),
56 },
57 }
58
59 Ok(())
60 }
61}
62
63fn expand_path(path: &Path) -> Result<PathBuf, Box<dyn Error>> {
65 if path.is_absolute() {
66 return Ok(path.to_owned());
67 }
68
69 let path = path.to_str().ok_or("Invalid path")?;
70
71 let home_prefixes: &[&str] = if cfg!(unix) {
73 &["~/", "$HOME/", "${HOME}/"]
74 } else {
75 &[r"~\", "~/", r"%UserProfile%\", r"%UserProfile%/"]
76 };
77 for prefix in home_prefixes {
78 if let Some(stripped) = path.strip_prefix(prefix) {
79 return Ok(get_home_dir()?.join(stripped));
80 }
81 }
82
83 let config_prefixes: &[&str] = if cfg!(unix) {
85 &["$XDG_CONFIG_HOME/", "${XDG_CONFIG_HOME}/"]
86 } else {
87 &[r"%LOCALAPPDATA%\", "%LOCALAPPDATA%/"]
88 };
89 for prefix in config_prefixes {
90 if let Some(stripped) = path.strip_prefix(prefix) {
91 return Ok(get_config_dir()?.join(stripped));
92 }
93 }
94
95 Ok(path.into())
96}
97
98fn get_home_dir() -> Result<PathBuf, Box<dyn Error>> {
99 dirs::home_dir().ok_or_else(|| "User-specific home directory not found".into())
100}
101
102fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
103 #[cfg(unix)]
104 let config_dir = xdg::BaseDirectories::new().get_config_home();
105 #[cfg(windows)]
106 let config_dir = dirs::config_dir();
107 config_dir.ok_or_else(|| "User-specific home directory not found".into())
108}
109
110fn get_state_dir() -> Result<PathBuf, Box<dyn Error>> {
111 #[cfg(unix)]
112 let state_dir = xdg::BaseDirectories::new().get_state_home();
113 #[cfg(windows)]
114 let state_dir = dirs::data_dir();
115 state_dir.ok_or_else(|| "User-specific state directory not found".into())
116}
117
118#[cfg(test)]
119mod tests {
120 use std::str::FromStr;
121
122 use super::*;
123
124 #[test]
125 fn parses_full_toml_config() {
126 const TOML: &str = r#"
127calendar_path = "calendar"
128state_dir = "state"
129default_due = "1d"
130default_priority = "high"
131default_priority_none_fist = true
132"#;
133
134 let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
135 assert_eq!(config.calendar_path, PathBuf::from("calendar"));
136 assert_eq!(config.state_dir, Some(PathBuf::from("state")));
137 assert_eq!(config.default_due, Some(DateTimeAnchor::InDays(1)));
138 assert_eq!(config.default_priority, Priority::P2);
139 assert!(config.default_priority_none_fist);
140 }
141
142 #[test]
143 fn parses_minimal_toml_with_defaults() {
144 const TOML: &str = r#"
145calendar_path = "calendar"
146"#;
147
148 let config: Config = toml::from_str(TOML).expect("Failed to parse TOML");
149 assert_eq!(config.calendar_path, PathBuf::from("calendar"));
150 assert_eq!(config.state_dir, None);
151 assert_eq!(config.default_due, None);
152 assert_eq!(config.default_priority, Priority::None);
153 assert!(!config.default_priority_none_fist);
154 }
155
156 #[test]
157 fn expands_path_with_home_env_vars() {
158 let home = get_home_dir().unwrap();
159 let home_prefixes: &[&str] = if cfg!(unix) {
160 &["~", "$HOME", "${HOME}"]
161 } else {
162 &[r"~", r"%UserProfile%"]
163 };
164 for prefix in home_prefixes {
165 let result = expand_path(&PathBuf::from(format!("{prefix}/Documents"))).unwrap();
166 assert_eq!(result, home.join("Documents"));
167 assert!(result.is_absolute());
168 }
169 }
170
171 #[test]
172 fn expands_path_with_config_env_vars() {
173 let config_dir = get_config_dir().unwrap();
174 let config_prefixes: &[&str] = if cfg!(unix) {
175 &["$XDG_CONFIG_HOME", "${XDG_CONFIG_HOME}"]
176 } else {
177 &[r"%LOCALAPPDATA%"]
178 };
179 for prefix in config_prefixes {
180 let result = expand_path(&PathBuf::from(format!("{prefix}/config.toml"))).unwrap();
181 assert_eq!(result, config_dir.join("config.toml"));
182 assert!(result.is_absolute());
183 }
184 }
185
186 #[test]
187 fn preserves_absolute_path() {
188 let absolute_path = PathBuf::from("/etc/passwd");
189 let result = expand_path(&absolute_path).unwrap();
190 assert_eq!(result, absolute_path);
191 }
192
193 #[test]
194 fn preserves_relative_path() {
195 let relative_path = PathBuf::from("relative/path/to/file");
196 let result = expand_path(&relative_path).unwrap();
197 assert_eq!(result, relative_path);
198 }
199
200 #[test]
201 fn parses_datetime_anchor_with_suffix_format() {
202 assert_eq!(
204 DateTimeAnchor::from_str("1d").unwrap(),
205 DateTimeAnchor::InDays(1)
206 );
207 assert_eq!(
208 DateTimeAnchor::from_str("2h").unwrap(),
209 DateTimeAnchor::Relative(2 * 60 * 60)
210 );
211 assert_eq!(
212 DateTimeAnchor::from_str("45m").unwrap(),
213 DateTimeAnchor::Relative(45 * 60)
214 );
215 assert_eq!(
216 DateTimeAnchor::from_str("1800s").unwrap(),
217 DateTimeAnchor::Relative(1800)
218 );
219 }
220}