Skip to main content

strop_engine/
config.rs

1//! Configuration (0005-lite): real TOML from day one, embedded defaults,
2//! never bricks (0005 §2). The full layering/hot-reload/settings-popup
3//! arrives with 0005 proper; this is the editor-facing config object.
4//!
5//! `$XDG_CONFIG_HOME/strop/config.toml` (or ~/.config/strop/config.toml):
6//! ```toml
7//! tab_size = 4
8//! ```
9//!
10//! LSP server config is a separate file with its own layering —
11//! `languages.toml`, helix-shaped, owned by strop-lsp (0012): project
12//! `.strop/languages.toml` > XDG > the embedded registry.
13
14use serde::Deserialize;
15
16#[derive(Debug, Clone, Deserialize, serde::Serialize)]
17#[serde(default)]
18pub struct Config {
19    /// Indent unit in spaces (`>>`, auto-indent). Tabs land with 0005's
20    /// full option set.
21    pub tab_size: usize,
22    /// Indent guides (dim │ per level) on/off.
23    pub indent_guides: bool,
24}
25
26impl Default for Config {
27    fn default() -> Self {
28        Self {
29            tab_size: 4,
30            indent_guides: true,
31        }
32    }
33}
34
35/// Knob metadata (0005 §6): the settings popup renders from this table;
36/// descriptions live here, never in popup code.
37pub struct Knob {
38    pub key: &'static str,
39    pub kind: &'static str, // "bool" | "number"
40    pub desc: &'static str,
41}
42
43pub const KNOBS: &[Knob] = &[
44    Knob {
45        key: "tab_size",
46        kind: "number",
47        desc: "indent width in spaces",
48    },
49    Knob {
50        key: "indent_guides",
51        kind: "bool",
52        desc: "dim │ guide per indent level",
53    },
54];
55
56impl Config {
57    /// `strop config`: the knobs with live values (KNOBS is the data
58    /// source; this is its first consumer — the settings popup is next).
59    pub fn print_knobs(&self) {
60        for k in KNOBS {
61            let value = match k.key {
62                "tab_size" => self.tab_size.to_string(),
63                "indent_guides" => self.indent_guides.to_string(),
64                _ => "?".into(),
65            };
66            println!("  {:<16} {:<7} {:<8} {}", k.key, k.kind, value, k.desc);
67        }
68    }
69
70    /// Load the user config; errors are returned as a message for the
71    /// statusline — the editor always starts with defaults (0005 §2).
72    pub fn load() -> (Self, Option<String>) {
73        let Some(path) = config_path() else {
74            return (Self::default(), None);
75        };
76        let Ok(text) = std::fs::read_to_string(&path) else {
77            return (Self::default(), None); // absent is fine
78        };
79        match toml::from_str::<Config>(&text) {
80            Ok(c) => (c, None),
81            Err(e) => (
82                Self::default(),
83                Some(format!("config {}: {e} — using defaults", path.display())),
84            ),
85        }
86    }
87
88    pub fn indent(&self) -> String {
89        " ".repeat(self.tab_size)
90    }
91}
92
93fn config_path() -> Option<std::path::PathBuf> {
94    let base = std::env::var_os("XDG_CONFIG_HOME")
95        .map(std::path::PathBuf::from)
96        .or_else(|| {
97            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
98        })?;
99    Some(base.join("strop").join("config.toml"))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn defaults_when_absent() {
108        let (c, err) = Config::load();
109        let _ = err; // present only when a malformed file exists
110        assert!(c.tab_size >= 2);
111    }
112
113    #[test]
114    fn parses_tab_size() {
115        let c: Config = toml::from_str("tab_size = 2").unwrap();
116        assert_eq!(c.tab_size, 2);
117        assert_eq!(c.indent(), "  ");
118    }
119
120    #[test]
121    fn parses_indent_guides() {
122        let c: Config = toml::from_str("indent_guides = false").unwrap();
123        assert!(!c.indent_guides);
124        // absent → default on
125        let c: Config = toml::from_str("").unwrap();
126        assert!(c.indent_guides);
127    }
128
129    #[test]
130    fn knobs_table_covers_every_field() {
131        // the popup renders from KNOBS; a field without a knob is invisible
132        // to users — keep the two in lockstep
133        assert_eq!(KNOBS.len(), 2);
134    }
135
136    #[test]
137    fn malformed_falls_back() {
138        assert!(toml::from_str::<Config>("tab_size = \"oops\"").is_err());
139    }
140}