Skip to main content

speccoder_runtime/
user_config.rs

1//! The person's own settings, `~/.config/speccoder/config.toml`:
2//! how speccoder gets their attention. Never read from the committed
3//! `speccoder.toml`, which is the project's policy.
4
5use std::path::PathBuf;
6use std::time::Duration;
7
8/// What may ask for attention.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Signal {
11    /// A call waits for a person.
12    Approval,
13    /// A task failed or stopped.
14    TaskFailed,
15    /// A feature run ended.
16    FeatureDone,
17    /// Nothing happened for `stall_after`.
18    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/// How attention is asked for.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Method {
42    /// OSC 9 or OSC 777 where the terminal shows them, else BEL.
43    Auto,
44    /// `ESC ] 9 ; message BEL`.
45    Osc9,
46    /// `ESC ] 777 ; notify ; speccoder ; message BEL`.
47    Osc777,
48    /// BEL.
49    Bel,
50    /// A program; the message is its last argument.
51    Command(Vec<String>),
52    /// Nothing.
53    Off,
54}
55
56/// `[ui.notify]`.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Notify {
59    /// The signals that notify.
60    pub on: Vec<Signal>,
61    /// Only when the terminal is not focused (plain mode counts as unfocused).
62    pub unfocused_only: bool,
63    /// How.
64    pub method: Method,
65    /// No event for this long is a stall.
66    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    /// Whether `signal` notifies.
82    #[must_use]
83    pub fn wants(&self, signal: Signal) -> bool {
84        self.on.contains(&signal)
85    }
86}
87
88/// The settings file: `$XDG_CONFIG_HOME/speccoder/config.toml` when set,
89/// else `$HOME/.config/speccoder/config.toml`.
90#[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/// How output looks (`[ui]`): values as written, checked here;
101/// unset is `None` (the renderer's default).
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct Ui {
104    /// `auto`, `dark`, `light`, `high-contrast`, `daltonized`, `mono`.
105    pub theme: Option<String>,
106    /// `auto`, `always`, `never`.
107    pub color: Option<String>,
108    /// ASCII glyphs and borders.
109    pub ascii: Option<bool>,
110    /// Screen-reader mode.
111    pub screen_reader: Option<bool>,
112}
113
114/// The person's settings.
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub struct Settings {
117    /// `[ui.notify]`.
118    pub notify: Notify,
119    /// `[ui]`.
120    pub ui: Ui,
121}
122
123/// The settings and a warning per value that was not understood (the
124/// default is used for it). A missing file is all defaults.
125#[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/// `[ui]`'s look keys from `text` (`[ui.notify]` is [`parse`]'s).
150#[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); // `parse` already said why
159    };
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/// [`load`] from `text`, naming `file` in warnings.
195#[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    // The unit is the last character, which may be more than a byte.
287    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}