212 lines
6.7 KiB
Rust
212 lines
6.7 KiB
Rust
//! Version-aware config layering. A `[[version_overrides]]` array carries
|
|
//! semver-gated patches deep-merged in ascending `minimum_version` order.
|
|
//!
|
|
//! ```toml
|
|
//! [[version_overrides]]
|
|
//! minimum_version = "1.7.0"
|
|
//! [version_overrides.features]
|
|
//! logging = true
|
|
//!
|
|
//! [[version_overrides]]
|
|
//! minimum_version = "1.8.0"
|
|
//! maximum_version = "1.9.999"
|
|
//! [version_overrides.features.telemetry]
|
|
//! enabled = true
|
|
//! ```
|
|
|
|
use semver::Version;
|
|
use serde::Deserialize;
|
|
|
|
use crate::config_override::{PATCH_STRIP_KEYS, apply_patches, take_patch_array};
|
|
|
|
pub const VERSION_OVERRIDES_KEY: &str = "version_overrides";
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct VersionOverrideMeta {
|
|
#[serde(default)]
|
|
pub minimum_version: Option<String>,
|
|
#[serde(default)]
|
|
pub maximum_version: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum VersionOverrideError {
|
|
#[error("version_overrides: failed to deserialize: {0}")]
|
|
Deserialize(#[from] toml::de::Error),
|
|
#[error("version_overrides[{index}].minimum_version = {value:?} is not valid semver: {source}")]
|
|
InvalidMinimumVersion {
|
|
index: usize,
|
|
value: String,
|
|
#[source]
|
|
source: semver::Error,
|
|
},
|
|
#[error("version_overrides[{index}].maximum_version = {value:?} is not valid semver: {source}")]
|
|
InvalidMaximumVersion {
|
|
index: usize,
|
|
value: String,
|
|
#[source]
|
|
source: semver::Error,
|
|
},
|
|
}
|
|
|
|
/// Strips `version_overrides` (always) and deep-merges each matching
|
|
/// patch in ascending `minimum_version` order.
|
|
pub fn apply_version_overrides(
|
|
config: &mut toml::Value,
|
|
version: &Version,
|
|
) -> Result<(), VersionOverrideError> {
|
|
let entries = take_patch_array::<VersionOverrideMeta>(config, VERSION_OVERRIDES_KEY)?;
|
|
|
|
// Parse all bounds upfront so an invalid entry fails before any merge.
|
|
// Missing minimum_version => Version::new(0, 0, 0) (no lower bound).
|
|
let mut parsed: Vec<(Version, Option<Version>, toml::Table)> =
|
|
Vec::with_capacity(entries.len());
|
|
for (index, entry) in entries.into_iter().enumerate() {
|
|
let min_v = match &entry.meta.minimum_version {
|
|
Some(s) => Version::parse(s.trim()).map_err(|source| {
|
|
VersionOverrideError::InvalidMinimumVersion {
|
|
index,
|
|
value: s.clone(),
|
|
source,
|
|
}
|
|
})?,
|
|
None => Version::new(0, 0, 0),
|
|
};
|
|
let max_v = match &entry.meta.maximum_version {
|
|
Some(max_str) => Some(Version::parse(max_str.trim()).map_err(|source| {
|
|
VersionOverrideError::InvalidMaximumVersion {
|
|
index,
|
|
value: max_str.clone(),
|
|
source,
|
|
}
|
|
})?),
|
|
None => None,
|
|
};
|
|
parsed.push((min_v, max_v, entry.patch));
|
|
}
|
|
|
|
// Stable sort -- ties on minimum_version keep declared order so later
|
|
// entries win.
|
|
parsed.sort_by(|a, b| a.0.cmp(&b.0));
|
|
|
|
let patches = parsed.into_iter().filter_map(|(min_v, max_v, patch)| {
|
|
if version < &min_v {
|
|
return None;
|
|
}
|
|
if let Some(ref m) = max_v
|
|
&& version > m
|
|
{
|
|
return None;
|
|
}
|
|
Some(patch)
|
|
});
|
|
apply_patches(config, patches, PATCH_STRIP_KEYS);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn parse(s: &str) -> toml::Value {
|
|
toml::from_str(s).expect("valid toml")
|
|
}
|
|
|
|
fn v(s: &str) -> Version {
|
|
Version::parse(s).unwrap()
|
|
}
|
|
|
|
/// Helper asserts the section is stripped on every call, so the
|
|
/// "stripped even on no match" contract is covered across all 8 cases.
|
|
#[test]
|
|
fn version_match_boundaries() {
|
|
fn applies(min: Option<&str>, max: Option<&str>, cli: &str) -> bool {
|
|
let line = |k: &str, val: Option<&str>| {
|
|
val.map(|s| format!("\n {k} = \"{s}\""))
|
|
.unwrap_or_default()
|
|
};
|
|
let mut cfg = parse(&format!(
|
|
r#"
|
|
x = 0
|
|
|
|
[[version_overrides]]{}{}
|
|
x = 1
|
|
"#,
|
|
line("minimum_version", min),
|
|
line("maximum_version", max),
|
|
));
|
|
apply_version_overrides(&mut cfg, &v(cli)).unwrap();
|
|
assert!(
|
|
cfg.get(VERSION_OVERRIDES_KEY).is_none(),
|
|
"section must be stripped"
|
|
);
|
|
cfg["x"].as_integer() == Some(1)
|
|
}
|
|
assert!(applies(Some("1.7.0"), None, "1.7.0")); // min inclusive
|
|
assert!(applies(Some("1.0.0"), Some("1.7.0"), "1.7.0")); // max inclusive
|
|
assert!(!applies(Some("1.7.0"), None, "1.6.0")); // below min
|
|
assert!(!applies(Some("1.0.0"), Some("1.5.0"), "2.0.0")); // above max
|
|
assert!(applies(Some("1.7.0"), None, "99.0.0")); // unbounded above
|
|
assert!(applies(None, Some("2.0.0"), "1.5.0")); // max-only, within
|
|
assert!(!applies(None, Some("2.0.0"), "2.0.1")); // max-only, above
|
|
assert!(applies(None, None, "1.0.0")); // unbounded both = always
|
|
}
|
|
|
|
#[test]
|
|
fn later_matching_override_wins_on_same_key() {
|
|
let mut cfg = parse(
|
|
r#"
|
|
[features.telemetry]
|
|
enabled = false
|
|
|
|
[[version_overrides]]
|
|
minimum_version = "1.7.0"
|
|
[version_overrides.features.telemetry]
|
|
enabled = true
|
|
sample_rate = 0.1
|
|
|
|
[[version_overrides]]
|
|
minimum_version = "1.8.0"
|
|
[version_overrides.features.telemetry]
|
|
sample_rate = 0.5
|
|
"#,
|
|
);
|
|
apply_version_overrides(&mut cfg, &v("1.8.0")).unwrap();
|
|
let t = &cfg["features"]["telemetry"];
|
|
assert_eq!(t["enabled"].as_bool(), Some(true));
|
|
assert_eq!(t["sample_rate"].as_float(), Some(0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_semver_in_bounds_is_hard_error() {
|
|
let mut cfg = parse(
|
|
r#"
|
|
[[version_overrides]]
|
|
minimum_version = "not-a-version"
|
|
x = 1
|
|
"#,
|
|
);
|
|
let err = apply_version_overrides(&mut cfg, &v("1.0.0")).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
VersionOverrideError::InvalidMinimumVersion { .. }
|
|
));
|
|
// Section is consumed even on error.
|
|
assert!(cfg.get(VERSION_OVERRIDES_KEY).is_none());
|
|
|
|
let mut cfg = parse(
|
|
r#"
|
|
[[version_overrides]]
|
|
minimum_version = "1.0.0"
|
|
maximum_version = "garbage"
|
|
x = 1
|
|
"#,
|
|
);
|
|
let err = apply_version_overrides(&mut cfg, &v("1.0.0")).unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
VersionOverrideError::InvalidMaximumVersion { .. }
|
|
));
|
|
}
|
|
}
|