//! Shared take/apply for `[[version_overrides]]` / `[[campaigns]]` arrays. use serde::de::DeserializeOwned; use crate::deep_merge_toml; pub type PatchPath = &'static [&'static str]; #[derive(Debug, Clone)] pub struct ConfigOverrideEntry { pub meta: M, pub patch: toml::Table, } /// Strip `key` from the root table; each element is `M` + remaining keys as patch. pub fn take_patch_array( config: &mut toml::Value, key: &str, ) -> Result>, toml::de::Error> where M: DeserializeOwned, { let Some(table) = config.as_table_mut() else { return Ok(Vec::new()); }; let Some(array_value) = table.remove(key) else { return Ok(Vec::new()); }; #[derive(serde::Deserialize)] struct FlatEntry { #[serde(flatten)] meta: M, #[serde(flatten)] patch: toml::Table, } let entries: Vec> = array_value.try_into()?; Ok(entries .into_iter() .map(|e| ConfigOverrideEntry { meta: e.meta, patch: e.patch, }) .collect()) } /// Whether `patch` affects the value at `path`: it sets a value there (any leaf /// under it counts), **or** it sets a non-table ancestor — deep-merge replaces /// the whole subtree in that case, so every leaf beneath is touched (a patch /// like `models = "oops"` wipes `models.default` and must still be dismissable /// / flagged as driving it). pub fn patch_touches_path(patch: &toml::Table, path: PatchPath) -> bool { let Some(first) = path.first() else { return false; }; let Some(mut cur) = patch.get(*first) else { return false; }; for seg in path.iter().skip(1) { match cur.as_table() { Some(t) => match t.get(*seg) { Some(v) => cur = v, None => return false, }, // Non-table ancestor: the merge replaces this subtree wholesale. None => return true, } } true } /// Whether `patch` touches any of `paths`. pub fn patch_touches_any(patch: &toml::Table, paths: &[PatchPath]) -> bool { paths.iter().any(|p| patch_touches_path(patch, p)) } /// Keys stripped from every applied patch so an override can't re-introduce a /// nested `version_overrides`/`campaigns` array (recursive re-injection). This /// const owns the recursive-injection keys for every override kind; [`apply_patches`] /// takes the strip list as a parameter so the strip step itself stays key-agnostic. pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns"]; /// Deep-merge each patch in iteration order (later wins on a leaf), stripping /// `strip_keys` from every patch first. pub fn apply_patches( config: &mut toml::Value, patches: impl IntoIterator, strip_keys: &[&str], ) { for mut patch in patches { for key in strip_keys { patch.remove(*key); } deep_merge_toml(config, &toml::Value::Table(patch)); } } #[cfg(test)] mod tests { use super::*; fn table(s: &str) -> toml::Table { toml::from_str(s).unwrap() } /// A patch that replaces a parent table with a scalar (`models = "oops"`) /// wipes every leaf beneath it on merge, so it must count as touching those /// leaves — otherwise the campaign that destroyed `models.default` would be /// neither dismissable nor flagged as driving the field. #[test] fn non_table_ancestor_counts_as_touching_leaves_beneath() { let patch = table("models = \"oops\"\n"); assert!(patch_touches_path(&patch, &["models", "default"])); assert!(patch_touches_path(&patch, &["models"])); // Sibling sections are unaffected. assert!(!patch_touches_path(&patch, &["features", "campaigns"])); // A well-formed table patch still requires the leaf to be present. let tbl = table("[models]\ndefault = \"m\"\n"); assert!(patch_touches_path(&tbl, &["models", "default"])); assert!(!patch_touches_path(&tbl, &["models", "other"])); } #[test] fn apply_patches_strips_requested_keys() { let mut cfg = toml::Value::Table(table("[models]\ndefault = \"old\"\n")); let patch = table("[models]\ndefault = \"new\"\n"); apply_patches(&mut cfg, std::iter::once(patch), PATCH_STRIP_KEYS); assert_eq!(cfg["models"]["default"].as_str(), Some("new")); // Top-level strip keys are removed before merge. let mut cfg2 = toml::Value::Table(toml::Table::new()); let mut p = toml::Table::new(); p.insert("version_overrides".into(), toml::Value::Array(vec![])); p.insert("campaigns".into(), toml::Value::Array(vec![])); p.insert("keep".into(), toml::Value::Boolean(true)); apply_patches(&mut cfg2, std::iter::once(p), PATCH_STRIP_KEYS); assert!(cfg2.get("version_overrides").is_none()); assert!(cfg2.get("campaigns").is_none()); assert_eq!(cfg2["keep"].as_bool(), Some(true)); } }