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