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 serde::{Deserialize, Serialize};

use crate::tree::{find_node_mut, fresh_tree};
use crate::types::*;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeOverride {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub menu: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub host: Option<Option<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub probe: Option<Option<Probe>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub comparison_children: Option<Option<Vec<String>>>,
}

#[derive(Debug, Clone)]
pub struct CustomEntry {
    pub parent_id: Option<String>,
    pub node: Node,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PartialRootMeta {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub probe: Option<ProbeKind>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub menu: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remark: Option<String>,
}

pub struct TreeDiff {
    pub lang: Option<Language>,
    pub root: Option<PartialRootMeta>,
    pub ex: Option<Vec<String>>,
    pub ov: Option<HashMap<String, NodeOverride>>,
    pub cu: Option<Vec<CustomEntry>>,
}

pub fn apply_diff(diff: &TreeDiff, base: &Catalog) -> WorkingTree {
    let lang = diff.lang.clone().unwrap_or_default();
    let mut tree = fresh_tree(base, lang);

    if let Some(root) = &diff.root {
        if let Some(probe) = &root.probe {
            tree.root.probe = probe.clone();
        }
        if let Some(menu) = &root.menu {
            tree.root.menu = menu.clone();
        }
        if let Some(title) = &root.title {
            tree.root.title = title.clone();
        }
        if let Some(remark) = &root.remark {
            tree.root.remark = Some(remark.clone());
        }
    }

    if let Some(ex) = &diff.ex {
        for id in ex {
            if let Some(n) = find_node_mut(&mut tree.nodes, id) {
                n.included = false;
            }
        }
    }

    if let Some(ov) = &diff.ov {
        for (id, ovr) in ov {
            if let Some(n) = find_node_mut(&mut tree.nodes, id) {
                if let Some(name) = &ovr.name {
                    n.name = name.clone();
                }
                if let Some(menu) = &ovr.menu {
                    n.menu = menu.clone();
                }
                if let Some(title) = &ovr.title {
                    n.title = title.clone();
                }
                if let Some(host_opt) = &ovr.host {
                    n.host = host_opt.clone();
                }
                if let Some(probe_opt) = &ovr.probe {
                    n.probe = probe_opt.clone();
                }
                if let Some(cc_opt) = &ovr.comparison_children {
                    n.comparison_children = cc_opt.clone();
                }
            }
        }
    }

    if let Some(cu) = &diff.cu {
        for entry in cu {
            let node = entry.node.clone();
            if let Some(pid) = &entry.parent_id {
                if let Some(parent) = find_node_mut(&mut tree.nodes, pid) {
                    parent.children.push(node);
                } else {
                    tree.nodes.push(node);
                }
            } else {
                tree.nodes.push(node);
            }
        }
    }

    tree
}

#[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()
    }

    #[test]
    fn empty_diff_produces_fresh_tree() {
        let cat = catalog();
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: None,
            ov: None,
            cu: None,
        };
        let tree = apply_diff(&diff, &cat);
        assert_eq!(tree.language, Language::En);
        assert_eq!(tree.nodes.len(), cat.nodes.len());
    }

    #[test]
    fn diff_applies_exclusions() {
        let cat = catalog();
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: Some(vec!["c:CDN/Cloudflare".to_string()]),
            ov: None,
            cu: None,
        };
        let tree = apply_diff(&diff, &cat);
        let cf = find_node(&tree.nodes, "c:CDN/Cloudflare").unwrap();
        assert!(!cf.included);
    }

    #[test]
    fn diff_applies_overrides() {
        let cat = catalog();
        let mut ov = HashMap::new();
        ov.insert(
            "c:CDN/Cloudflare".to_string(),
            NodeOverride {
                host: Some(Some("1.1.1.1".to_string())),
                ..Default::default()
            },
        );
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: None,
            ov: Some(ov),
            cu: None,
        };
        let tree = apply_diff(&diff, &cat);
        let cf = find_node(&tree.nodes, "c:CDN/Cloudflare").unwrap();
        assert_eq!(cf.host.as_deref(), Some("1.1.1.1"));
    }

    #[test]
    fn diff_override_clears_host() {
        let cat = catalog();
        let mut ov = HashMap::new();
        ov.insert(
            "c:CDN/Cloudflare".to_string(),
            NodeOverride {
                host: Some(None),
                ..Default::default()
            },
        );
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: None,
            ov: Some(ov),
            cu: None,
        };
        let tree = apply_diff(&diff, &cat);
        let cf = find_node(&tree.nodes, "c:CDN/Cloudflare").unwrap();
        assert!(cf.host.is_none());
    }

    #[test]
    fn diff_appends_custom_to_root() {
        let cat = catalog();
        let custom = Node {
            id: "x:test-uuid".to_string(),
            source: NodeSource::Custom,
            node_type: NodeType::Category,
            name: "MyStuff".to_string(),
            menu: "My Stuff".to_string(),
            title: "Personal".to_string(),
            included: true,
            children: vec![],
            host: None,
            probe: None,
            comparison_children: None,
            extra_attrs: None,
        };
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: None,
            ov: None,
            cu: Some(vec![CustomEntry {
                parent_id: None,
                node: custom,
            }]),
        };
        let tree = apply_diff(&diff, &cat);
        assert_eq!(tree.nodes.len(), cat.nodes.len() + 1);
        let last = tree.nodes.last().unwrap();
        assert_eq!(last.name, "MyStuff");
    }

    #[test]
    fn diff_appends_custom_under_parent() {
        let cat = catalog();
        let custom = Node {
            id: "x:test-uuid".to_string(),
            source: NodeSource::Custom,
            node_type: NodeType::Target,
            name: "MyTarget".to_string(),
            menu: "My Target".to_string(),
            title: "Custom target".to_string(),
            included: true,
            children: vec![],
            host: Some("example.com".to_string()),
            probe: None,
            comparison_children: None,
            extra_attrs: None,
        };
        let diff = TreeDiff {
            lang: None,
            root: None,
            ex: None,
            ov: None,
            cu: Some(vec![CustomEntry {
                parent_id: Some("c:CDN".to_string()),
                node: custom,
            }]),
        };
        let tree = apply_diff(&diff, &cat);
        let cdn = find_node(&tree.nodes, "c:CDN").unwrap();
        let last_child = cdn.children.last().unwrap();
        assert_eq!(last_child.name, "MyTarget");
    }

    #[test]
    fn diff_sets_language() {
        let cat = catalog();
        let diff = TreeDiff {
            lang: Some(Language::ZhTw),
            root: None,
            ex: None,
            ov: None,
            cu: None,
        };
        let tree = apply_diff(&diff, &cat);
        assert_eq!(tree.language, Language::ZhTw);
    }
}