smokeping-config 0.1.2

SmokePing config builder — render Targets files from a committable patch YAML on top of a versioned base catalogue
#![allow(dead_code)]

use crate::types::*;

pub fn fresh_tree(base: &Catalog, language: Language) -> WorkingTree {
    WorkingTree {
        schema_ver: base.schema_ver,
        root: base.root.clone(),
        nodes: base.nodes.clone(),
        version: base.version.clone(),
        language,
    }
}

pub fn find_node<'a>(nodes: &'a [Node], id: &str) -> Option<&'a Node> {
    for n in nodes {
        if n.id == id {
            return Some(n);
        }
        if let Some(hit) = find_node(&n.children, id) {
            return Some(hit);
        }
    }
    None
}

pub fn find_node_mut<'a>(nodes: &'a mut [Node], id: &str) -> Option<&'a mut Node> {
    for n in nodes {
        if n.id == id {
            return Some(n);
        }
        if let Some(hit) = find_node_mut(&mut n.children, id) {
            return Some(hit);
        }
    }
    None
}

pub fn walk_nodes<F>(nodes: &[Node], visit: &mut F)
where
    F: FnMut(&Node),
{
    for n in nodes {
        visit(n);
        walk_nodes(&n.children, visit);
    }
}

pub fn id_to_path(nodes: &[Node], id: &str) -> Option<String> {
    fn walk(arr: &[Node], prefix: &str, id: &str) -> Option<String> {
        for n in arr {
            let path = format!("{}/{}", prefix, n.name);
            if n.id == id {
                return Some(path);
            }
            if let Some(sub) = walk(&n.children, &path, id) {
                return Some(sub);
            }
        }
        None
    }
    walk(nodes, "", id)
}

pub fn path_to_id(nodes: &[Node], path: &str) -> Option<String> {
    if path.is_empty() || !path.starts_with('/') {
        return None;
    }
    let segments: Vec<&str> = path[1..].split('/').collect();
    if segments.iter().any(|s| s.is_empty()) {
        return None;
    }
    let mut current = nodes;
    let mut matched: Option<&Node> = None;
    for seg in &segments {
        matched = current.iter().find(|n| n.name == *seg);
        match matched {
            Some(n) => current = &n.children,
            None => return None,
        }
    }
    matched.map(|n| n.id.clone())
}

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

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

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

    #[test]
    fn fresh_tree_clones_with_language() {
        let cat = catalog();
        let tree = fresh_tree(&cat, Language::En);
        assert_eq!(tree.schema_ver, cat.schema_ver);
        assert_eq!(tree.nodes.len(), cat.nodes.len());
        assert_eq!(tree.language, Language::En);
    }

    #[test]
    fn fresh_tree_independent_clones() {
        let cat = catalog();
        let mut t1 = fresh_tree(&cat, Language::En);
        let t2 = fresh_tree(&cat, Language::En);
        t1.nodes[0].name = "MUTATED".to_string();
        assert_ne!(t1.nodes[0].name, t2.nodes[0].name);
    }

    #[test]
    fn find_node_top_level() {
        let cat = catalog();
        let node = find_node(&cat.nodes, "c:CDN").unwrap();
        assert_eq!(node.name, "CDN");
    }

    #[test]
    fn find_node_nested() {
        let cat = catalog();
        let node = find_node(&cat.nodes, "c:CDN/Cloudflare").unwrap();
        assert_eq!(node.name, "Cloudflare");
    }

    #[test]
    fn find_node_missing() {
        let cat = catalog();
        assert!(find_node(&cat.nodes, "c:DoesNotExist").is_none());
    }

    #[test]
    fn walk_visits_all() {
        let cat = catalog();
        let mut count = 0;
        walk_nodes(&cat.nodes, &mut |_| count += 1);
        assert!(count > 20);
    }

    #[test]
    fn id_to_path_top_level() {
        let cat = catalog();
        assert_eq!(id_to_path(&cat.nodes, "c:CDN"), Some("/CDN".to_string()));
    }

    #[test]
    fn id_to_path_nested() {
        let cat = catalog();
        assert_eq!(
            id_to_path(&cat.nodes, "c:CDN/Cloudflare"),
            Some("/CDN/Cloudflare".to_string())
        );
    }

    #[test]
    fn id_to_path_missing() {
        let cat = catalog();
        assert!(id_to_path(&cat.nodes, "c:NoSuch").is_none());
    }

    #[test]
    fn path_to_id_top_level() {
        let cat = catalog();
        assert_eq!(path_to_id(&cat.nodes, "/CDN"), Some("c:CDN".to_string()));
    }

    #[test]
    fn path_to_id_nested() {
        let cat = catalog();
        assert_eq!(
            path_to_id(&cat.nodes, "/CDN/Cloudflare"),
            Some("c:CDN/Cloudflare".to_string())
        );
    }

    #[test]
    fn path_to_id_missing() {
        let cat = catalog();
        assert!(path_to_id(&cat.nodes, "/CDN/DoesNotExist").is_none());
    }

    #[test]
    fn path_to_id_no_leading_slash() {
        let cat = catalog();
        assert!(path_to_id(&cat.nodes, "CDN").is_none());
    }

    #[test]
    fn path_to_id_empty_segment() {
        let cat = catalog();
        assert!(path_to_id(&cat.nodes, "/CDN//Cloudflare").is_none());
    }

    #[test]
    fn path_to_id_empty_string() {
        let cat = catalog();
        assert!(path_to_id(&cat.nodes, "").is_none());
    }

    #[test]
    fn path_to_id_root_only() {
        let cat = catalog();
        assert!(path_to_id(&cat.nodes, "/").is_none());
    }

    #[test]
    fn id_to_path_round_trip() {
        let cat = catalog();
        walk_nodes(&cat.nodes, &mut |n| {
            if n.source == NodeSource::Curated {
                let path = id_to_path(&cat.nodes, &n.id).unwrap();
                let id = path_to_id(&cat.nodes, &path).unwrap();
                assert_eq!(id, n.id, "round-trip failed for {}", n.id);
            }
        });
    }
}