aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--Cargo.lock11
-rw-r--r--Cargo.toml1
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/app/mod.rs34
-rw-r--r--src/config/io.rs253
-rw-r--r--tests/cli_wrapper.rs55
6 files changed, 338 insertions, 17 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 10a4c899..39f5b1b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -600,6 +600,7 @@ dependencies = [
"regex",
"schemars",
"serde",
+ "serde_ignored",
"serde_json",
"sha2",
"tokio",
@@ -1454,6 +1455,16 @@ dependencies = [
]
[[package]]
+name = "serde_ignored"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index fff43502..1a81b5b7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ png = "0.17"
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
+serde_ignored = "0.1.14"
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 71984d1e..65bb683f 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -8,6 +8,7 @@
- Added macOS support for the `HERDR_AGENT=<agent>` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679)
### Fixed
+- `herdr config check` now reports unknown config keys with their full paths instead of treating ignored typos as valid configuration. (#1573)
- Named agent prompts now honor live bracketed-paste mode before sending Enter, preserving OpenCode text such as `A != B` instead of triggering shell mode. (#1525)
- New panes, tabs, layouts, and workspaces using `new_cwd = "follow"` now inherit the foreground process-group leader's working directory instead of an unrelated helper process directory. (#1472)
- Noninteractive update, plugin, integration, sound, custom-command, and Git subprocesses no longer flash console windows on Windows. (#1468)
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]
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 1b2c6407..0faac229 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -2506,6 +2506,61 @@ fn config_check_reports_invalid_config_without_server() {
}
#[test]
+fn config_check_reports_unknown_keys_without_treating_comments_as_keys() {
+ 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"),
+ r#"made_up_toplevel = 1
+"made.up" = 2
+# commented_out_toplevel = true
+
+[ui]
+mouse_capture = true
+mouse_captur = false
+# commented_out_ui_key = true
+
+[keys]
+new_tabb = "ctrl+t"
+"#,
+ )
+ .unwrap();
+
+ let checked = run_named_cli(&config_home, &runtime_dir, &["config", "check"]);
+
+ assert_eq!(checked.status.code(), Some(1));
+ assert!(
+ checked.stderr.is_empty(),
+ "stderr: {}",
+ String::from_utf8_lossy(&checked.stderr)
+ );
+ let stdout = String::from_utf8_lossy(&checked.stdout);
+ assert!(stdout.contains("config: issues found"), "{stdout}");
+ assert!(
+ stdout.contains("unknown config key made_up_toplevel; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key \"made.up\"; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key ui.mouse_captur; ignoring key"),
+ "{stdout}"
+ );
+ assert!(
+ stdout.contains("unknown config key keys.new_tabb; ignoring key"),
+ "{stdout}"
+ );
+ assert!(!stdout.contains("commented_out"), "{stdout}");
+
+ cleanup_test_base(&base);
+}
+
+#[test]
fn config_check_reports_unreadable_config_path() {
let base = unique_test_dir();
let config_home = base.join("config");