fix(config): report unknown theme names

refs #2452
This commit is contained in:
Ogulcan Celik
2026-08-07 19:18:10 +03:00
parent b0723b7906
commit 1910cd949d
6 changed files with 158 additions and 34 deletions
+1
View File
@@ -8,6 +8,7 @@
- The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits.
### Fixed
- `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452)
- macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet)
- Prefix keybindings now preserve Shift in WezTerm Kitty keyboard mode, so commands such as config reload no longer trigger their unshifted action. (#2435)
- Stable direct installs, self-updates, and remote helper downloads now require and verify the SHA-256 digest published for each GitHub release asset.
+13 -32
View File
@@ -558,24 +558,24 @@ impl Palette {
/// Resolve a theme by name. Returns None for unknown names.
pub fn from_name(name: &str) -> Option<Self> {
match name.to_lowercase().replace([' ', '_'], "-").as_str() {
"catppuccin" | "catppuccin-mocha" => Some(Self::catppuccin()),
"catppuccin-latte" | "latte" | "light" => Some(Self::catppuccin_latte()),
match crate::config::canonical_theme_name(name)? {
"catppuccin" => Some(Self::catppuccin()),
"catppuccin-latte" => Some(Self::catppuccin_latte()),
"terminal" => Some(Self::terminal()),
"tokyo-night" | "tokyonight" => Some(Self::tokyo_night()),
"tokyo-night-day" | "tokyo-day" | "tokyonight-day" => Some(Self::tokyo_night_day()),
"tokyo-night" => Some(Self::tokyo_night()),
"tokyo-night-day" => Some(Self::tokyo_night_day()),
"dracula" => Some(Self::dracula()),
"nord" => Some(Self::nord()),
"gruvbox" | "gruvbox-dark" => Some(Self::gruvbox()),
"gruvbox" => Some(Self::gruvbox()),
"gruvbox-light" => Some(Self::gruvbox_light()),
"one-dark" | "onedark" => Some(Self::one_dark()),
"one-light" | "onelight" => Some(Self::one_light()),
"solarized" | "solarized-dark" => Some(Self::solarized()),
"one-dark" => Some(Self::one_dark()),
"one-light" => Some(Self::one_light()),
"solarized" => Some(Self::solarized()),
"solarized-light" => Some(Self::solarized_light()),
"kanagawa" => Some(Self::kanagawa()),
"kanagawa-lotus" | "lotus" => Some(Self::kanagawa_lotus()),
"rose-pine" | "rosepine" => Some(Self::rose_pine()),
"rose-pine-dawn" | "rosepine-dawn" | "dawn" => Some(Self::rose_pine_dawn()),
"kanagawa-lotus" => Some(Self::kanagawa_lotus()),
"rose-pine" => Some(Self::rose_pine()),
"rose-pine-dawn" => Some(Self::rose_pine_dawn()),
"vesper" => Some(Self::vesper()),
_ => None,
}
@@ -1044,26 +1044,7 @@ impl SettingsSection {
}
/// All built-in theme names in display order.
pub const THEME_NAMES: &[&str] = &[
"catppuccin",
"catppuccin-latte",
"terminal",
"tokyo-night",
"tokyo-night-day",
"dracula",
"nord",
"gruvbox",
"gruvbox-light",
"one-dark",
"one-light",
"solarized",
"solarized-light",
"kanagawa",
"kanagawa-lotus",
"rose-pine",
"rose-pine-dawn",
"vesper",
];
pub const THEME_NAMES: &[&str] = crate::config::THEME_NAMES;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MenuListState {
+3 -2
View File
@@ -30,11 +30,11 @@ pub use self::{
SpaceSidebarToken, SpacesSidebarConfig,
},
sound::SoundConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig},
theme::{parse_color, CustomThemeColors, ThemeConfig, THEME_NAMES},
};
pub(crate) use self::io::upsert_top_level_bool;
pub(crate) use self::keybinds::parse_key_combo;
pub(crate) use self::{io::upsert_top_level_bool, theme::canonical_theme_name};
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
@@ -72,6 +72,7 @@ impl Config {
.into_iter()
.chain(keybind_diags)
.chain(self.remote_image_paste_key().err())
.chain(self.theme.diagnostics())
.chain(self.ui.sound.diagnostics())
.chain(self.invalid_sidebar_bounds_diagnostic())
.collect()
+16
View File
@@ -341,6 +341,8 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
|section| config.remote = section,
);
diagnostics.extend(config.theme.diagnostics());
Ok(LoadedConfig {
config,
diagnostics,
@@ -863,6 +865,20 @@ resume_agents_on_restore = true
assert!(loaded.invalid_sections.is_empty());
}
#[test]
fn load_live_config_warns_about_unknown_theme_names() {
let loaded = load_live_config_from_str(
r#"
[theme]
name = "catppucin"
"#,
)
.unwrap();
assert_eq!(loaded.diagnostics.len(), 1);
assert!(loaded.diagnostics[0].contains("theme.name = \"catppucin\""));
}
#[test]
fn load_live_config_warns_about_unknown_top_level_sections() {
let loaded = load_live_config_from_str(
+98
View File
@@ -1,6 +1,51 @@
use serde::Deserialize;
use tracing::warn;
pub const THEME_NAMES: &[&str] = &[
"catppuccin",
"catppuccin-latte",
"terminal",
"tokyo-night",
"tokyo-night-day",
"dracula",
"nord",
"gruvbox",
"gruvbox-light",
"one-dark",
"one-light",
"solarized",
"solarized-light",
"kanagawa",
"kanagawa-lotus",
"rose-pine",
"rose-pine-dawn",
"vesper",
];
pub(crate) fn canonical_theme_name(name: &str) -> Option<&'static str> {
match name.to_lowercase().replace([' ', '_'], "-").as_str() {
"catppuccin" | "catppuccin-mocha" => Some("catppuccin"),
"catppuccin-latte" | "latte" | "light" => Some("catppuccin-latte"),
"terminal" => Some("terminal"),
"tokyo-night" | "tokyonight" => Some("tokyo-night"),
"tokyo-night-day" | "tokyo-day" | "tokyonight-day" => Some("tokyo-night-day"),
"dracula" => Some("dracula"),
"nord" => Some("nord"),
"gruvbox" | "gruvbox-dark" => Some("gruvbox"),
"gruvbox-light" => Some("gruvbox-light"),
"one-dark" | "onedark" => Some("one-dark"),
"one-light" | "onelight" => Some("one-light"),
"solarized" | "solarized-dark" => Some("solarized"),
"solarized-light" => Some("solarized-light"),
"kanagawa" => Some("kanagawa"),
"kanagawa-lotus" | "lotus" => Some("kanagawa-lotus"),
"rose-pine" | "rosepine" => Some("rose-pine"),
"rose-pine-dawn" | "rosepine-dawn" | "dawn" => Some("rose-pine-dawn"),
"vesper" => Some("vesper"),
_ => None,
}
}
/// Theme configuration: pick a built-in or override individual tokens.
///
/// ```toml
@@ -26,6 +71,31 @@ pub struct ThemeConfig {
pub custom: Option<CustomThemeColors>,
}
impl ThemeConfig {
pub(crate) fn diagnostics(&self) -> Vec<String> {
let valid = THEME_NAMES.join(", ");
[
("theme.name", self.name.as_deref(), "catppuccin"),
("theme.dark_name", self.dark_name.as_deref(), "catppuccin"),
(
"theme.light_name",
self.light_name.as_deref(),
"catppuccin-latte",
),
]
.into_iter()
.filter_map(|(field, value, fallback)| {
let value = value?;
canonical_theme_name(value).is_none().then(|| {
format!(
"unknown theme name {field} = {value:?}; using {fallback:?}; valid themes: {valid}"
)
})
})
.collect()
}
}
/// Per-token color overrides. All fields optional — only set what you want to change.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
@@ -132,6 +202,34 @@ name = "dracula"
assert_eq!(config.theme.name.as_deref(), Some("dracula"));
}
#[test]
fn unknown_theme_names_are_diagnosed() {
let config: Config = toml::from_str(
r#"
[theme]
name = "catppucin"
dark_name = "tokio-night"
light_name = "lattee"
"#,
)
.unwrap();
let diagnostics = config.theme.diagnostics();
assert_eq!(diagnostics.len(), 3);
assert!(diagnostics[0].contains("theme.name = \"catppucin\""));
assert!(diagnostics[0].contains("using \"catppuccin\""));
assert!(diagnostics[1].contains("theme.dark_name = \"tokio-night\""));
assert!(diagnostics[2].contains("theme.light_name = \"lattee\""));
assert!(diagnostics[2].contains("using \"catppuccin-latte\""));
}
#[test]
fn theme_name_aliases_are_valid() {
for name in ["catppuccin-mocha", "tokyonight", "gruvbox-dark", "dawn"] {
assert!(canonical_theme_name(name).is_some(), "alias: {name}");
}
}
#[test]
fn parse_color_accepts_reset_aliases() {
use ratatui::style::Color;
+27
View File
@@ -496,6 +496,33 @@ new_tabb = "ctrl+t"
cleanup_test_base(&base);
}
#[test]
fn config_check_reports_unknown_theme_names() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let config_dir = config_home.join(app_dir_name());
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("config.toml"),
"[theme]\nname = \"catppucin\"\n",
)
.unwrap();
let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
assert_eq!(checked.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&checked.stdout);
assert!(stdout.contains("config: issues found"), "{stdout}");
assert!(
stdout.contains("unknown theme name theme.name = \"catppucin\"; using \"catppuccin\""),
"{stdout}"
);
assert!(stdout.contains("valid themes: catppuccin"), "{stdout}");
cleanup_test_base(&base);
}
#[test]
fn config_check_reports_unreadable_config_path() {
let base = unique_test_dir();