1use serde::Deserialize;
18
19pub 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 pub tab_size: usize,
34 pub indent_guides: bool,
36 pub indent_style: IndentStyle,
38 pub indent_detect: bool,
42 pub auto_format: bool,
45 pub search_show_hidden: bool,
48 pub search_respect_ignore: bool,
51}
52
53#[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
75pub struct Knob {
78 pub key: &'static str,
79 pub kind: &'static str, 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 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 pub fn print_knobs(&self) {
141 for k in KNOBS {
142 let Some(value) = self.knob_value(k.key) else {
143 continue; };
145 println!(" {:<16} {:<7} {:<8} {}", k.key, k.kind, value, k.desc);
146 }
147 }
148
149 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); };
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 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; 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 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 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 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}