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/// Supported indent-width range (0051 R08): manual `:tab-size`
20/// overrides AND the `tab_size` config value. Zero would make Tab a
21/// no-op with a hangover of stale guides; huge values are allocation
22/// and layout hazards — both are refused visibly, never clamped
23/// silently.
24pub const TAB_SIZE_MIN: usize = 1;
25pub const TAB_SIZE_MAX: usize = 16;
26
27#[derive(Debug, Clone, Deserialize, serde::Serialize)]
28#[serde(default)]
29pub struct Config {
30    /// Indent unit width in spaces (`>>`, auto-indent, tab display).
31    /// A document's detected indent overrides this per buffer when
32    /// `indent_detect` is on.
33    pub tab_size: usize,
34    /// Indent guides (dim │ per level) on/off.
35    pub indent_guides: bool,
36    /// What auto-indent, `>>` and the Tab key emit.
37    pub indent_style: IndentStyle,
38    /// Infer an opened document's indent (unit and width) from its
39    /// content; the config above is the fallback and the new-file
40    /// default.
41    pub indent_detect: bool,
42    /// Format through the language server before writing (helix's
43    /// auto-format). A formatter failure never blocks the write.
44    pub auto_format: bool,
45    /// Search surfaces show unignored dotfiles/dotfolders by default
46    /// (0051 R03). `hidden:include|exclude` in a query overrides.
47    pub search_show_hidden: bool,
48    /// Search surfaces respect .gitignore/.ignore/.rgignore (0051 R03).
49    /// `ignored:include|exclude` in a query overrides.
50    pub search_respect_ignore: bool,
51}
52
53/// `indent_style` in config.toml.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum IndentStyle {
57    Spaces,
58    Tabs,
59}
60
61impl Default for Config {
62    fn default() -> Self {
63        Self {
64            tab_size: 4,
65            indent_guides: true,
66            indent_style: IndentStyle::Spaces,
67            indent_detect: true,
68            auto_format: true,
69            search_show_hidden: true,
70            search_respect_ignore: true,
71        }
72    }
73}
74
75/// Knob metadata (0005 §6): the settings popup renders from this table;
76/// descriptions live here, never in popup code.
77pub struct Knob {
78    pub key: &'static str,
79    pub kind: &'static str, // "bool" | "number"
80    pub desc: &'static str,
81}
82
83pub const KNOBS: &[Knob] = &[
84    Knob {
85        key: "tab_size",
86        kind: "number",
87        desc: "indent width in spaces",
88    },
89    Knob {
90        key: "indent_guides",
91        kind: "bool",
92        desc: "dim │ guide per indent level",
93    },
94    Knob {
95        key: "indent_style",
96        kind: "string",
97        desc: "auto-indent unit: spaces or tabs",
98    },
99    Knob {
100        key: "indent_detect",
101        kind: "bool",
102        desc: "infer each document's indent from its content",
103    },
104    Knob {
105        key: "auto_format",
106        kind: "bool",
107        desc: "format through the language server before :w",
108    },
109    Knob {
110        key: "search_show_hidden",
111        kind: "bool",
112        desc: "search shows dotfiles by default",
113    },
114    Knob {
115        key: "search_respect_ignore",
116        kind: "bool",
117        desc: "search respects ignore files",
118    },
119];
120
121impl Config {
122    /// The one typed knob→value projection (0051 R10): `print_knobs`,
123    /// `:explain` and selectors read real values from this path. A key
124    /// absent from KNOBS is `None` — never a fabricated "?" placeholder.
125    pub fn knob_value(&self, key: &str) -> Option<String> {
126        Some(match key {
127            "tab_size" => self.tab_size.to_string(),
128            "indent_guides" => self.indent_guides.to_string(),
129            "indent_style" => format!("{:?}", self.indent_style).to_lowercase(),
130            "indent_detect" => self.indent_detect.to_string(),
131            "auto_format" => self.auto_format.to_string(),
132            "search_show_hidden" => self.search_show_hidden.to_string(),
133            "search_respect_ignore" => self.search_respect_ignore.to_string(),
134            _ => return None,
135        })
136    }
137
138    /// `strop config`: the knobs with live values (KNOBS is the data
139    /// source; this is its first consumer — the settings popup is next).
140    pub fn print_knobs(&self) {
141        for k in KNOBS {
142            let Some(value) = self.knob_value(k.key) else {
143                continue; // tests pin every KNOBS key to a value
144            };
145            println!("  {:<16} {:<7} {:<8} {}", k.key, k.kind, value, k.desc);
146        }
147    }
148
149    /// Load the user config; errors are returned as a message for the
150    /// statusline — the editor always starts with defaults (0005 §2).
151    pub fn load() -> (Self, Option<String>) {
152        let Some(path) = config_path() else {
153            return (Self::default(), None);
154        };
155        let Ok(text) = std::fs::read_to_string(&path) else {
156            return (Self::default(), None); // absent is fine
157        };
158        let parsed = toml::from_str::<Config>(&text)
159            .map_err(|e| e.to_string())
160            .and_then(Config::validated);
161        match parsed {
162            Ok(c) => (c, None),
163            Err(e) => (
164                Self::default(),
165                Some(format!("config {}: {e} — using defaults", path.display())),
166            ),
167        }
168    }
169
170    /// `tab_size` outside the supported range (0051 R08): refused
171    /// visibly like any malformed config — never a silent clamp, never
172    /// a zero-width Tab or an enormous indent allocation.
173    fn validated(self) -> Result<Self, String> {
174        if (TAB_SIZE_MIN..=TAB_SIZE_MAX).contains(&self.tab_size) {
175            Ok(self)
176        } else {
177            Err(format!(
178                "tab_size must be {TAB_SIZE_MIN}–{TAB_SIZE_MAX}, got {}",
179                self.tab_size
180            ))
181        }
182    }
183
184    pub fn indent(&self) -> String {
185        match self.indent_style {
186            IndentStyle::Spaces => " ".repeat(self.tab_size),
187            IndentStyle::Tabs => "\t".into(),
188        }
189    }
190}
191
192fn config_path() -> Option<std::path::PathBuf> {
193    let base = std::env::var_os("XDG_CONFIG_HOME")
194        .map(std::path::PathBuf::from)
195        .or_else(|| {
196            std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
197        })?;
198    Some(base.join("strop").join("config.toml"))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn defaults_when_absent() {
207        let (c, err) = Config::load();
208        let _ = err; // present only when a malformed file exists
209        assert!(c.tab_size >= 2);
210    }
211
212    #[test]
213    fn parses_tab_size() {
214        let c: Config = toml::from_str("tab_size = 2").unwrap();
215        assert_eq!(c.tab_size, 2);
216        assert_eq!(c.indent(), "  ");
217    }
218
219    #[test]
220    fn parses_indent_guides() {
221        let c: Config = toml::from_str("indent_guides = false").unwrap();
222        assert!(!c.indent_guides);
223        // absent → default on
224        let c: Config = toml::from_str("").unwrap();
225        assert!(c.indent_guides);
226    }
227
228    #[test]
229    fn knobs_name_real_fields_and_cover_all_of_them() {
230        // the popup renders from KNOBS: a knob naming no field is dead
231        // weight, a field without a knob is invisible to users.
232        for knob in KNOBS {
233            let snippet = match knob.key {
234                "indent_style" => "indent_style = \"spaces\"".to_string(),
235                _ => match knob.kind {
236                    "number" => format!("{k} = 2", k = knob.key),
237                    "bool" => format!("{k} = true", k = knob.key),
238                    _ => format!("{k} = \"x\"", k = knob.key),
239                },
240            };
241            assert!(
242                toml::from_str::<Config>(&snippet).is_ok(),
243                "knob {:?} names no config field",
244                knob.key
245            );
246        }
247        assert_eq!(
248            KNOBS.len(),
249            7,
250            "tab_size, indent_guides, indent_style, indent_detect, auto_format, search_show_hidden, search_respect_ignore"
251        );
252    }
253
254    #[test]
255    fn every_knob_resolves_a_real_value() {
256        // 0051 R10: one typed access path; a knob that resolves to
257        // None would render as a placeholder or vanish from :explain.
258        let config = Config::default();
259        for knob in KNOBS {
260            let value = config.knob_value(knob.key);
261            assert!(value.is_some(), "knob {:?} has no value", knob.key);
262            assert_ne!(
263                value.as_deref(),
264                Some("?"),
265                "knob {:?} is a placeholder",
266                knob.key
267            );
268        }
269        assert!(config.knob_value("not_a_knob").is_none());
270    }
271
272    #[test]
273    fn malformed_falls_back() {
274        assert!(toml::from_str::<Config>("tab_size = \"oops\"").is_err());
275    }
276}