svccat 1.5.0

Detect drift between your declared service catalog and what actually lives in the repo.
Documentation
use crate::manifest::Manifest;
use serde::Serialize;
use std::collections::HashMap;

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CatalogInfo {
    api_version: String,
    kind: String,
    metadata: CatalogMetadata,
    spec: CatalogSpec,
}

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CatalogMetadata {
    name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    annotations: HashMap<String, String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    links: Vec<CatalogLink>,
}

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CatalogLink {
    url: String,
    title: String,
}

#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct CatalogSpec {
    #[serde(rename = "type")]
    type_field: String,
    lifecycle: String,
    owner: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    system: Option<String>,
    #[serde(rename = "dependsOn", default, skip_serializing_if = "Vec::is_empty")]
    depends_on: Vec<String>,
}

pub fn render_export(manifest: &Manifest) -> Result<String, serde_yaml::Error> {
    let mut docs = Vec::new();
    for svc in &manifest.services {
        let mut annotations = HashMap::new();
        if let Some(ref oncall) = svc.oncall {
            annotations.insert("svccat.io/oncall".to_string(), oncall.clone());
        }
        if let Some(ref path) = svc.path {
            annotations.insert("svccat.io/path".to_string(), path.clone());
        }
        if let Some(ref docs_path) = svc.docs {
            annotations.insert("svccat.io/docs".to_string(), docs_path.clone());
        }
        if let Some(ref ci_path) = svc.ci {
            annotations.insert("svccat.io/ci".to_string(), ci_path.clone());
        }

        let mut tags = svc.tags.clone();
        if let Some(ref lang) = svc.language {
            tags.push(lang.to_lowercase());
        }

        let mut links = Vec::new();
        if let Some(ref url) = svc.url {
            links.push(CatalogLink {
                url: url.clone(),
                title: "Website".to_string(),
            });
        }

        let depends_on = svc
            .depends_on
            .iter()
            .map(|dep| format!("component:{}", dep))
            .collect();

        let info = CatalogInfo {
            api_version: "backstage.io/v1alpha1".to_string(),
            kind: "Component".to_string(),
            metadata: CatalogMetadata {
                name: svc.name.clone(),
                description: svc.role.clone(),
                annotations,
                tags,
                links,
            },
            spec: CatalogSpec {
                type_field: "service".to_string(),
                lifecycle: "production".to_string(),
                owner: svc.team.clone().unwrap_or_else(|| "unknown".to_string()),
                system: svc.platform.clone(),
                depends_on,
            },
        };
        docs.push(info);
    }

    let mut out = String::new();
    for (i, doc) in docs.iter().enumerate() {
        if i > 0 {
            out.push_str("---\n");
        }
        out.push_str(&serde_yaml::to_string(doc)?);
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::render_export;
    use crate::manifest::{Manifest, ServiceEntry};

    #[test]
    fn test_backstage_render_export() {
        let mut manifest = Manifest::default();
        manifest.services.push(ServiceEntry {
            name: "auth-service".to_string(),
            language: Some("Rust".to_string()),
            platform: Some("Fly.io".to_string()),
            role: Some("Authentication provider".to_string()),
            url: Some("https://auth.example.com".to_string()),
            team: Some("security".to_string()),
            depends_on: vec!["db".to_string()],
            ..Default::default()
        });

        let yaml = render_export(&manifest).unwrap();
        assert!(yaml.contains("apiVersion: backstage.io/v1alpha1"));
        assert!(yaml.contains("kind: Component"));
        assert!(yaml.contains("name: auth-service"));
        assert!(yaml.contains("description: Authentication provider"));
        assert!(yaml.contains("system: Fly.io"));
        assert!(yaml.contains("dependsOn:\n  - component:db"));
        assert!(yaml.contains("owner: security"));
        assert!(yaml.contains("type: service"));
        assert!(yaml.contains("lifecycle: production"));
        assert!(yaml.contains("url: https://auth.example.com"));
        assert!(yaml.contains("title: Website"));
        assert!(yaml.contains("rust"));
    }
}