diff --git a/crates/headroom-core/src/profile.rs b/crates/headroom-core/src/profile.rs index 65c0002..0b456e1 100644 --- a/crates/headroom-core/src/profile.rs +++ b/crates/headroom-core/src/profile.rs @@ -239,8 +239,16 @@ impl Default for CompressorSection { } /// `makeup_db` field: either an explicit number of dB or `"auto"`. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -#[serde(untagged)] +/// +/// De/serialization is hand-rolled rather than derived. A derived +/// `#[serde(untagged)]` enum cannot deserialize the unit variant +/// `Auto` from the string `"auto"` (untagged matches unit variants +/// against `null`, not a name), so every shipped profile carrying +/// `makeup_db = "auto"` silently failed to parse under the `toml` +/// crate and got skipped. We accept a number → `Db`, or the +/// case-insensitive string `"auto"` → `Auto`, and serialize back to +/// the same shapes. +#[derive(Debug, Clone, Copy, Default)] pub enum MakeupGain { /// Numeric dB value. Db(f32), @@ -249,6 +257,34 @@ pub enum MakeupGain { Auto, } +impl Serialize for MakeupGain { + fn serialize(&self, ser: S) -> Result { + match self { + MakeupGain::Db(v) => ser.serialize_f32(*v), + MakeupGain::Auto => ser.serialize_str("auto"), + } + } +} + +impl<'de> Deserialize<'de> for MakeupGain { + fn deserialize>(de: D) -> Result { + // Buffer into an untagged number-or-string, then interpret. + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Num(f32), + Str(String), + } + match Repr::deserialize(de)? { + Repr::Num(v) => Ok(MakeupGain::Db(v)), + Repr::Str(s) if s.eq_ignore_ascii_case("auto") => Ok(MakeupGain::Auto), + Repr::Str(s) => Err(serde::de::Error::custom(format!( + "invalid makeup_db {s:?}: expected a number or \"auto\"" + ))), + } + } +} + /// `[limiter]` section. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -517,11 +553,10 @@ mod tests { #[test] fn makeup_gain_serialises_as_string_or_number() { + // Auto serialises to the lowercase string `"auto"` — the same + // token profiles use on disk — and round-trips. let auto = serde_json::to_string(&MakeupGain::Auto).unwrap(); - // Untagged enum: Auto serialises as its discriminant variant — - // serde_json renders unit variant Auto as `"Auto"`. We don't - // promise wire-format here; this is a profile concern. Just - // verify round-trip works. + assert_eq!(auto, "\"auto\""); let back: MakeupGain = serde_json::from_str(&auto).unwrap(); assert!(matches!(back, MakeupGain::Auto)); @@ -529,4 +564,20 @@ mod tests { let back: MakeupGain = serde_json::from_str(&db).unwrap(); assert!(matches!(back, MakeupGain::Db(v) if (v - 3.0).abs() < 1e-6)); } + + #[test] + fn makeup_gain_parses_auto_from_toml_case_insensitively() { + #[derive(Deserialize)] + struct Holder { + makeup_db: MakeupGain, + } + for tok in ["\"auto\"", "\"Auto\"", "\"AUTO\""] { + let h: Holder = toml::from_str(&format!("makeup_db = {tok}")).unwrap(); + assert!(matches!(h.makeup_db, MakeupGain::Auto), "token {tok}"); + } + let h: Holder = toml::from_str("makeup_db = -3.5").unwrap(); + assert!(matches!(h.makeup_db, MakeupGain::Db(v) if (v + 3.5).abs() < 1e-6)); + // A bogus string is a hard error, not a silent skip. + assert!(toml::from_str::("makeup_db = \"loud\"").is_err()); + } }