1use std::path::PathBuf;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Signal {
11 Approval,
13 TaskFailed,
15 FeatureDone,
17 Stalled,
19}
20
21impl Signal {
22 const ALL: [Self; 4] = [
23 Self::Approval,
24 Self::TaskFailed,
25 Self::FeatureDone,
26 Self::Stalled,
27 ];
28
29 fn key(self) -> &'static str {
30 match self {
31 Self::Approval => "approval",
32 Self::TaskFailed => "task_failed",
33 Self::FeatureDone => "feature_done",
34 Self::Stalled => "stalled",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Method {
42 Auto,
44 Osc9,
46 Osc777,
48 Bel,
50 Command(Vec<String>),
52 Off,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Notify {
59 pub on: Vec<Signal>,
61 pub unfocused_only: bool,
63 pub method: Method,
65 pub stall_after: Duration,
67}
68
69impl Default for Notify {
70 fn default() -> Self {
71 Self {
72 on: Signal::ALL.to_vec(),
73 unfocused_only: true,
74 method: Method::Auto,
75 stall_after: Duration::from_secs(15 * 60),
76 }
77 }
78}
79
80impl Notify {
81 #[must_use]
83 pub fn wants(&self, signal: Signal) -> bool {
84 self.on.contains(&signal)
85 }
86}
87
88#[must_use]
91pub fn path() -> Option<PathBuf> {
92 let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty());
93 match xdg {
94 Some(dir) => Some(PathBuf::from(dir)),
95 None => std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")),
96 }
97 .map(|base| base.join("speccoder").join("config.toml"))
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct Ui {
104 pub theme: Option<String>,
106 pub color: Option<String>,
108 pub ascii: Option<bool>,
110 pub screen_reader: Option<bool>,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub struct Settings {
117 pub notify: Notify,
119 pub ui: Ui,
121}
122
123#[must_use]
126pub fn load() -> (Settings, Vec<String>) {
127 let Some(p) = path() else {
128 return (Settings::default(), Vec::new());
129 };
130 let Ok(text) = std::fs::read_to_string(&p) else {
131 return (Settings::default(), Vec::new());
132 };
133 let file = p.display().to_string();
134 let (notify, mut warnings) = parse(&text, &file);
135 let (ui, more) = parse_ui(&text, &file);
136 warnings.extend(more);
137 (Settings { notify, ui }, warnings)
138}
139
140const THEMES: [&str; 6] = [
141 "auto",
142 "dark",
143 "light",
144 "high-contrast",
145 "daltonized",
146 "mono",
147];
148
149#[must_use]
151pub fn parse_ui(text: &str, file: &str) -> (Ui, Vec<String>) {
152 let mut ui = Ui::default();
153 let mut warnings = Vec::new();
154 let mut warn = |key: &str, why: &str| {
155 warnings.push(format!("warning: {file}: {key}: {why}; using the default"));
156 };
157 let Ok(table) = toml::from_str::<toml::Table>(text) else {
158 return (ui, warnings); };
160 let Some(section) = table.get("ui").and_then(toml::Value::as_table) else {
161 return (ui, warnings);
162 };
163 for (key, value) in section {
164 match key.as_str() {
165 "notify" => {}
166 "theme" => match value.as_str().filter(|t| THEMES.contains(t)) {
167 Some(t) => ui.theme = Some(t.to_owned()),
168 None => warn(
169 "ui.theme",
170 &format!("expected one of {}", THEMES.join(", ")),
171 ),
172 },
173 "color" => match value
174 .as_str()
175 .filter(|c| ["auto", "always", "never"].contains(c))
176 {
177 Some(c) => ui.color = Some(c.to_owned()),
178 None => warn("ui.color", "expected auto, always or never"),
179 },
180 "ascii" => match value.as_bool() {
181 Some(b) => ui.ascii = Some(b),
182 None => warn("ui.ascii", "expected true or false"),
183 },
184 "screen_reader" => match value.as_bool() {
185 Some(b) => ui.screen_reader = Some(b),
186 None => warn("ui.screen_reader", "expected true or false"),
187 },
188 other => warn(&format!("ui.{other}"), "not a setting"),
189 }
190 }
191 (ui, warnings)
192}
193
194#[must_use]
196pub fn parse(text: &str, file: &str) -> (Notify, Vec<String>) {
197 let mut n = Notify::default();
198 let mut warnings = Vec::new();
199 let mut warn = |key: &str, why: &str| {
200 warnings.push(format!("warning: {file}: {key}: {why}; using the default"));
201 };
202 let table: toml::Table = match toml::from_str(text) {
203 Ok(t) => t,
204 Err(e) => {
205 warn("file", &e.to_string().replace('\n', " "));
206 return (n, warnings);
207 }
208 };
209 let Some(notify) = table
210 .get("ui")
211 .and_then(|u| u.get("notify"))
212 .and_then(toml::Value::as_table)
213 else {
214 return (n, warnings);
215 };
216 let mut command: Vec<String> = Vec::new();
217 for (key, value) in notify {
218 match key.as_str() {
219 "on" => match value.as_array() {
220 Some(items) => {
221 n.on = items
222 .iter()
223 .filter_map(|i| {
224 let name = i.as_str().unwrap_or_default();
225 let found = Signal::ALL.into_iter().find(|s| s.key() == name);
226 if found.is_none() {
227 warn("ui.notify.on", &format!("`{name}` is not a signal"));
228 }
229 found
230 })
231 .collect();
232 }
233 None => warn("ui.notify.on", "expected a list"),
234 },
235 "when" => match value.as_str() {
236 Some("unfocused") => n.unfocused_only = true,
237 Some("always") => n.unfocused_only = false,
238 _ => warn("ui.notify.when", "expected \"unfocused\" or \"always\""),
239 },
240 "method" => match value.as_str() {
241 Some("auto") => n.method = Method::Auto,
242 Some("osc9") => n.method = Method::Osc9,
243 Some("osc777") => n.method = Method::Osc777,
244 Some("bel") => n.method = Method::Bel,
245 Some("off") => n.method = Method::Off,
246 Some("command") => n.method = Method::Command(Vec::new()),
247 _ => warn(
248 "ui.notify.method",
249 "expected auto, osc9, osc777, bel, command or off",
250 ),
251 },
252 "command" => match value.as_array() {
253 Some(items) => {
254 command = items
255 .iter()
256 .filter_map(|i| i.as_str().map(str::to_owned))
257 .collect();
258 }
259 None => warn("ui.notify.command", "expected a list of strings"),
260 },
261 "stall_after" => match value.as_str().and_then(duration) {
262 Some(d) if d >= Duration::from_secs(30) => n.stall_after = d,
263 _ => warn(
264 "ui.notify.stall_after",
265 "expected Ns, Nm or Nh, at least 30s",
266 ),
267 },
268 other => warn(&format!("ui.notify.{other}"), "not a setting"),
269 }
270 }
271 if let Method::Command(argv) = &mut n.method {
272 if command.is_empty() {
273 warn(
274 "ui.notify.command",
275 "method is \"command\" but no command is given",
276 );
277 n.method = Method::Off;
278 } else {
279 *argv = command;
280 }
281 }
282 (n, warnings)
283}
284
285fn duration(text: &str) -> Option<Duration> {
286 let (number, unit) = text.split_at(text.char_indices().last()?.0);
288 let n: u64 = number.parse().ok()?;
289 match unit {
290 "s" => Some(Duration::from_secs(n)),
291 "m" => Some(Duration::from_secs(n * 60)),
292 "h" => Some(Duration::from_secs(n * 3600)),
293 _ => None,
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn a_multibyte_unit_is_no_duration() {
303 for bad in ["15д", "€", "д"] {
304 assert_eq!(duration(bad), None, "{bad}");
305 }
306 assert_eq!(duration("15m"), Some(Duration::from_secs(900)));
307 }
308
309 #[test]
310 fn the_look_is_read_and_bad_values_warn() {
311 let (ui, w) = parse_ui(
312 "[ui]\ntheme = \"daltonized\"\ncolor = \"never\"\nascii = true\nscreen_reader = true\n",
313 "c",
314 );
315 assert!(w.is_empty(), "{w:?}");
316 assert_eq!(ui.theme.as_deref(), Some("daltonized"));
317 assert_eq!(ui.color.as_deref(), Some("never"));
318 assert_eq!((ui.ascii, ui.screen_reader), (Some(true), Some(true)));
319 let (ui, w) = parse_ui(
320 "[ui]\ntheme = \"neon\"\nascii = \"yes\"\nsparkles = 1\n",
321 "c",
322 );
323 assert_eq!(ui, Ui::default());
324 assert_eq!(w.len(), 3, "{w:?}");
325 assert!(w[0].contains("ui.") && w.iter().any(|x| x.contains("ui.sparkles")));
326 }
327
328 #[test]
329 fn defaults_and_values() {
330 let (n, w) = parse("", "c");
331 assert_eq!(n, Notify::default());
332 assert!(w.is_empty());
333 let (n, w) = parse(
334 "[ui.notify]\non = [\"approval\"]\nwhen = \"always\"\nmethod = \"command\"\ncommand = [\"notify-send\"]\nstall_after = \"2m\"\n[ui.theme]\nx = 1\n",
335 "c",
336 );
337 assert!(w.is_empty(), "{w:?}");
338 assert_eq!(n.on, [Signal::Approval]);
339 assert!(!n.unfocused_only);
340 assert_eq!(n.method, Method::Command(vec!["notify-send".to_owned()]));
341 assert_eq!(n.stall_after, Duration::from_secs(120));
342 }
343
344 #[test]
345 fn what_is_not_understood_warns_and_falls_back() {
346 let (n, w) = parse(
347 "[ui.notify]\non = [\"approval\", \"lunch\"]\nmethod = \"command\"\nstall_after = \"5s\"\ncolour = 1\n",
348 "/x/config.toml",
349 );
350 assert_eq!(n.on, [Signal::Approval]);
351 assert_eq!(n.method, Method::Off);
352 assert_eq!(n.stall_after, Duration::from_secs(900));
353 assert_eq!(w.len(), 4, "{w:?}");
354 assert!(
355 w.iter()
356 .all(|l| l.starts_with("warning: /x/config.toml: ui.notify."))
357 );
358 let (_, w) = parse("not toml [", "f");
359 assert_eq!(w.len(), 1);
360 }
361}