diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 16:17:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 16:17:52 +0300 |
| commit | eb8c8c585229b07b33a3746ab061adc4c728420c (patch) | |
| tree | 1d41f20b64a5354c7f08b490175da6a26a30c6c8 | |
| parent | 226e87306deaba017d36583f51ae34e42b303aad (diff) | |
fix: warn on unknown config sections
| -rw-r--r-- | docs/next/CHANGELOG.md | 3 | ||||
| -rw-r--r-- | src/config/io.rs | 140 |
2 files changed, 141 insertions, 2 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 13e84347..d30ef572 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -19,6 +19,9 @@ - Bumped the client/server protocol version to 14 for `pane.move` compatibility. (#299) - Public workspace, tab, and pane ids are now short stable handles such as `w1`, `w1:t1`, and `w1:p1`; closed tab and pane ids no longer retarget later resources. (#569) +### Fixed +- Config startup and reload now warn about unknown top-level table sections, including a `[toast]` hint that points to `[ui.toast]`, instead of silently ignoring them. + ## [0.6.10] - 2026-06-11 This is a hotfix release for v0.6.9. See the v0.6.9 notes for the full feature release. diff --git a/src/config/io.rs b/src/config/io.rs index 984f25e0..f8c2eb90 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -4,6 +4,20 @@ use tracing::warn; use super::{model::LoadedConfig, Config, CONFIG_PATH_ENV_VAR}; +const KNOWN_TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ + "advanced", + "experimental", + "keys", + "onboarding", + "remote", + "session", + "terminal", + "theme", + "ui", + "update", + "worktrees", +]; + pub fn app_dir_name() -> &'static str { if cfg!(debug_assertions) { "herdr-dev" @@ -85,7 +99,9 @@ impl Config { match std::fs::read_to_string(&path) { Ok(content) => match toml::from_str::<Config>(&content) { Ok(config) => { - let diagnostics = config.collect_diagnostics(); + let mut diagnostics = + unknown_top_level_section_diagnostics_from_str(&content); + diagnostics.extend(config.collect_diagnostics()); return LoadedConfig { config, diagnostics, @@ -183,7 +199,7 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>> })?; let mut config = Config::default(); - let mut diagnostics = Vec::new(); + let mut diagnostics = unknown_top_level_section_diagnostics(table); let mut invalid_sections = Vec::new(); if let Some(value) = table.get("onboarding") { @@ -283,6 +299,48 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>> }) } +fn unknown_top_level_section_diagnostics_from_str(content: &str) -> Vec<String> { + content + .parse::<toml::Value>() + .ok() + .and_then(|value| value.as_table().map(unknown_top_level_section_diagnostics)) + .unwrap_or_default() +} + +fn unknown_top_level_section_diagnostics( + table: &toml::map::Map<String, toml::Value>, +) -> Vec<String> { + table + .iter() + .filter_map(|(key, value)| unknown_top_level_section_diagnostic(key, value)) + .collect() +} + +fn unknown_top_level_section_diagnostic(key: &str, value: &toml::Value) -> Option<String> { + if KNOWN_TOP_LEVEL_CONFIG_KEYS.contains(&key) { + return None; + } + + let header = if value.is_table() { + format!("[{key}]") + } else if value + .as_array() + .is_some_and(|items| !items.is_empty() && items.iter().all(toml::Value::is_table)) + { + format!("[[{key}]]") + } else { + return None; + }; + + if key == "toast" { + Some(format!( + "unknown config section {header}; did you mean [ui.toast]? ignoring section" + )) + } else { + Some(format!("unknown config section {header}; ignoring section")) + } +} + fn load_live_section<T>( table: &toml::map::Map<String, toml::Value>, section: &'static str, @@ -549,6 +607,84 @@ resume_agents_on_restore = true } #[test] + fn load_live_config_warns_about_unknown_top_level_sections() { + let loaded = load_live_config_from_str( + r#" +[toast] +delivery = "system" + +[ui.toast] +delivery = "herdr" +"#, + ) + .unwrap(); + + assert_eq!( + loaded.diagnostics, + vec!["unknown config section [toast]; did you mean [ui.toast]? ignoring section"] + ); + assert!(loaded.invalid_sections.is_empty()); + assert_eq!( + loaded.config.ui.toast.delivery, + super::super::ToastDelivery::Herdr + ); + } + + #[test] + fn load_live_config_does_not_warn_about_unknown_top_level_scalar_values() { + let loaded = load_live_config_from_str( + r#" +plugin = [] + +[ui.toast] +delivery = "herdr" +"#, + ) + .unwrap(); + + assert!(loaded.diagnostics.is_empty()); + assert_eq!( + loaded.config.ui.toast.delivery, + super::super::ToastDelivery::Herdr + ); + } + + #[test] + fn startup_config_load_warns_about_unknown_top_level_sections() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let path = std::env::temp_dir().join(format!( + "herdr-config-unknown-section-{}.toml", + std::process::id() + )); + std::fs::write( + &path, + r#" +[[plugin]] +id = "example" + +[ui.toast] +delivery = "system" +"#, + ) + .unwrap(); + std::env::set_var(CONFIG_PATH_ENV_VAR, &path); + + let loaded = Config::load(); + + assert_eq!( + loaded.diagnostics, + vec!["unknown config section [[plugin]]; ignoring section"] + ); + assert_eq!( + loaded.config.ui.toast.delivery, + super::super::ToastDelivery::System + ); + + std::env::remove_var(CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_file(path); + } + + #[test] fn remove_keybinding_config_sections_removes_keys_tables_only() { let content = r#"onboarding = false |
