aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-07-18 21:27:48 +0300
committerGitHub <noreply@github.com>2026-07-18 21:27:48 +0300
commitf11be93e0409f8159eb8c501416f4bd3516c4270 (patch)
treecde111a1dfcfe51ea6ca042353fc228d5bbbfe3a /src
parent02a6e874f67800891b5a549297219ed6f3ce0f2f (diff)
fix: report unknown config keys (#1575)
* fix: report unknown config keys refs #1573 * fix: preserve mixed config warning context refs #1573 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs34
-rw-r--r--src/config/io.rs253
2 files changed, 270 insertions, 17 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index d634dbb4..d556b960 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -2605,7 +2605,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\nprompt_new_workspace_name = true\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -3032,6 +3032,38 @@ mod tests {
}
#[test]
+ fn reload_config_applies_known_sibling_and_summarizes_unknown_key() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-unknown-key");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ let target_mouse_capture = !app.state.mouse_capture;
+ std::fs::write(
+ &path,
+ format!("[ui]\nmouse_capture = {target_mouse_capture}\nmouse_captur = false\n"),
+ )
+ .unwrap();
+
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
+ assert_eq!(
+ report.diagnostics,
+ vec!["unknown config key ui.mouse_captur; ignoring key"]
+ );
+ assert_eq!(app.state.mouse_capture, target_mouse_capture);
+ assert_eq!(
+ app.state.config_diagnostic.as_deref(),
+ Some("config.toml has unknown keys; herdr config check")
+ );
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_user_binding_displaces_default_without_rejecting_prefix() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-user-binding-displaces-default");
diff --git a/src/config/io.rs b/src/config/io.rs
index c81b6e08..e19f6944 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -122,9 +122,19 @@ impl Config {
}
};
- match toml::from_str::<Config>(&content) {
- Ok(config) => {
- let mut diagnostics = unknown_top_level_section_diagnostics_from_str(&content);
+ match deserialize_with_ignored::<Config, _>(toml::Deserializer::new(&content)) {
+ Ok((config, ignored_keys)) => {
+ let (unknown_sections, mut diagnostics) =
+ unknown_top_level_sections_from_str(&content);
+ diagnostics.extend(unknown_config_key_diagnostics(
+ ignored_keys
+ .into_iter()
+ .filter(|path| {
+ !matches!(path.as_slice(), [ConfigKeyPathSegment::Key(key)] if unknown_sections.contains(key))
+ })
+ .collect(),
+ None,
+ ));
diagnostics.extend(config.collect_diagnostics());
LoadedConfig {
config,
@@ -193,6 +203,11 @@ pub fn config_diagnostic_summary(diagnostics: &[String]) -> Option<String> {
} else {
" invalid; keeping current config"
}
+ } else if diagnostics
+ .iter()
+ .all(|diagnostic| diagnostic.starts_with("unknown config key "))
+ {
+ " has unknown keys"
} else {
""
};
@@ -233,6 +248,7 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
let mut config = Config::default();
let mut diagnostics = unknown_top_level_section_diagnostics(table);
+ diagnostics.extend(unknown_top_level_config_key_diagnostics(table));
let mut invalid_sections = Vec::new();
if let Some(value) = table.get("onboarding") {
@@ -332,12 +348,23 @@ 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_sections_from_str(content: &str) -> (Vec<String>, Vec<String>) {
+ let Ok(value) = content.parse::<toml::Value>() else {
+ return (Vec::new(), Vec::new());
+ };
+ let Some(table) = value.as_table() else {
+ return (Vec::new(), Vec::new());
+ };
+
+ let mut keys = Vec::new();
+ let mut diagnostics = Vec::new();
+ for (key, value) in table {
+ if let Some(diagnostic) = unknown_top_level_section_diagnostic(key, value) {
+ keys.push(key.clone());
+ diagnostics.push(diagnostic);
+ }
+ }
+ (keys, diagnostics)
}
fn unknown_top_level_section_diagnostics(
@@ -374,6 +401,107 @@ fn unknown_top_level_section_diagnostic(key: &str, value: &toml::Value) -> Optio
}
}
+fn unknown_top_level_config_key_diagnostics(
+ table: &toml::map::Map<String, toml::Value>,
+) -> Vec<String> {
+ let paths = table
+ .iter()
+ .filter(|(key, value)| {
+ !KNOWN_TOP_LEVEL_CONFIG_KEYS.contains(&key.as_str())
+ && unknown_top_level_section_diagnostic(key, value).is_none()
+ })
+ .map(|(key, _)| vec![ConfigKeyPathSegment::Key(key.clone())])
+ .collect();
+ unknown_config_key_diagnostics(paths, None)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum ConfigKeyPathSegment {
+ Key(String),
+ Index(usize),
+}
+
+fn config_key_path(path: &serde_ignored::Path<'_>) -> Vec<ConfigKeyPathSegment> {
+ fn visit(path: &serde_ignored::Path<'_>, segments: &mut Vec<ConfigKeyPathSegment>) {
+ match path {
+ serde_ignored::Path::Root => {}
+ serde_ignored::Path::Seq { parent, index } => {
+ visit(parent, segments);
+ segments.push(ConfigKeyPathSegment::Index(*index));
+ }
+ serde_ignored::Path::Map { parent, key } => {
+ visit(parent, segments);
+ segments.push(ConfigKeyPathSegment::Key(key.clone()));
+ }
+ serde_ignored::Path::Some { parent }
+ | serde_ignored::Path::NewtypeStruct { parent }
+ | serde_ignored::Path::NewtypeVariant { parent } => visit(parent, segments),
+ }
+ }
+
+ let mut segments = Vec::new();
+ visit(path, &mut segments);
+ segments
+}
+
+fn format_config_key_path(path: &[ConfigKeyPathSegment]) -> String {
+ path.iter()
+ .map(|segment| match segment {
+ ConfigKeyPathSegment::Key(key)
+ if !key.is_empty()
+ && key.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
+ }) =>
+ {
+ key.clone()
+ }
+ ConfigKeyPathSegment::Key(key) => toml::Value::String(key.clone()).to_string(),
+ ConfigKeyPathSegment::Index(index) => index.to_string(),
+ })
+ .collect::<Vec<_>>()
+ .join(".")
+}
+
+fn unknown_config_key_diagnostics(
+ paths: Vec<Vec<ConfigKeyPathSegment>>,
+ section: Option<&str>,
+) -> Vec<String> {
+ let mut paths: Vec<Vec<ConfigKeyPathSegment>> = paths
+ .into_iter()
+ .map(|mut path| {
+ if let Some(section) = section {
+ path.insert(0, ConfigKeyPathSegment::Key(section.to_string()));
+ }
+ path
+ })
+ .collect();
+ paths.sort();
+ paths.dedup();
+ paths
+ .into_iter()
+ .map(|path| {
+ format!(
+ "unknown config key {}; ignoring key",
+ format_config_key_path(&path)
+ )
+ })
+ .collect()
+}
+
+fn deserialize_with_ignored<'de, T, D>(
+ deserializer: D,
+) -> Result<(T, Vec<Vec<ConfigKeyPathSegment>>), D::Error>
+where
+ T: serde::Deserialize<'de>,
+ D: serde::Deserializer<'de>,
+{
+ let mut ignored = Vec::new();
+ let value = serde_ignored::deserialize(deserializer, |path| {
+ ignored.push(config_key_path(&path));
+ })?;
+ Ok((value, ignored))
+}
+
fn load_live_section<T>(
table: &toml::map::Map<String, toml::Value>,
section: &'static str,
@@ -388,8 +516,11 @@ fn load_live_section<T>(
return;
};
- match value.clone().try_into::<T>() {
- Ok(section_config) => apply(section_config),
+ match deserialize_with_ignored(value.clone()) {
+ Ok((section_config, ignored_keys)) => {
+ diagnostics.extend(unknown_config_key_diagnostics(ignored_keys, Some(section)));
+ apply(section_config);
+ }
Err(err) => {
diagnostics.push(format!(
"invalid {label}: {err}; keeping current {section} settings"
@@ -625,6 +756,32 @@ mod tests {
}
#[test]
+ fn config_diagnostic_summary_reports_unknown_keys_compactly() {
+ let diagnostics = vec![
+ "unknown config key ui.mouse_captur; ignoring key".to_string(),
+ "unknown config key keys.new_tabb; ignoring key".to_string(),
+ ];
+
+ assert_eq!(
+ config_diagnostic_summary(&diagnostics).as_deref(),
+ Some("config.toml has unknown keys; herdr config check")
+ );
+ }
+
+ #[test]
+ fn config_diagnostic_summary_keeps_mixed_diagnostics_generic() {
+ let diagnostics = vec![
+ "invalid ui config: invalid type: string; keeping current ui settings".to_string(),
+ "unknown config key keys.new_tabb; ignoring key".to_string(),
+ ];
+
+ assert_eq!(
+ config_diagnostic_summary(&diagnostics).as_deref(),
+ Some("config.toml; herdr config check")
+ );
+ }
+
+ #[test]
fn config_diagnostic_summary_reports_default_fallback() {
let diagnostics = vec![
"config parse error: TOML parse error at line 33, column 8\n |\n33 | type = \"popup\"\n | ^^^^^^^\nunknown variant `popup`; using defaults"
@@ -731,22 +888,86 @@ delivery = "herdr"
}
#[test]
- fn load_live_config_does_not_warn_about_unknown_top_level_scalar_values() {
+ fn load_live_config_warns_about_unknown_keys_and_applies_known_siblings() {
let loaded = load_live_config_from_str(
- r#"
+ r##"
plugin = []
+[theme.custom]
+accentt = "#ffffff"
+
+[advanced]
+scrollback_lines = 42
+
+[keys]
+fullscreen = "prefix+z"
+new_tabb = "prefix+t"
+
+[[keys.command]]
+key = "prefix+g"
+command = "git status"
+descrption = "status"
+
+[ui]
+mouse_capture = false
+mouse_captur = true
+"foo.bar" = true
+"foo.?.bar" = false
+
[ui.toast]
-delivery = "herdr"
-"#,
+enabled = true
+delivry = "system"
+
+[ui.sidebar.agents.rows_by_agent]
+claude = [["terminal_title"]]
+"##,
)
.unwrap();
- assert!(loaded.diagnostics.is_empty());
+ assert_eq!(
+ loaded.diagnostics,
+ vec![
+ "unknown config key plugin; ignoring key",
+ "unknown config key theme.custom.accentt; ignoring key",
+ "unknown config key keys.command.0.descrption; ignoring key",
+ "unknown config key keys.new_tabb; ignoring key",
+ "unknown config key ui.\"foo.?.bar\"; ignoring key",
+ "unknown config key ui.\"foo.bar\"; ignoring key",
+ "unknown config key ui.mouse_captur; ignoring key",
+ "unknown config key ui.toast.delivry; ignoring key",
+ ]
+ );
+ assert!(loaded.invalid_sections.is_empty());
+ assert_eq!(loaded.config.advanced.scrollback_limit_bytes, 42);
+ assert!(!loaded.config.ui.mouse_capture);
assert_eq!(
loaded.config.ui.toast.delivery,
super::super::ToastDelivery::Herdr
);
+ assert!(loaded
+ .config
+ .keybinds()
+ .zoom
+ .bindings
+ .iter()
+ .any(|binding| binding.label == "prefix+z"));
+ }
+
+ #[test]
+ fn load_live_config_discards_ignored_keys_from_an_invalid_section() {
+ let loaded = load_live_config_from_str(
+ r#"
+[ui]
+mouse_capture = "yes"
+mouse_captur = true
+"#,
+ )
+ .unwrap();
+
+ assert_eq!(loaded.diagnostics.len(), 1);
+ assert!(loaded.diagnostics[0].contains("invalid ui config"));
+ assert!(!loaded.diagnostics[0].starts_with("unknown config key"));
+ assert_eq!(loaded.invalid_sections, vec!["ui"]);
}
#[test]