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    warnings.extend(project_only(&text, &file));
138    (Settings { notify, ui }, warnings)
139}
140
141/// Keys that are the project's policy, never the person's: a value in the
142/// user config is refused with a warning naming it (until settings learn
143/// which keys a person may tighten).
144pub const PROJECT_ONLY: &[(&str, &str)] = &[("bug", "reproduce_tries"), ("bug", "test")];
145
146/// One warning per [`PROJECT_ONLY`] key `text` sets.
147#[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/// `[ui]`'s look keys from `text` (`[ui.notify]` is [`parse`]'s).
173#[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); // `parse` already said why
182    };
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/// [`load`] from `text`, naming `file` in warnings.
218#[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    // The unit is the last character, which may be more than a byte.
310    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}