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            // Only what blocks work: a call waiting, a task that did not
73            // pass. A finished feature or a stall notifies when listed.
74            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    /// Whether `signal` notifies.
84    #[must_use]
85    pub fn wants(&self, signal: Signal) -> bool {
86        self.on.contains(&signal)
87    }
88}
89
90/// The settings file: `$XDG_CONFIG_HOME/speccoder/config.toml` when set,
91/// else `$HOME/.config/speccoder/config.toml`.
92#[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/// How output looks (`[ui]`): values as written, checked here;
103/// unset is `None` (the renderer's default).
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct Ui {
106    /// `auto`, `dark`, `light`, `high-contrast`, `daltonized`, `mono`.
107    pub theme: Option<String>,
108    /// `auto`, `always`, `never`.
109    pub color: Option<String>,
110    /// ASCII glyphs and borders.
111    pub ascii: Option<bool>,
112    /// Screen-reader mode.
113    pub screen_reader: Option<bool>,
114}
115
116/// The person's settings.
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
118pub struct Settings {
119    /// `[ui.notify]`.
120    pub notify: Notify,
121    /// `[ui]`.
122    pub ui: Ui,
123}
124
125/// The settings and a warning per value that was not understood (the
126/// default is used for it). A missing file is all defaults.
127#[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
143/// Keys that are the project's policy, never the person's: a value in the
144/// user config is refused with a warning naming it (until settings learn
145/// which keys a person may tighten).
146pub const PROJECT_ONLY: &[(&str, &str)] = &[("bug", "reproduce_tries"), ("bug", "test")];
147
148/// One warning per [`PROJECT_ONLY`] key `text` sets.
149#[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/// `[ui]`'s look keys from `text` (`[ui.notify]` is [`parse`]'s).
175#[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); // `parse` already said why
184    };
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/// [`load`] from `text`, naming `file` in warnings.
220#[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    // The unit is the last character, which may be more than a byte.
312    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}