use serde::Deserialize;
pub const TAB_SIZE_MIN: usize = 1;
pub const TAB_SIZE_MAX: usize = 16;
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(default)]
pub struct Config {
pub tab_size: usize,
pub indent_guides: bool,
pub indent_style: IndentStyle,
pub indent_detect: bool,
pub auto_format: bool,
pub search_show_hidden: bool,
pub search_respect_ignore: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IndentStyle {
Spaces,
Tabs,
}
impl Default for Config {
fn default() -> Self {
Self {
tab_size: 4,
indent_guides: true,
indent_style: IndentStyle::Spaces,
indent_detect: true,
auto_format: true,
search_show_hidden: true,
search_respect_ignore: true,
}
}
}
pub struct Knob {
pub key: &'static str,
pub kind: &'static str, pub desc: &'static str,
}
pub const KNOBS: &[Knob] = &[
Knob {
key: "tab_size",
kind: "number",
desc: "indent width in spaces",
},
Knob {
key: "indent_guides",
kind: "bool",
desc: "dim │ guide per indent level",
},
Knob {
key: "indent_style",
kind: "string",
desc: "auto-indent unit: spaces or tabs",
},
Knob {
key: "indent_detect",
kind: "bool",
desc: "infer each document's indent from its content",
},
Knob {
key: "auto_format",
kind: "bool",
desc: "format through the language server before :w",
},
Knob {
key: "search_show_hidden",
kind: "bool",
desc: "search shows dotfiles by default",
},
Knob {
key: "search_respect_ignore",
kind: "bool",
desc: "search respects ignore files",
},
];
impl Config {
pub fn knob_value(&self, key: &str) -> Option<String> {
Some(match key {
"tab_size" => self.tab_size.to_string(),
"indent_guides" => self.indent_guides.to_string(),
"indent_style" => format!("{:?}", self.indent_style).to_lowercase(),
"indent_detect" => self.indent_detect.to_string(),
"auto_format" => self.auto_format.to_string(),
"search_show_hidden" => self.search_show_hidden.to_string(),
"search_respect_ignore" => self.search_respect_ignore.to_string(),
_ => return None,
})
}
pub fn print_knobs(&self) {
for k in KNOBS {
let Some(value) = self.knob_value(k.key) else {
continue; };
println!(" {:<16} {:<7} {:<8} {}", k.key, k.kind, value, k.desc);
}
}
pub fn load() -> (Self, Option<String>) {
let Some(path) = config_path() else {
return (Self::default(), None);
};
let Ok(text) = std::fs::read_to_string(&path) else {
return (Self::default(), None); };
let parsed = toml::from_str::<Config>(&text)
.map_err(|e| e.to_string())
.and_then(Config::validated);
match parsed {
Ok(c) => (c, None),
Err(e) => (
Self::default(),
Some(format!("config {}: {e} — using defaults", path.display())),
),
}
}
fn validated(self) -> Result<Self, String> {
if (TAB_SIZE_MIN..=TAB_SIZE_MAX).contains(&self.tab_size) {
Ok(self)
} else {
Err(format!(
"tab_size must be {TAB_SIZE_MIN}–{TAB_SIZE_MAX}, got {}",
self.tab_size
))
}
}
pub fn indent(&self) -> String {
match self.indent_style {
IndentStyle::Spaces => " ".repeat(self.tab_size),
IndentStyle::Tabs => "\t".into(),
}
}
}
fn config_path() -> Option<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})?;
Some(base.join("strop").join("config.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_when_absent() {
let (c, err) = Config::load();
let _ = err; assert!(c.tab_size >= 2);
}
#[test]
fn parses_tab_size() {
let c: Config = toml::from_str("tab_size = 2").unwrap();
assert_eq!(c.tab_size, 2);
assert_eq!(c.indent(), " ");
}
#[test]
fn parses_indent_guides() {
let c: Config = toml::from_str("indent_guides = false").unwrap();
assert!(!c.indent_guides);
let c: Config = toml::from_str("").unwrap();
assert!(c.indent_guides);
}
#[test]
fn knobs_name_real_fields_and_cover_all_of_them() {
for knob in KNOBS {
let snippet = match knob.key {
"indent_style" => "indent_style = \"spaces\"".to_string(),
_ => match knob.kind {
"number" => format!("{k} = 2", k = knob.key),
"bool" => format!("{k} = true", k = knob.key),
_ => format!("{k} = \"x\"", k = knob.key),
},
};
assert!(
toml::from_str::<Config>(&snippet).is_ok(),
"knob {:?} names no config field",
knob.key
);
}
assert_eq!(
KNOBS.len(),
7,
"tab_size, indent_guides, indent_style, indent_detect, auto_format, search_show_hidden, search_respect_ignore"
);
}
#[test]
fn every_knob_resolves_a_real_value() {
let config = Config::default();
for knob in KNOBS {
let value = config.knob_value(knob.key);
assert!(value.is_some(), "knob {:?} has no value", knob.key);
assert_ne!(
value.as_deref(),
Some("?"),
"knob {:?} is a placeholder",
knob.key
);
}
assert!(config.knob_value("not_a_knob").is_none());
}
#[test]
fn malformed_falls_back() {
assert!(toml::from_str::<Config>("tab_size = \"oops\"").is_err());
}
}