smokeping-config 0.1.2

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

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProbeKind {
    FPing,
    DNS,
    EchoPingHttp,
    EchoPingHttps,
    EchoPingPlugin,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DnsRecordType {
    A,
    AAAA,
    MX,
    TXT,
    NS,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Probe {
    FPing,
    DNS {
        #[serde(skip_serializing_if = "Option::is_none")]
        lookup: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        #[serde(rename = "recordType")]
        record_type: Option<DnsRecordType>,
    },
    EchoPingHttp {
        url: String,
    },
    EchoPingHttps {
        url: String,
    },
    EchoPingPlugin {
        pingport: u16,
    },
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NodeSource {
    Curated,
    Custom,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NodeType {
    Category,
    Target,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Node {
    pub id: String,
    pub source: NodeSource,
    #[serde(rename = "type")]
    pub node_type: NodeType,
    pub name: String,
    pub menu: String,
    pub title: String,
    pub included: bool,
    pub children: Vec<Node>,
    #[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_attrs: Option<IndexMap<String, String>>,
}

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogVersion {
    pub date: String,
    pub sha: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Catalog {
    pub schema_ver: u8,
    pub root: RootMeta,
    pub nodes: Vec<Node>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<CatalogVersion>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Language {
    #[default]
    #[serde(rename = "en")]
    En,
    #[serde(rename = "zh-TW")]
    ZhTw,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkingTree {
    pub schema_ver: u8,
    pub root: RootMeta,
    pub nodes: Vec<Node>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<CatalogVersion>,
    pub language: Language,
}

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

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

    #[test]
    fn deserialize_catalog_json() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        assert_eq!(catalog.schema_ver, 2);
        assert_eq!(catalog.root.probe, ProbeKind::FPing);
        assert_eq!(catalog.root.menu, "Top");
        assert!(!catalog.nodes.is_empty());
    }

    #[test]
    fn catalog_version_populated() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let version = catalog.version.as_ref().expect("version should be present");
        assert!(version.date.len() == 10, "date should be YYYY-MM-DD format");
        assert!(
            version.sha.len() == 7 || version.sha == "unknown",
            "sha should be 7-char hex or 'unknown'"
        );
    }

    #[test]
    fn first_node_is_cdn_category() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let cdn = &catalog.nodes[0];
        assert_eq!(cdn.id, "c:CDN");
        assert_eq!(cdn.node_type, NodeType::Category);
        assert_eq!(cdn.source, NodeSource::Curated);
        assert!(cdn.included);
        assert!(!cdn.children.is_empty());
    }

    #[test]
    fn cloudflare_target_has_host() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let cf = &catalog.nodes[0].children[0];
        assert_eq!(cf.id, "c:CDN/Cloudflare");
        assert_eq!(cf.node_type, NodeType::Target);
        assert_eq!(cf.host.as_deref(), Some("cloudflare.com"));
        assert!(cf.children.is_empty());
    }

    #[test]
    fn dns_probes_node_has_probe() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let dns_probes = catalog
            .nodes
            .iter()
            .find(|n| n.name == "DNSProbes")
            .unwrap();
        let probe = dns_probes
            .probe
            .as_ref()
            .expect("DNSProbes should have a probe");
        assert!(matches!(probe, Probe::DNS { .. }));
    }

    #[test]
    fn comparison_children_parsed() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let asia = catalog.nodes.iter().find(|n| n.name == "Asia").unwrap();
        let cc = asia
            .comparison_children
            .as_ref()
            .expect("Asia should have comparisonChildren");
        assert!(!cc.is_empty());
        assert!(cc[0].starts_with('/'));
    }

    #[test]
    fn round_trip_json() {
        let catalog: Catalog = serde_json::from_str(CATALOG_JSON).unwrap();
        let json = serde_json::to_string(&catalog).unwrap();
        let catalog2: Catalog = serde_json::from_str(&json).unwrap();
        assert_eq!(catalog.schema_ver, catalog2.schema_ver);
        assert_eq!(catalog.nodes.len(), catalog2.nodes.len());
    }
}