From d91941aca8152996e38bfd57cb15d913b80b9850 Mon Sep 17 00:00:00 2001 From: HoneyHazard <8847050+HoneyHazard@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:31:38 -0700 Subject: [PATCH] Add lenient_config to ignore unknown config fields instead of failing Every config struct currently uses #[serde(deny_unknown_fields)], so any unrecognized field anywhere in wiremix.toml - a typo, or a key from a newer/older wiremix version, including this fork's own not-yet-upstream keys - makes wiremix refuse to start at all, with no way to opt out. Adds a lenient_config config option (default false) with matching --lenient-config/--no-lenient-config CLI flags. When enabled, unknown fields are logged as warnings and ignored instead of erroring, using serde_ignored to collect every ignored field path across the whole file in one pass (not just the first one hit) regardless of nesting depth, so --lenient-config's warning output and the default strict error message both report everything at once. Requires removing #[serde(deny_unknown_fields)] from the individual structs, since serde_ignored's callback only fires for fields a struct's own Deserialize impl would otherwise silently drop - deny_unknown_fields makes that struct error out before serde_ignored ever sees it, bypassing the lenient/strict decision entirely. The one exception is the test-only strict::ConfigFile used to validate the shipped example wiremix.toml stays exhaustive - that one keeps deny_unknown_fields since catching drift there is its whole purpose. --- Cargo.lock | 11 +++ Cargo.toml | 1 + src/config.rs | 161 ++++++++++++++++++++++++++++-------- src/config/char_set.rs | 9 -- src/config/name_override.rs | 1 - src/config/theme.rs | 18 ---- src/opt.rs | 10 +++ wiremix.toml | 7 ++ 8 files changed, 157 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c45f8bc..4eccbdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,16 @@ dependencies = [ "serde", ] +[[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.137" @@ -2027,6 +2037,7 @@ dependencies = [ "regex", "scopeguard", "serde", + "serde_ignored", "serde_json", "serde_with", "smallvec", diff --git a/Cargo.toml b/Cargo.toml index 7962fc6..f0d1daf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ ratatui = { version = "0.29.0", features = ["serde"] } regex = "1.12.3" scopeguard = "1.2.0" serde = { version = "1.0.218", features = ["derive"] } +serde_ignored = "0.1.14" serde_json = "1.0.137" serde_with = "3.12.0" smallvec = "1.14.0" diff --git a/src/config.rs b/src/config.rs index ac871b0..ae433ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -51,7 +51,6 @@ pub struct Config { /// Config, which, for example, has a single char_set and theme. #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields)] struct ConfigFile { remote: Option, #[serde(default = "default_fps")] @@ -68,6 +67,8 @@ struct ConfigFile { max_volume_percent: Option, #[serde(default = "default_enforce_max_volume")] enforce_max_volume: bool, + #[serde(default = "default_lenient_config")] + lenient_config: bool, #[serde( default = "Keybinding::defaults", deserialize_with = "Keybinding::merge" @@ -102,7 +103,6 @@ pub enum Peaks { } #[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] pub struct Keybinding { pub key: KeyCode, #[serde(default = "Keybinding::default_modifiers")] @@ -112,7 +112,6 @@ pub struct Keybinding { #[derive(Deserialize, Debug)] #[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields)] pub struct Names { #[serde(default = "Names::default_stream")] pub stream: Vec, @@ -205,7 +204,6 @@ pub struct Theme { #[derive(Debug, Deserialize)] #[cfg_attr(test, derive(PartialEq))] -#[serde(deny_unknown_fields)] pub struct Filter { pub id: Option, pub matches: Vec, @@ -266,6 +264,10 @@ fn default_enforce_max_volume() -> bool { false } +fn default_lenient_config() -> bool { + false +} + fn default_lazy_capture() -> bool { false } @@ -328,6 +330,54 @@ impl ConfigFile { if opt.lazy_capture { self.lazy_capture = true; } + + if opt.no_lenient_config { + self.lenient_config = false; + } + + if opt.lenient_config { + self.lenient_config = true; + } + } + + /// Parses `toml_str` into a `ConfigFile`, then applies `opt`'s + /// overrides. Unknown fields anywhere in the file - top-level or + /// nested inside `[[keybindings]]`, `[themes.*]`, `[char_sets.*]`, + /// etc. - are collected rather than failing on just the first one + /// found. If `lenient_config` ends up `false` (the default, after + /// `opt` has had a chance to override it), any unknown fields turn + /// into a single aggregated error; if `true`, each one is only + /// logged as a warning and parsing proceeds using defaults for the + /// rest of that value. + fn parse(toml_str: &str, opt: &Opt) -> anyhow::Result { + let mut unknown_fields = Vec::new(); + let deserializer = toml::Deserializer::parse(toml_str)?; + let mut config_file: Self = + serde_ignored::deserialize(deserializer, |path| { + unknown_fields.push(path.to_string()); + })?; + + config_file.apply_opt(opt); + + if !unknown_fields.is_empty() { + if config_file.lenient_config { + for field in &unknown_fields { + eprintln!( + "wiremix: warning: ignoring unknown configuration \ + field '{field}'" + ); + } + } else { + anyhow::bail!( + "unknown configuration field(s): {} (pass \ + --lenient-config, or set lenient_config = true, to \ + ignore instead of failing)", + unknown_fields.join(", ") + ); + } + } + + Ok(config_file) } } @@ -420,7 +470,7 @@ impl Config { path: Option<&Path>, opt: &Opt, ) -> Result { - let mut config_file: ConfigFile = match path { + let config_file = match path { Some(path) if path.exists() => { let context = || { format!( @@ -432,20 +482,17 @@ impl Config { let toml_str = fs::read_to_string(path).with_context(context)?; - toml::from_str(&toml_str).with_context(context)? + ConfigFile::parse(&toml_str, opt).with_context(context)? } - _ => toml::from_str("")?, + _ => ConfigFile::parse("", opt)?, }; - // Override with command-line options - config_file.apply_opt(opt); - let config_file = config_file; Self::try_from(config_file) } #[cfg(test)] pub fn from_toml_str(toml: &str) -> Self { - let config_file: ConfigFile = toml::from_str(toml).unwrap(); + let config_file = ConfigFile::parse(toml, &Opt::default()).unwrap(); Self::try_from(config_file).unwrap() } } @@ -471,6 +518,7 @@ pub mod strict { theme: String, max_volume_percent: Option, enforce_max_volume: bool, + lenient_config: bool, #[serde(deserialize_with = "keybindings")] keybindings: HashMap, names: Names, @@ -495,6 +543,7 @@ pub mod strict { theme: strict.theme, max_volume_percent: strict.max_volume_percent, enforce_max_volume: strict.enforce_max_volume, + lenient_config: strict.lenient_config, keybindings: strict.keybindings, names: strict.names, char_sets: strict.char_sets, @@ -562,41 +611,87 @@ mod tests { use super::*; #[test] - fn unknown_field_config_file() { - let config = r#" - unknown = "unknown" - "#; - assert!(toml::from_str::(config).is_err()); + fn unknown_field_top_level_errors_by_default() { + let result = + ConfigFile::parse("unknown = \"unknown\"", &Opt::default()); + assert!(result.is_err()); } #[test] - fn unknown_field_keybinding() { + fn unknown_field_keybinding_errors_by_default() { let config = r#" - key = { Char = "x" } - action = "Nothing" - unknown = "unknown" + keybindings = [ + { key = { Char = "x" }, action = "Nothing", unknown = "unknown" }, + ] "#; - assert!(toml::from_str::(config).is_err()); + assert!(ConfigFile::parse(config, &Opt::default()).is_err()); } #[test] - fn unknown_field_names() { - let config = r#" - unknown = "unknown" - "#; - assert!(toml::from_str::(config).is_err()); + fn unknown_field_names_errors_by_default() { + let config = "[names]\nunknown = \"unknown\""; + assert!(ConfigFile::parse(config, &Opt::default()).is_err()); } #[test] - fn unknown_field_name_override() { + fn unknown_field_name_override_errors_by_default() { let config = r#" - types = [ "stream" ] - property = "node:node.name" - value = "value" - templates = [ "template" ] - unknown = "unknown" + [names] + overrides = [ + { + types = [ "stream" ], + property = "node:node.name", + value = "value", + templates = [ "template" ], + unknown = "unknown", + }, + ] "#; - assert!(toml::from_str::(config).is_err()); + assert!(ConfigFile::parse(config, &Opt::default()).is_err()); + } + + #[test] + fn unknown_field_nested_theme_errors_by_default() { + let config = "[themes.default]\nunknown = { }"; + assert!(ConfigFile::parse(config, &Opt::default()).is_err()); + } + + #[test] + fn unknown_field_nested_char_set_errors_by_default() { + let config = "[char_sets.default]\nunknown = \"x\""; + assert!(ConfigFile::parse(config, &Opt::default()).is_err()); + } + + #[test] + fn unknown_field_lenient_via_config_file_is_ignored() { + let config = "lenient_config = true\nunknown = \"unknown\""; + assert!(ConfigFile::parse(config, &Opt::default()).is_ok()); + } + + #[test] + fn unknown_field_lenient_via_cli_flag_is_ignored() { + let opt = Opt { + lenient_config: true, + ..Default::default() + }; + let result = ConfigFile::parse("unknown = \"unknown\"", &opt); + assert!(result.is_ok()); + } + + #[test] + fn cli_no_lenient_config_overrides_config_file_lenient() { + let opt = Opt { + no_lenient_config: true, + ..Default::default() + }; + let config = "lenient_config = true\nunknown = \"unknown\""; + assert!(ConfigFile::parse(config, &opt).is_err()); + } + + #[test] + fn no_unknown_fields_ok_even_when_strict() { + let result = ConfigFile::parse("fps = 30.0", &Opt::default()); + assert!(result.is_ok()); } #[test] diff --git a/src/config/char_set.rs b/src/config/char_set.rs index f6d8a2b..3c73095 100644 --- a/src/config/char_set.rs +++ b/src/config/char_set.rs @@ -10,7 +10,6 @@ use crate::config::CharSet; // This is what actually gets parsed from the config. #[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] pub struct CharSetOverlay { inherit: Option, default_device: Option, @@ -401,12 +400,4 @@ mod tests { assert_eq!(char_set.meter_left_active, builtin.meter_left_active); } } - - #[test] - fn unknown_field() { - let config = r#" - unknown = "unknown" - "#; - assert!(toml::from_str::(config).is_err()); - } } diff --git a/src/config/name_override.rs b/src/config/name_override.rs index c9aa731..b786a72 100644 --- a/src/config/name_override.rs +++ b/src/config/name_override.rs @@ -21,7 +21,6 @@ impl<'de> Deserialize<'de> for NameOverride { } #[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] struct NameOverrideRaw { types: Vec, diff --git a/src/config/theme.rs b/src/config/theme.rs index 6919078..6c3ecdf 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -7,7 +7,6 @@ use crate::config::Theme; // This is what actually gets parsed from the config. #[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] pub struct ThemeOverlay { inherit: Option, default_device: Option, @@ -40,7 +39,6 @@ pub struct ThemeOverlay { } #[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] struct StyleDef { pub fg: Option, pub bg: Option, @@ -265,22 +263,6 @@ impl Theme { mod tests { use super::*; - #[test] - fn unknown_field_theme() { - let config = r#" - unknown = "unknown" - "#; - assert!(toml::from_str::(config).is_err()); - } - - #[test] - fn unknown_field_style() { - let config = r#" - unknown = "unknown" - "#; - assert!(toml::from_str::(config).is_err()); - } - #[test] fn inherit_nonexistent() { let config = r#" diff --git a/src/opt.rs b/src/opt.rs index 534d130..b8dc49f 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -78,6 +78,16 @@ pub struct Opt { #[clap(long, conflicts_with = "no_lazy_capture")] pub lazy_capture: bool, + /// Fail to start if the configuration contains any unknown fields + /// (default) + #[clap(long, conflicts_with = "lenient_config")] + pub no_lenient_config: bool, + + /// Ignore unknown configuration fields (logging a warning for each) + /// instead of failing to start + #[clap(long, conflicts_with = "no_lenient_config")] + pub lenient_config: bool, + #[cfg(debug_assertions)] #[clap(short, long)] pub dump_events: bool, diff --git a/wiremix.toml b/wiremix.toml index 218653b..bc31457 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -44,6 +44,13 @@ enforce_max_volume = false # If true, only monitor peak levels of visible nodes lazy_capture = false +# If true, an unrecognized field anywhere in this file (a typo, or a key +# from a newer/older wiremix version) is logged as a warning and ignored +# instead of preventing wiremix from starting at all. Off by default, so +# a typo in your config is caught immediately rather than silently doing +# nothing. +lenient_config = false + # Keybindings #