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