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//! indent_style = "spaces"   # or "tabs"
9//! indent_detect = true      # infer a document's indent from its content
10//! auto_format = true        # LSP format before :w (helix parity)
11//! ```
12//!
13//! LSP server config is a separate file with its own layering —
14//! `languages.toml`, helix-shaped, owned by strop-lsp (0012): project
15//! `.strop/languages.toml` > XDG > the embedded registry.
16
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Deserialize, serde::Serialize)]
20#[serde(default)]
21pub struct Config {
22    /// Indent unit width in spaces (`>>`, auto-indent, tab display).
23    /// A document's detected indent overrides this per buffer when
24    /// `indent_detect` is on.
25    pub tab_size: usize,
26    /// Indent guides (dim │ per level) on/off.
27    pub indent_guides: bool,
28    /// What auto-indent, `>>` and the Tab key emit.
29    pub indent_style: IndentStyle,
30    /// Infer an opened document's indent (unit and width) from its
31    /// content; the config above is the fallback and the new-file
32    /// default.
33    pub indent_detect: bool,
34    /// Format through the language server before writing (helix's
35    /// auto-format). A formatter failure never blocks the write.
36    pub auto_format: bool,
37}
38
39/// `indent_style` in config.toml.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum IndentStyle {
43    Spaces,
44    Tabs,
45}
46
47impl Default for Config {
48    fn default() -> Self {
49        Self {
50            tab_size: 4,
51            indent_guides: true,
52            indent_style: IndentStyle::Spaces,
53            indent_detect: true,
54            auto_format: true,
55        }
56    }
57}
58
59/// Knob metadata (0005 §6): the settings popup renders from this table;
60/// descriptions live here, never in popup code.
61pub struct Knob {
62    pub key: &'static str,
63    pub kind: &'static str, // "bool" | "number"
64    pub desc: &'static str,
65}
66
67pub const KNOBS: &[Knob] = &[
68    Knob {
69        key: "tab_size",
70        kind: "number",
71        desc: "indent width in spaces",
72    },
73    Knob {
74        key: "indent_guides",
75        kind: "bool",
76        desc: "dim │ guide per indent level",
77    },
78    Knob {
79        key: "indent_style",
80        kind: "string",
81        desc: "auto-indent unit: spaces or tabs",
82    },
83    Knob {
84        key: "indent_detect",
85        kind: "bool",
86        desc: "infer each document's indent from its content",
87    },
88    Knob {
89        key: "auto_format",
90        kind: "bool",
91        desc: "format through the language server before :w",
92    },
93];
94
95impl Config {
96    /// `strop config`: the knobs with live values (KNOBS is the data
97    /// source; this is its first consumer — the settings popup is next).
98    pub fn print_knobs(&self) {
99        for k in KNOBS {
100            let value = match k.key {
101                "tab_size" => self.tab_size.to_string(),
102                "indent_guides" => self.indent_guides.to_string(),
103                "indent_style" => format!("{:?}", self.indent_style).to_lowercase(),
104                "indent_detect" => self.indent_detect.to_string(),
105                "auto_format" => self.auto_format.to_string(),
106                _ => "?".into(),
107            };
108            println!("  {:<16} {:<7} {:<8} {}", k.key, k.kind, value, k.desc);
109        }
110    }
111
112    /// Load the user config; errors are returned as a message for the
113    /// statusline — the editor always starts with defaults (0005 §2).
114    pub fn load() -> (Self, Option<String>) {
115        let Some(path) = config_path() else {
116            return (Self::default(), None);
117        };
118        let Ok(text) = std::fs::read_to_string(&path) else {
119            return (Self::default(), None); // absent is fine
120        };
121        match toml::from_str::<Config>(&text) {
122            Ok(c) => (c, None),
123            Err(e) => (
124                Self::default(),
125                Some(format!("config {}: {e} — using defaults", path.display())),
126            ),
127        }
128    }
129
130    pub fn indent(&self) -> String {
131        match self.indent_style {
132            IndentStyle::Spaces => " ".repeat(self.tab_size),
133            IndentStyle::Tabs => "\t".into(),
134        }
135    }
136}
137
138fn config_path() -> Option<std::path::PathBuf> {
139    let base = std::env::var_os("XDG_CONFIG_HOME")
140        .map(std::path::PathBuf::from)
141        .or_else(|| {
142            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
143        })?;
144    Some(base.join("strop").join("config.toml"))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn defaults_when_absent() {
153        let (c, err) = Config::load();
154        let _ = err; // present only when a malformed file exists
155        assert!(c.tab_size >= 2);
156    }
157
158    #[test]
159    fn parses_tab_size() {
160        let c: Config = toml::from_str("tab_size = 2").unwrap();
161        assert_eq!(c.tab_size, 2);
162        assert_eq!(c.indent(), "  ");
163    }
164
165    #[test]
166    fn parses_indent_guides() {
167        let c: Config = toml::from_str("indent_guides = false").unwrap();
168        assert!(!c.indent_guides);
169        // absent → default on
170        let c: Config = toml::from_str("").unwrap();
171        assert!(c.indent_guides);
172    }
173
174    #[test]
175    fn knobs_name_real_fields_and_cover_all_of_them() {
176        // the popup renders from KNOBS: a knob naming no field is dead
177        // weight, a field without a knob is invisible to users.
178        for knob in KNOBS {
179            let snippet = match knob.key {
180                "indent_style" => "indent_style = \"spaces\"".to_string(),
181                _ => match knob.kind {
182                    "number" => format!("{k} = 2", k = knob.key),
183                    "bool" => format!("{k} = true", k = knob.key),
184                    _ => format!("{k} = \"x\"", k = knob.key),
185                },
186            };
187            assert!(
188                toml::from_str::<Config>(&snippet).is_ok(),
189                "knob {:?} names no config field",
190                knob.key
191            );
192        }
193        assert_eq!(
194            KNOBS.len(),
195            5,
196            "tab_size, indent_guides, indent_style, indent_detect, auto_format"
197        );
198    }
199
200    #[test]
201    fn malformed_falls_back() {
202        assert!(toml::from_str::<Config>("tab_size = \"oops\"").is_err());
203    }
204}