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: vec![Signal::Approval, Signal::TaskFailed],
75 unfocused_only: true,
76 method: Method::Auto,
77 stall_after: Duration::from_secs(15 * 60),
78 }
79 }
80}
81
82impl Notify {
83 #[must_use]
85 pub fn wants(&self, signal: Signal) -> bool {
86 self.on.contains(&signal)
87 }
88}
89
90#[must_use]
93pub fn path() -> Option<PathBuf> {
94 let xdg = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty());
95 match xdg {
96 Some(dir) => Some(PathBuf::from(dir)),
97 None => std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")),
98 }
99 .map(|base| base.join("speccoder").join("config.toml"))
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct Ui {
106 pub theme: Option<String>,
108 pub color: Option<String>,
110 pub ascii: Option<bool>,
112 pub screen_reader: Option<bool>,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Settings {
119 pub notify: Notify,
121 pub ui: Ui,
123}
124
125#[must_use]
128pub fn load() -> (Settings, Vec<String>) {
129 let Some(p) = path() else {
130 return (Settings::default(), Vec::new());
131 };
132 let Ok(text) = std::fs::read_to_string(&p) else {
133 return (Settings::default(), Vec::new());
134 };
135 let file = p.display().to_string();
136 let (notify, mut warnings) = parse(&text, &file);
137 let (ui, more) = parse_ui(&text, &file);
138 warnings.extend(more);
139 warnings.extend(project_only(&text, &file));
140 (Settings { notify, ui }, warnings)
141}
142
143pub const PROJECT_ONLY: &[(&str, &str)] = &[("bug", "reproduce_tries"), ("bug", "test")];
147
148#[must_use]
150pub fn project_only(text: &str, file: &str) -> Vec<String> {
151 let Ok(table) = toml::from_str::<toml::Table>(text) else {
152 return Vec::new();
153 };
154 PROJECT_ONLY
155 .iter()
156 .filter(|(section, key)| table.get(*section).and_then(|s| s.get(*key)).is_some())
157 .map(|(section, key)| {
158 format!(
159 "warning: {file}: {section}.{key}: a project setting (speccoder.toml), not read from the user config; ignored"
160 )
161 })
162 .collect()
163}
164
165const THEMES: [&str; 6] = [
166 "auto",
167 "dark",
168 "light",
169 "high-contrast",
170 "daltonized",
171 "mono",
172];
173
174#[must_use]
176pub fn parse_ui(text: &str, file: &str) -> (Ui, Vec<String>) {
177 let mut ui = Ui::default();
178 let mut warnings = Vec::new();
179 let mut warn = |key: &str, why: &str| {
180 warnings.push(format!("warning: {file}: {key}: {why}; using the default"));
181 };
182 let Ok(table) = toml::from_str::<toml::Table>(text) else {
183 return (ui, warnings); };
185 let Some(section) = table.get("ui").and_then(toml::Value::as_table) else {
186 return (ui, warnings);
187 };
188 for (key, value) in section {
189 match key.as_str() {
190 "notify" => {}
191 "theme" => match value.as_str().filter(|t| THEMES.contains(t)) {
192 Some(t) => ui.theme = Some(t.to_owned()),
193 None => warn(
194 "ui.theme",
195 &format!("expected one of {}", THEMES.join(", ")),
196 ),
197 },
198 "color" => match value
199 .as_str()
200 .filter(|c| ["auto", "always", "never"].contains(c))
201 {
202 Some(c) => ui.color = Some(c.to_owned()),
203 None => warn("ui.color", "expected auto, always or never"),
204 },
205 "ascii" => match value.as_bool() {
206 Some(b) => ui.ascii = Some(b),
207 None => warn("ui.ascii", "expected true or false"),
208 },
209 "screen_reader" => match value.as_bool() {
210 Some(b) => ui.screen_reader = Some(b),
211 None => warn("ui.screen_reader", "expected true or false"),
212 },
213 other => warn(&format!("ui.{other}"), "not a setting"),
214 }
215 }
216 (ui, warnings)
217}
218
219#[must_use]
221pub fn parse(text: &str, file: &str) -> (Notify, Vec<String>) {
222 let mut n = Notify::default();
223 let mut warnings = Vec::new();
224 let mut warn = |key: &str, why: &str| {
225 warnings.push(format!("warning: {file}: {key}: {why}; using the default"));
226 };
227 let table: toml::Table = match toml::from_str(text) {
228 Ok(t) => t,
229 Err(e) => {
230 warn("file", &e.to_string().replace('\n', " "));
231 return (n, warnings);
232 }
233 };
234 let Some(notify) = table
235 .get("ui")
236 .and_then(|u| u.get("notify"))
237 .and_then(toml::Value::as_table)
238 else {
239 return (n, warnings);
240 };
241 let mut command: Vec<String> = Vec::new();
242 for (key, value) in notify {
243 match key.as_str() {
244 "on" => match value.as_array() {
245 Some(items) => {
246 n.on = items
247 .iter()
248 .filter_map(|i| {
249 let name = i.as_str().unwrap_or_default();
250 let found = Signal::ALL.into_iter().find(|s| s.key() == name);
251 if found.is_none() {
252 warn("ui.notify.on", &format!("`{name}` is not a signal"));
253 }
254 found
255 })
256 .collect();
257 }
258 None => warn("ui.notify.on", "expected a list"),
259 },
260 "when" => match value.as_str() {
261 Some("unfocused") => n.unfocused_only = true,
262 Some("always") => n.unfocused_only = false,
263 _ => warn("ui.notify.when", "expected \"unfocused\" or \"always\""),
264 },
265 "method" => match value.as_str() {
266 Some("auto") => n.method = Method::Auto,
267 Some("osc9") => n.method = Method::Osc9,
268 Some("osc777") => n.method = Method::Osc777,
269 Some("bel") => n.method = Method::Bel,
270 Some("off") => n.method = Method::Off,
271 Some("command") => n.method = Method::Command(Vec::new()),
272 _ => warn(
273 "ui.notify.method",
274 "expected auto, osc9, osc777, bel, command or off",
275 ),
276 },
277 "command" => match value.as_array() {
278 Some(items) => {
279 command = items
280 .iter()
281 .filter_map(|i| i.as_str().map(str::to_owned))
282 .collect();
283 }
284 None => warn("ui.notify.command", "expected a list of strings"),
285 },
286 "stall_after" => match value.as_str().and_then(duration) {
287 Some(d) if d >= Duration::from_secs(30) => n.stall_after = d,
288 _ => warn(
289 "ui.notify.stall_after",
290 "expected Ns, Nm or Nh, at least 30s",
291 ),
292 },
293 other => warn(&format!("ui.notify.{other}"), "not a setting"),
294 }
295 }
296 if let Method::Command(argv) = &mut n.method {
297 if command.is_empty() {
298 warn(
299 "ui.notify.command",
300 "method is \"command\" but no command is given",
301 );
302 n.method = Method::Off;
303 } else {
304 *argv = command;
305 }
306 }
307 (n, warnings)
308}
309
310fn duration(text: &str) -> Option<Duration> {
311 let (number, unit) = text.split_at(text.char_indices().last()?.0);
313 let n: u64 = number.parse().ok()?;
314 match unit {
315 "s" => Some(Duration::from_secs(n)),
316 "m" => Some(Duration::from_secs(n * 60)),
317 "h" => Some(Duration::from_secs(n * 3600)),
318 _ => None,
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 #[test]
325 fn a_project_setting_in_the_user_config_is_refused() {
326 let w = super::project_only("[bug]\nreproduce_tries = 5\n", "config.toml");
327 assert_eq!(w.len(), 1);
328 assert!(
329 w[0].contains("bug.reproduce_tries") && w[0].contains("ignored"),
330 "{w:?}"
331 );
332 assert!(super::project_only("[ui]\ntheme = \"dark\"\n", "c").is_empty());
333 }
334
335 use super::*;
336
337 #[test]
338 fn a_multibyte_unit_is_no_duration() {
339 for bad in ["15д", "€", "д"] {
340 assert_eq!(duration(bad), None, "{bad}");
341 }
342 assert_eq!(duration("15m"), Some(Duration::from_secs(900)));
343 }
344
345 #[test]
346 fn the_look_is_read_and_bad_values_warn() {
347 let (ui, w) = parse_ui(
348 "[ui]\ntheme = \"daltonized\"\ncolor = \"never\"\nascii = true\nscreen_reader = true\n",
349 "c",
350 );
351 assert!(w.is_empty(), "{w:?}");
352 assert_eq!(ui.theme.as_deref(), Some("daltonized"));
353 assert_eq!(ui.color.as_deref(), Some("never"));
354 assert_eq!((ui.ascii, ui.screen_reader), (Some(true), Some(true)));
355 let (ui, w) = parse_ui(
356 "[ui]\ntheme = \"neon\"\nascii = \"yes\"\nsparkles = 1\n",
357 "c",
358 );
359 assert_eq!(ui, Ui::default());
360 assert_eq!(w.len(), 3, "{w:?}");
361 assert!(w[0].contains("ui.") && w.iter().any(|x| x.contains("ui.sparkles")));
362 }
363
364 #[test]
365 fn defaults_and_values() {
366 let (n, w) = parse("", "c");
367 assert_eq!(n, Notify::default());
368 assert!(w.is_empty());
369 let (n, w) = parse(
370 "[ui.notify]\non = [\"approval\"]\nwhen = \"always\"\nmethod = \"command\"\ncommand = [\"notify-send\"]\nstall_after = \"2m\"\n[ui.theme]\nx = 1\n",
371 "c",
372 );
373 assert!(w.is_empty(), "{w:?}");
374 assert_eq!(n.on, [Signal::Approval]);
375 assert!(!n.unfocused_only);
376 assert_eq!(n.method, Method::Command(vec!["notify-send".to_owned()]));
377 assert_eq!(n.stall_after, Duration::from_secs(120));
378 }
379
380 #[test]
381 fn what_is_not_understood_warns_and_falls_back() {
382 let (n, w) = parse(
383 "[ui.notify]\non = [\"approval\", \"lunch\"]\nmethod = \"command\"\nstall_after = \"5s\"\ncolour = 1\n",
384 "/x/config.toml",
385 );
386 assert_eq!(n.on, [Signal::Approval]);
387 assert_eq!(n.method, Method::Off);
388 assert_eq!(n.stall_after, Duration::from_secs(900));
389 assert_eq!(w.len(), 4, "{w:?}");
390 assert!(
391 w.iter()
392 .all(|l| l.starts_with("warning: /x/config.toml: ui.notify."))
393 );
394 let (_, w) = parse("not toml [", "f");
395 assert_eq!(w.len(), 1);
396 }
397}