use std::path::{Path, PathBuf};
use std::str::FromStr;
use super::config::Config;
use super::keymap::{parse_key, Action, KeyBindingOverrides};
use super::sections::{
AUTOSAVE_IDLE_MS_DEFAULT, AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE, AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE,
};
use super::theme::Theme;
use super::theme_file::ThemeFile;
use super::warnings::{ConfigWarning, WarningKind};
pub fn list_export_stylesheets(config_dir: &Path) -> Vec<PathBuf> {
if !super::persistence::config_reads_allowed() {
return Vec::new();
}
let export_dir = config_dir.join("export");
let Ok(entries) = std::fs::read_dir(&export_dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("css"))
})
.collect();
files.sort();
files
}
fn deserialize_with_unknown_keys<'de, T>(
raw: &'de str,
) -> std::result::Result<(T, Vec<String>), toml::de::Error>
where
T: serde::de::Deserialize<'de>,
{
let mut unknown: Vec<String> = Vec::new();
let de = toml::Deserializer::parse(raw)?;
let value = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?;
Ok((value, unknown))
}
fn read_and_warn<T, M, F>(
path: &Path,
warnings: &mut Vec<ConfigWarning>,
on_missing: M,
on_parse_failure: F,
) -> T
where
T: serde::de::DeserializeOwned,
M: FnOnce() -> T,
F: FnOnce() -> T,
{
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return on_missing(),
Err(e) => {
warnings.push(ConfigWarning {
path: path.to_path_buf(),
kind: WarningKind::ParseError(format!("Failed to read file: {e}")),
});
return on_parse_failure();
}
};
match deserialize_with_unknown_keys::<T>(&raw) {
Ok((value, unknown)) => {
if !unknown.is_empty() {
warnings.push(ConfigWarning {
path: path.to_path_buf(),
kind: WarningKind::UnknownKeys(unknown),
});
}
value
}
Err(e) => {
warnings.push(ConfigWarning {
path: path.to_path_buf(),
kind: WarningKind::ParseError(e.to_string()),
});
on_parse_failure()
}
}
}
pub(super) fn read_main_config(path: &Path, warnings: &mut Vec<ConfigWarning>) -> Config {
let mut config: Config = read_and_warn(path, warnings, Config::default, Config::default);
validate_main_config(path, &mut config, warnings);
config
}
fn validate_main_config(path: &Path, config: &mut Config, warnings: &mut Vec<ConfigWarning>) {
let idle = config.editor.autosave_idle_ms;
if idle <= AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE || idle >= AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE {
config.editor.autosave_idle_ms = AUTOSAVE_IDLE_MS_DEFAULT;
warnings.push(ConfigWarning {
path: path.to_path_buf(),
kind: WarningKind::InvalidValue {
key: "editor.autosave_idle_ms".to_string(),
message: format!(
"value {idle} is outside the supported range ({} < N < {}); \
using the default ({AUTOSAVE_IDLE_MS_DEFAULT}) instead",
AUTOSAVE_IDLE_MS_MIN_EXCLUSIVE, AUTOSAVE_IDLE_MS_MAX_EXCLUSIVE,
),
},
});
}
}
pub(super) fn read_keybindings(
path: &Path,
warnings: &mut Vec<ConfigWarning>,
) -> KeyBindingOverrides {
let mut overrides: KeyBindingOverrides = read_and_warn(
path,
warnings,
KeyBindingOverrides::default,
KeyBindingOverrides::default,
);
let mut errors = Vec::new();
overrides.0.retain(|action_str, key_str| {
if let Err(e) = Action::from_str(action_str) {
errors.push(format!("{action_str} = \"{key_str}\": {e}"));
return false;
}
if let Err(e) = parse_key(key_str) {
errors.push(format!("{action_str} = \"{key_str}\": {e}"));
return false;
}
true
});
if !errors.is_empty() {
warnings.push(ConfigWarning {
path: path.to_path_buf(),
kind: WarningKind::InvalidKeybindings(errors),
});
}
overrides
}
pub const TRUECOLOR_FALLBACK_THEME: &str = "Edamame";
pub const INDEXED_FALLBACK_THEME: &str = "256 Dark";
pub(super) fn read_theme_named(
config_dir: &Path,
name: &str,
truecolor: bool,
warnings: &mut Vec<ConfigWarning>,
) -> (ThemeFile, Option<String>) {
if let Some(theme) = Theme::builtin(name) {
return ((&theme).into(), None);
}
if !super::persistence::config_reads_allowed() {
let fallback = if truecolor {
TRUECOLOR_FALLBACK_THEME
} else {
INDEXED_FALLBACK_THEME
};
tracing::debug!(
theme = name,
fallback,
"--no-config: not reading a user theme file; using the built-in fallback"
);
let theme = Theme::builtin(fallback).expect("built-in fallback name is valid");
return ((&theme).into(), None);
}
let path = config_dir.join("themes").join(format!("{name}.toml"));
if !path.exists() {
let fallback = if truecolor {
TRUECOLOR_FALLBACK_THEME
} else {
INDEXED_FALLBACK_THEME
};
tracing::warn!(
theme = name,
path = %path.display(),
fallback,
"theme file not found; substituting built-in fallback"
);
warnings.push(ConfigWarning {
path: path.clone(),
kind: WarningKind::MissingTheme {
requested: name.to_string(),
fallback: fallback.to_string(),
},
});
let theme = Theme::builtin(fallback).expect("built-in fallback name is valid");
return ((&theme).into(), Some(fallback.to_string()));
}
let theme_default = || ((&Theme::default()).into(), None);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) => {
warnings.push(ConfigWarning {
path: path.clone(),
kind: WarningKind::ParseError(format!("Failed to read file: {e}")),
});
return theme_default();
}
};
match deserialize_with_unknown_keys::<ThemeFile>(&raw) {
Ok((value, unknown)) => {
if !unknown.is_empty() {
warnings.push(ConfigWarning {
path: path.clone(),
kind: WarningKind::UnknownKeys(unknown),
});
}
(value, None)
}
Err(e) => {
warnings.push(ConfigWarning {
path,
kind: WarningKind::ParseError(e.to_string()),
});
theme_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shipped_reference_config_loads_without_warnings() {
let raw = super::super::init::REFERENCE_CONFIG_TOML;
let (_config, unknown): (Config, Vec<String>) = deserialize_with_unknown_keys(raw)
.expect("the shipped config/config.toml must parse as a Config");
assert!(
unknown.is_empty(),
"config/config.toml documents keys that no longer exist: {unknown:?}"
);
}
#[test]
fn shipped_reference_config_examples_are_all_uncommentable() {
let checked = check_commented_examples(super::super::init::REFERENCE_CONFIG_TOML)
.unwrap_or_else(|e| panic!("{e}"));
assert!(
checked > 20,
"expected the reference config to carry commented examples; only found \
{checked} — has the format changed?"
);
}
#[test]
fn commented_example_scanner_rejects_a_key_that_no_longer_exists() {
let good = "[editor]\n# line_wrap = true\n# [dev]\n# logging = false\n";
assert_eq!(check_commented_examples(good), Ok(2));
let stale = "[editor]\n# line_wrap_renamed = true\n";
let err = check_commented_examples(stale).unwrap_err();
assert!(
err.contains("line_wrap_renamed") && err.contains("[editor]"),
"unhelpful failure message: {err}"
);
let misplaced = "# [dev]\n# line_wrap = true\n";
assert!(check_commented_examples(misplaced).is_err());
let prose = "[editor]\n# Wrap long lines. Default: true.\n# # a .css file.\n";
assert_eq!(check_commented_examples(prose), Ok(0));
let bracket_prose = "[editor]\n# [ ] a task\n# line_wrap = true\n";
assert_eq!(check_commented_examples(bracket_prose), Ok(1));
assert_eq!(
table_header("[export.html]").as_deref(),
Some("[export.html]")
);
assert_eq!(
table_header("[[export.custom]]").as_deref(),
Some("[[export.custom]]")
);
assert_eq!(table_header("[see the note above]"), None);
assert_eq!(table_header("[]"), None);
assert_eq!(table_header("[editor"), None);
}
fn table_header(line: &str) -> Option<String> {
let inner = line
.strip_prefix("[[")
.and_then(|l| l.strip_suffix("]]"))
.or_else(|| line.strip_prefix('[').and_then(|l| l.strip_suffix(']')))?;
let bare = !inner.is_empty()
&& inner.split('.').all(|seg| {
!seg.is_empty()
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
});
bare.then(|| line.to_owned())
}
fn check_commented_examples(raw: &str) -> Result<usize, String> {
let mut section = String::new();
let mut checked = 0;
for (lineno, line) in raw.lines().enumerate() {
let trimmed = line.trim();
let candidate = trimmed.strip_prefix('#').map_or(trimmed, str::trim);
if let Some(header) = table_header(candidate) {
section = header;
continue;
}
let Some(body) = trimmed.strip_prefix('#').map(str::trim) else {
continue;
};
let Some((key, _)) = body.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty()
|| !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
continue;
}
let doc = format!("{section}\n{body}\n");
let (_config, unknown): (Config, Vec<String>) = deserialize_with_unknown_keys(&doc)
.map_err(|e| {
format!(
"config.toml line {}: uncommenting `{body}` under `{section}` \
does not parse: {e}",
lineno + 1
)
})?;
if !unknown.is_empty() {
return Err(format!(
"config.toml line {}: `{key}` under `{section}` is no longer a real \
setting (reported unknown: {unknown:?})",
lineno + 1
));
}
checked += 1;
}
Ok(checked)
}
#[test]
fn shipped_reference_keybindings_are_all_uncommentable() {
let raw = include_str!("../../config/keybindings.toml");
let mut checked = 0;
for line in raw.lines() {
let Some(body) = line.trim_start().strip_prefix('#') else {
continue;
};
let body = body.trim();
let Some((name, value)) = body.split_once('=') else {
continue;
};
let (name, value) = (name.trim(), value.trim());
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric()) {
continue;
}
let Some(chord) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else {
continue;
};
Action::from_str(name)
.unwrap_or_else(|_| panic!("keybindings.toml names unknown action `{name}`"));
parse_key(chord).unwrap_or_else(|_| {
panic!("keybindings.toml shows `{name} = \"{chord}\"`, which does not parse")
});
checked += 1;
}
assert!(
checked > 10,
"expected the reference keybindings file to carry example bindings; \
only found {checked} — has the format changed?"
);
}
#[test]
fn export_stylesheets_are_not_listed_while_the_config_dir_is_disabled() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
let export = dir.path().join("export");
std::fs::create_dir_all(&export).unwrap();
std::fs::write(export.join("mine.css"), "").unwrap();
{
let _disabled = crate::config::persistence::SuppressGuard::new();
assert!(list_export_stylesheets(dir.path()).is_empty());
}
assert_eq!(list_export_stylesheets(dir.path()).len(), 1);
}
#[test]
fn a_user_theme_is_not_read_while_the_config_dir_is_disabled() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
let themes = dir.path().join("themes");
std::fs::create_dir_all(&themes).unwrap();
std::fs::write(themes.join("mine.toml"), "[h1]\nfg = \"red\"\n").unwrap();
let render = |f: &ThemeFile| toml::to_string(f).expect("theme file serialises");
let builtin = render(&(&Theme::builtin(TRUECOLOR_FALLBACK_THEME).unwrap()).into());
let mut warnings = Vec::new();
{
let _disabled = crate::config::persistence::SuppressGuard::new();
let (file, fallback) = read_theme_named(dir.path(), "mine", true, &mut warnings);
assert_eq!(fallback, None, "nothing to persist — the file went unread");
assert!(warnings.is_empty(), "excluded is not missing: {warnings:?}");
assert_eq!(render(&file), builtin);
}
let (file, fallback) = read_theme_named(dir.path(), "mine", true, &mut warnings);
assert_eq!(fallback, None);
assert_ne!(
render(&file),
builtin,
"the user theme should have been read"
);
}
#[test]
fn list_export_stylesheets_finds_css_sorted_and_ignores_others() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
let export = dir.path().join("export");
std::fs::create_dir_all(&export).unwrap();
std::fs::write(export.join("zebra.css"), "").unwrap();
std::fs::write(export.join("default.css"), "").unwrap();
std::fs::write(export.join("notes.txt"), "").unwrap();
std::fs::write(export.join("UPPER.CSS"), "").unwrap();
std::fs::write(export.join("default.css.example"), "").unwrap();
let found = list_export_stylesheets(dir.path());
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["UPPER.CSS", "default.css", "zebra.css"]);
}
#[test]
fn list_export_stylesheets_missing_dir_is_empty() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
assert!(list_export_stylesheets(dir.path()).is_empty());
}
}