Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
161 changes: 128 additions & 33 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default = "default_fps")]
Expand All @@ -68,6 +67,8 @@ struct ConfigFile {
max_volume_percent: Option<f32>,
#[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"
Expand Down Expand Up @@ -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")]
Expand All @@ -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<names::NameTemplate>,
Expand Down Expand Up @@ -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<String>,
pub matches: Vec<MatchCondition>,
Expand Down Expand Up @@ -266,6 +264,10 @@ fn default_enforce_max_volume() -> bool {
false
}

fn default_lenient_config() -> bool {
false
}

fn default_lazy_capture() -> bool {
false
}
Expand Down Expand Up @@ -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<Self> {
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)
}
}

Expand Down Expand Up @@ -420,7 +470,7 @@ impl Config {
path: Option<&Path>,
opt: &Opt,
) -> Result<Self, anyhow::Error> {
let mut config_file: ConfigFile = match path {
let config_file = match path {
Some(path) if path.exists() => {
let context = || {
format!(
Expand All @@ -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()
}
}
Expand All @@ -471,6 +518,7 @@ pub mod strict {
theme: String,
max_volume_percent: Option<f32>,
enforce_max_volume: bool,
lenient_config: bool,
#[serde(deserialize_with = "keybindings")]
keybindings: HashMap<KeyEvent, Action>,
names: Names,
Expand All @@ -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,
Expand Down Expand Up @@ -562,41 +611,87 @@ mod tests {
use super::*;

#[test]
fn unknown_field_config_file() {
let config = r#"
unknown = "unknown"
"#;
assert!(toml::from_str::<ConfigFile>(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::<Keybinding>(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::<Names>(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::<NameOverride>(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]
Expand Down
9 changes: 0 additions & 9 deletions src/config/char_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
default_device: Option<String>,
Expand Down Expand Up @@ -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::<CharSetOverlay>(config).is_err());
}
}
1 change: 0 additions & 1 deletion src/config/name_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ impl<'de> Deserialize<'de> for NameOverride {
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct NameOverrideRaw {
types: Vec<OverrideType>,

Expand Down
18 changes: 0 additions & 18 deletions src/config/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
default_device: Option<StyleDef>,
Expand Down Expand Up @@ -40,7 +39,6 @@ pub struct ThemeOverlay {
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct StyleDef {
pub fg: Option<Color>,
pub bg: Option<Color>,
Expand Down Expand Up @@ -265,22 +263,6 @@ impl Theme {
mod tests {
use super::*;

#[test]
fn unknown_field_theme() {
let config = r#"
unknown = "unknown"
"#;
assert!(toml::from_str::<ThemeOverlay>(config).is_err());
}

#[test]
fn unknown_field_style() {
let config = r#"
unknown = "unknown"
"#;
assert!(toml::from_str::<StyleDef>(config).is_err());
}

#[test]
fn inherit_nonexistent() {
let config = r#"
Expand Down
10 changes: 10 additions & 0 deletions src/opt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions wiremix.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
Loading