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, deny_unknown_fields)]
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,
pub cursor_fade: bool,
pub completion: CompletionSettings,
pub theme: strop_core::theme::ThemeId,
#[serde(skip)]
pub(crate) provenance: Provenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CompletionSettings {
pub enabled: bool,
pub auto_popup: bool,
}
impl Default for CompletionSettings {
fn default() -> Self {
Self {
enabled: true,
auto_popup: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLayer {
Default,
User,
}
impl ConfigLayer {
pub fn label(self) -> &'static str {
match self {
ConfigLayer::Default => "default",
ConfigLayer::User => "user",
}
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct Provenance {
pub(crate) user_path: Option<std::path::PathBuf>,
user_keys: std::collections::BTreeSet<String>,
}
#[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,
cursor_fade: true,
completion: CompletionSettings::default(),
theme: strop_core::theme::ThemeId::Dark,
provenance: Provenance::default(),
}
}
}
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",
},
Knob {
key: "cursor_fade",
kind: "bool",
desc: "fade the Normal-mode cursor in after jumps/focus returns",
},
Knob {
key: "completion.enabled",
kind: "bool",
desc: "code completion and its source-index lifecycle",
},
Knob {
key: "completion.auto_popup",
kind: "bool",
desc: "automatic suggestions while typing; off keeps manual completion",
},
Knob {
key: "theme",
kind: "string",
desc: "built-in interface palette; :theme lists choices",
},
];
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(),
"cursor_fade" => self.cursor_fade.to_string(),
"completion.enabled" => self.completion.enabled.to_string(),
"completion.auto_popup" => self.completion.auto_popup.to_string(),
"theme" => self.theme.name().to_string(),
_ => return None,
})
}
pub fn print_knobs(&self) {
let rows = KNOBS
.iter()
.filter_map(|knob| {
let Some(value) = self.knob_value(knob.key) else {
debug_assert!(false, "declared config knob has no value projection");
return None;
};
Some((knob, value, self.knob_layer(knob.key).label()))
})
.collect::<Vec<_>>();
let key_width = rows.iter().map(|row| row.0.key.len()).max().unwrap_or(0);
let kind_width = rows.iter().map(|row| row.0.kind.len()).max().unwrap_or(0);
let value_width = rows.iter().map(|row| row.1.len()).max().unwrap_or(0);
let layer_width = rows.iter().map(|row| row.2.len()).max().unwrap_or(0);
for (knob, value, layer) in rows {
println!(
" {:<key_width$} {:<kind_width$} {:<value_width$} {:<layer_width$} {}",
knob.key, knob.kind, value, layer, knob.desc
);
}
}
pub fn knob_layer(&self, key: &str) -> ConfigLayer {
if self.provenance.user_keys.contains(key) {
ConfigLayer::User
} else {
ConfigLayer::Default
}
}
pub fn user_layer_path(&self) -> Option<&std::path::Path> {
self.provenance.user_path.as_deref()
}
pub fn load() -> (Self, Option<String>) {
let Some(path) = config_path() else {
return (Self::default(), None);
};
Self::load_from(&path)
}
pub fn load_from(path: &std::path::Path) -> (Self, Option<String>) {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return (Self::default(), None);
}
Err(error) => {
return (
Self::default(),
Some(format!(
"config {}: {error} — using defaults",
path.display()
)),
);
}
};
match Self::parse(&text) {
Ok((mut config, keys)) => {
config.provenance = Provenance {
user_path: Some(path.to_path_buf()),
user_keys: keys,
};
(config, None)
}
Err(e) => (
Self::default(),
Some(format!("config {}: {e} — using defaults", path.display())),
),
}
}
fn parse(text: &str) -> Result<(Self, std::collections::BTreeSet<String>), String> {
let table = toml::from_str::<toml::Table>(text).map_err(|e| e.to_string())?;
let mut keys: std::collections::BTreeSet<String> = table.keys().cloned().collect();
if let Some(completion) = table.get("completion").and_then(toml::Value::as_table) {
for key in ["enabled", "auto_popup"] {
if completion.contains_key(key) {
keys.insert(format!("completion.{key}"));
}
}
}
let config = toml::from_str::<Config>(text)
.map_err(|e| e.to_string())
.and_then(Config::validated)?;
Ok((config, keys))
}
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 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);
}
#[test]
fn completion_provenance_tracks_individual_nested_controls() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[completion]\nenabled = false\n").unwrap();
let (disabled, error) = Config::load_from(&path);
assert!(error.is_none());
assert_eq!(
disabled.knob_value("completion.enabled").as_deref(),
Some("false")
);
assert_eq!(disabled.knob_layer("completion.enabled"), ConfigLayer::User);
assert_eq!(
disabled.knob_layer("completion.auto_popup"),
ConfigLayer::Default
);
std::fs::write(&path, "[completion]\nauto_popup = false\n").unwrap();
let (manual, error) = Config::load_from(&path);
assert!(error.is_none());
assert_eq!(
manual.knob_value("completion.auto_popup").as_deref(),
Some("false")
);
assert_eq!(
manual.knob_layer("completion.auto_popup"),
ConfigLayer::User
);
assert_eq!(
manual.knob_layer("completion.enabled"),
ConfigLayer::Default
);
assert!(Config::parse("[completion]\nenabled = \"false\"\n").is_err());
}
#[test]
fn provenance_names_the_actual_winning_layer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "tab_size = 8\nauto_format = false\n").unwrap();
let (config, error) = Config::load_from(&path);
assert!(error.is_none());
assert_eq!(config.tab_size, 8);
assert_eq!(config.knob_layer("tab_size"), ConfigLayer::User);
assert_eq!(config.knob_layer("auto_format"), ConfigLayer::User);
assert_eq!(config.knob_layer("indent_guides"), ConfigLayer::Default);
assert_eq!(config.knob_layer("cursor_fade"), ConfigLayer::Default);
assert_eq!(config.user_layer_path(), Some(path.as_path()));
let bare: Config = toml::from_str("tab_size = 2").unwrap();
assert_eq!(bare.knob_layer("tab_size"), ConfigLayer::Default);
assert!(bare.user_layer_path().is_none());
}
#[test]
fn malformed_layer_sets_no_provenance() {
assert!(Config::parse("tab_size = \"oops\"").is_err());
assert!(
Config::parse("tab_size = 99").is_err(),
"validated() still gates"
);
}
#[test]
fn missing_config_file_remains_silent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.toml");
let (config, error) = Config::load_from(&path);
assert!(error.is_none());
assert_eq!(config.tab_size, Config::default().tab_size);
assert!(config.user_layer_path().is_none());
}
#[test]
fn non_not_found_config_read_reports_error() {
let dir = tempfile::tempdir().unwrap();
let (config, error) = Config::load_from(dir.path());
let error = error.expect("reading a directory as config must be reported");
assert!(error.contains(&dir.path().display().to_string()), "{error}");
assert!(error.contains("using defaults"), "{error}");
assert_eq!(config.tab_size, Config::default().tab_size);
assert!(config.user_layer_path().is_none());
}
#[test]
fn invalid_utf8_config_reports_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, [0xff, 0xfe]).unwrap();
let (config, error) = Config::load_from(&path);
let error = error.expect("invalid UTF-8 must be reported");
assert!(error.contains(&path.display().to_string()), "{error}");
assert!(error.contains("using defaults"), "{error}");
assert_eq!(config.tab_size, Config::default().tab_size);
assert!(config.user_layer_path().is_none());
}
#[test]
fn unknown_config_keys_are_named_and_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
for (source, unknown_key) in [
("tabsize = 8\n", "tabsize"),
("[completion]\nautoComplete = false\n", "autoComplete"),
] {
std::fs::write(&path, source).unwrap();
let (config, error) = Config::load_from(&path);
let error = error.unwrap_or_else(|| {
panic!("unknown key {unknown_key:?} must reject the config layer")
});
assert!(error.contains(unknown_key), "{error}");
assert!(error.contains("using defaults"), "{error}");
assert_eq!(config.tab_size, Config::default().tab_size);
assert!(config.completion.auto_popup);
assert!(config.user_layer_path().is_none());
}
}
#[test]
fn config_columns_fit_real_keys_and_theme_names() {
const CHILD: &str = "STROP_CONFIG_COLUMNS_TEST_CHILD";
if std::env::var_os(CHILD).is_some() {
let config = Config {
theme: strop_core::theme::ThemeId::CatppuccinMocha,
..Config::default()
};
println!();
config.print_knobs();
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"config::tests::config_columns_fit_real_keys_and_theme_names",
"--test-threads=1",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"config output child failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("catppuccin-mocha"), "{stdout}");
let mut description_column = None;
for knob in KNOBS {
let prefix = format!(" {}", knob.key);
let row = stdout
.lines()
.find(|line| line.contains(&prefix))
.unwrap_or_else(|| panic!("missing {} row:\n{stdout}", knob.key));
let column = row
.find(knob.desc)
.unwrap_or_else(|| panic!("missing description in {row:?}"));
if let Some(expected) = description_column {
assert_eq!(
column, expected,
"description columns must align:\n{stdout}"
);
} else {
description_column = Some(column);
}
}
}
}