smokeping-config 0.1.2

SmokePing config builder — render Targets files from a committable patch YAML on top of a versioned base catalogue
use std::collections::HashMap;

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

use crate::diff::{CustomEntry, NodeOverride, PartialRootMeta, TreeDiff, apply_diff};
use crate::tree::path_to_id;
use crate::types::*;

pub const PATCH_SCHEMA: u8 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchNode {
    #[serde(rename = "type")]
    pub node_type: NodeType,
    pub name: String,
    pub menu: String,
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub included: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub host: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub probe: Option<Probe>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub comparison_children: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extra: Option<IndexMap<String, String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub children: Option<Vec<PatchNode>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchCustomEntry {
    pub parent_path: Option<String>,
    pub node: PatchNode,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Patch {
    pub schema: u8,
    pub base_version: CatalogVersion,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<Language>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub root: Option<PartialRootMeta>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub overrides: Option<HashMap<String, NodeOverride>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub custom: Option<Vec<PatchCustomEntry>>,
}

pub struct BaseMismatch {
    pub patch: CatalogVersion,
    pub actual: Option<CatalogVersion>,
}

pub struct DriftReport {
    pub missing_paths: Vec<String>,
    pub base_mismatch: Option<BaseMismatch>,
}

pub fn apply_patch(patch: &Patch, base: &Catalog) -> (WorkingTree, DriftReport) {
    let mut drift = DriftReport {
        missing_paths: Vec::new(),
        base_mismatch: None,
    };

    if let Some(base_ver) = &base.version {
        if base_ver.sha != patch.base_version.sha {
            drift.base_mismatch = Some(BaseMismatch {
                patch: patch.base_version.clone(),
                actual: Some(base_ver.clone()),
            });
        }
    }

    let mut diff = TreeDiff {
        lang: patch.language.clone(),
        root: patch.root.clone(),
        ex: None,
        ov: None,
        cu: None,
    };

    if let Some(excluded) = &patch.excluded {
        let mut ids = Vec::new();
        for p in excluded {
            match path_to_id(&base.nodes, p) {
                Some(id) => ids.push(id),
                None => drift.missing_paths.push(p.clone()),
            }
        }
        if !ids.is_empty() {
            diff.ex = Some(ids);
        }
    }

    if let Some(overrides) = &patch.overrides {
        let mut ov = HashMap::new();
        for (path, ovr) in overrides {
            match path_to_id(&base.nodes, path) {
                Some(id) => {
                    ov.insert(id, ovr.clone());
                }
                None => drift.missing_paths.push(path.clone()),
            }
        }
        if !ov.is_empty() {
            diff.ov = Some(ov);
        }
    }

    if let Some(custom) = &patch.custom {
        let mut cu = Vec::new();
        for entry in custom {
            let mut parent_id: Option<String> = None;
            if let Some(pp) = &entry.parent_path {
                match path_to_id(&base.nodes, pp) {
                    Some(id) => parent_id = Some(id),
                    None => {
                        drift.missing_paths.push(pp.clone());
                    }
                }
            }
            cu.push(CustomEntry {
                parent_id,
                node: from_patch_node(&entry.node),
            });
        }
        if !cu.is_empty() {
            diff.cu = Some(cu);
        }
    }

    let tree = apply_diff(&diff, base);
    (tree, drift)
}

fn from_patch_node(p: &PatchNode) -> Node {
    Node {
        id: format!("x:{}", uuid::Uuid::new_v4()),
        source: NodeSource::Custom,
        node_type: p.node_type.clone(),
        name: p.name.clone(),
        menu: p.menu.clone(),
        title: p.title.clone(),
        included: p.included.unwrap_or(true),
        children: p
            .children
            .as_ref()
            .map(|c| c.iter().map(from_patch_node).collect())
            .unwrap_or_default(),
        host: p.host.clone(),
        probe: p.probe.clone(),
        comparison_children: p.comparison_children.clone(),
        extra_attrs: p.extra.clone(),
    }
}

pub fn patch_to_yaml(patch: &Patch) -> Result<String, String> {
    serde_yaml_ng::to_string(patch).map_err(|e| e.to_string())
}

pub fn patch_from_yaml(text: &str) -> Result<Patch, String> {
    let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(text).map_err(|e| e.to_string())?;

    if !parsed.is_mapping() {
        return Err("not a YAML mapping".to_string());
    }

    let schema = parsed
        .get("schema")
        .and_then(|v| v.as_u64())
        .ok_or_else(|| "missing schema".to_string())?;

    if schema != PATCH_SCHEMA as u64 {
        return Err(format!(
            "unsupported schema (got {}, expected {})",
            schema, PATCH_SCHEMA
        ));
    }

    let base_version = parsed.get("baseVersion");
    if base_version.is_none()
        || !base_version
            .unwrap()
            .get("sha")
            .map(|v| v.is_string())
            .unwrap_or(false)
    {
        return Err("missing or malformed baseVersion".to_string());
    }

    serde_yaml_ng::from_str(text).map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tree::find_node;

    static CATALOG_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/catalog.json"));

    fn catalog() -> Catalog {
        serde_json::from_str(CATALOG_JSON).unwrap()
    }

    fn version() -> CatalogVersion {
        catalog().version.unwrap()
    }

    fn minimal_patch() -> Patch {
        Patch {
            schema: PATCH_SCHEMA,
            base_version: version(),
            language: None,
            root: None,
            excluded: None,
            overrides: None,
            custom: None,
        }
    }

    #[test]
    fn fresh_tree_produces_minimal_patch_yaml() {
        let patch = minimal_patch();
        let yaml = patch_to_yaml(&patch).unwrap();
        assert!(yaml.contains("schema: 1"));
        assert!(yaml.contains("baseVersion"));
    }

    #[test]
    fn apply_minimal_patch_no_drift() {
        let cat = catalog();
        let patch = minimal_patch();
        let (tree, drift) = apply_patch(&patch, &cat);
        assert!(drift.missing_paths.is_empty());
        assert!(drift.base_mismatch.is_none());
        assert_eq!(tree.nodes.len(), cat.nodes.len());
    }

    #[test]
    fn apply_patch_with_exclusion() {
        let cat = catalog();
        let patch = Patch {
            excluded: Some(vec!["/CDN/Cloudflare".to_string()]),
            ..minimal_patch()
        };
        let (tree, drift) = apply_patch(&patch, &cat);
        assert!(drift.missing_paths.is_empty());
        let cf = find_node(&tree.nodes, "c:CDN/Cloudflare").unwrap();
        assert!(!cf.included);
    }

    #[test]
    fn apply_patch_with_override() {
        let cat = catalog();
        let mut overrides = HashMap::new();
        overrides.insert(
            "/CDN/Cloudflare".to_string(),
            NodeOverride {
                host: Some(Some("1.1.1.1".to_string())),
                ..Default::default()
            },
        );
        let patch = Patch {
            overrides: Some(overrides),
            ..minimal_patch()
        };
        let (tree, _) = apply_patch(&patch, &cat);
        let cf = find_node(&tree.nodes, "c:CDN/Cloudflare").unwrap();
        assert_eq!(cf.host.as_deref(), Some("1.1.1.1"));
    }

    #[test]
    fn missing_paths_reported_as_drift() {
        let cat = catalog();
        let patch = Patch {
            excluded: Some(vec!["/CDN/DoesNotExist".to_string()]),
            ..minimal_patch()
        };
        let (_, drift) = apply_patch(&patch, &cat);
        assert_eq!(drift.missing_paths, vec!["/CDN/DoesNotExist"]);
    }

    #[test]
    fn base_version_mismatch_reported() {
        let cat = catalog();
        let patch = Patch {
            base_version: CatalogVersion {
                date: "2020-01-01".to_string(),
                sha: "aaaaaaa".to_string(),
            },
            ..minimal_patch()
        };
        let (_, drift) = apply_patch(&patch, &cat);
        let mm = drift.base_mismatch.unwrap();
        assert_eq!(mm.patch.sha, "aaaaaaa");
    }

    #[test]
    fn no_base_mismatch_when_sha_matches() {
        let cat = catalog();
        let patch = minimal_patch();
        let (_, drift) = apply_patch(&patch, &cat);
        assert!(drift.base_mismatch.is_none());
    }

    #[test]
    fn yaml_round_trip() {
        let patch = Patch {
            excluded: Some(vec!["/CDN/Akamai".to_string()]),
            ..minimal_patch()
        };
        let yaml = patch_to_yaml(&patch).unwrap();
        let parsed = patch_from_yaml(&yaml).unwrap();
        assert_eq!(parsed.schema, PATCH_SCHEMA);
        assert_eq!(parsed.excluded.as_ref().unwrap(), &["/CDN/Akamai"]);
    }

    #[test]
    fn patch_from_yaml_rejects_wrong_schema() {
        let yaml = "schema: 99\nbaseVersion:\n  date: x\n  sha: y\n";
        let err = patch_from_yaml(yaml).unwrap_err();
        assert!(err.contains("unsupported schema"));
    }

    #[test]
    fn patch_from_yaml_rejects_missing_base_version() {
        let yaml = "schema: 1\n";
        let err = patch_from_yaml(yaml).unwrap_err();
        assert!(err.contains("baseVersion"));
    }

    #[test]
    fn custom_nodes_round_trip_through_yaml() {
        let patch = Patch {
            custom: Some(vec![PatchCustomEntry {
                parent_path: None,
                node: PatchNode {
                    node_type: NodeType::Category,
                    name: "MyStuff".to_string(),
                    menu: "My Stuff".to_string(),
                    title: "Personal".to_string(),
                    included: None,
                    host: None,
                    probe: None,
                    comparison_children: None,
                    extra: None,
                    children: None,
                },
            }]),
            ..minimal_patch()
        };
        let yaml = patch_to_yaml(&patch).unwrap();
        let parsed = patch_from_yaml(&yaml).unwrap();
        let custom = parsed.custom.unwrap();
        assert_eq!(custom.len(), 1);
        assert_eq!(custom[0].node.name, "MyStuff");
    }
}