svccat 1.5.0

Detect drift between your declared service catalog and what actually lives in the repo.
Documentation
use crate::drift::DriftReport;
use crate::manifest::Manifest;

/// Render drift items from a check as CSV.
///
/// Columns: service, severity, kind, message, detail
pub fn render_check(report: &DriftReport) {
    print!("{}", render_check_to_string(report));
}

/// Render drift items from a check as CSV and return it as a string.
pub fn render_check_to_string(report: &DriftReport) -> String {
    let mut out = String::from("service,severity,kind,message,detail\n");
    for item in &report.drifts {
        let kind = serde_json::to_value(&item.kind)
            .ok()
            .and_then(|v| v.as_str().map(str::to_owned))
            .unwrap_or_default();
        let severity = serde_json::to_value(&item.severity)
            .ok()
            .and_then(|v| v.as_str().map(str::to_owned))
            .unwrap_or_default();
        out.push_str(&format!(
            "{},{},{},{},{}\n",
            csv_field(&item.service),
            severity,
            kind,
            csv_field(&item.message),
            csv_field(item.detail.as_deref().unwrap_or(""))
        ));
    }
    out
}

/// Render the service catalog as CSV.
///
/// Columns: name, language, platform, role, url, team, oncall
pub fn render_export(manifest: &Manifest) {
    print!("{}", render_export_to_string(manifest));
}

/// Render the service catalog as CSV and return as a string.
pub fn render_export_to_string(manifest: &Manifest) -> String {
    let mut out = String::from("name,language,platform,role,url,team,oncall\n");
    for svc in &manifest.services {
        out.push_str(&format!(
            "{},{},{},{},{},{},{}\n",
            csv_field(&svc.name),
            csv_field(svc.language.as_deref().unwrap_or("")),
            csv_field(svc.platform.as_deref().unwrap_or("")),
            csv_field(svc.role.as_deref().unwrap_or("")),
            csv_field(svc.url.as_deref().unwrap_or("")),
            csv_field(svc.team.as_deref().unwrap_or("")),
            csv_field(svc.oncall.as_deref().unwrap_or(""))
        ));
    }
    out
}

/// Wrap a field value in double-quotes if it contains a comma, double-quote,
/// or newline, escaping internal double-quotes by doubling them (RFC 4180).
pub fn csv_field(s: &str) -> String {
    if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
        format!("\"{}\"", s.replace('"', "\"\""))
    } else {
        s.to_owned()
    }
}

#[cfg(test)]
mod tests {
    use super::{csv_field, render_check_to_string};
    use crate::drift::DriftReport;

    #[test]
    fn leaves_plain_values_unquoted() {
        assert_eq!(csv_field("service-a"), "service-a");
    }

    #[test]
    fn quotes_and_escapes_special_values() {
        assert_eq!(csv_field("a,b"), "\"a,b\"");
        assert_eq!(csv_field("a\"b"), "\"a\"\"b\"");
        assert_eq!(csv_field("a\nb"), "\"a\nb\"");
        assert_eq!(csv_field("a\rb"), "\"a\rb\"");
    }

    #[test]
    fn render_check_to_string_has_header_and_rows() {
        let report: DriftReport = serde_json::from_value(serde_json::json!({
            "manifest": "services.yaml",
            "declared": 1,
            "discovered": 0,
            "drifts": [
                {
                    "kind": "declared_missing_from_repo",
                    "severity": "error",
                    "service": "api,svc",
                    "message": "missing \"directory\"",
                    "detail": null
                }
            ]
        }))
        .unwrap();

        let csv = render_check_to_string(&report);
        assert!(csv.starts_with("service,severity,kind,message,detail\n"));
        assert!(csv.contains(
            "\"api,svc\",error,declared_missing_from_repo,\"missing \"\"directory\"\"\","
        ));
    }

    #[test]
    fn render_check_to_string_with_no_drift_keeps_header_only() {
        let report: DriftReport = serde_json::from_value(serde_json::json!({
            "manifest": "services.yaml",
            "declared": 1,
            "discovered": 1,
            "drifts": []
        }))
        .unwrap();

        let csv = render_check_to_string(&report);
        assert_eq!(csv, "service,severity,kind,message,detail\n");
    }

    #[test]
    fn render_check_to_string_quotes_multiline_detail() {
        let report: DriftReport = serde_json::from_value(serde_json::json!({
            "manifest": "services.yaml",
            "declared": 1,
            "discovered": 0,
            "drifts": [
                {
                    "kind": "declared_missing_from_repo",
                    "severity": "error",
                    "service": "api",
                    "message": "missing service directory",
                    "detail": "line one\nline two"
                }
            ]
        }))
        .unwrap();

        let csv = render_check_to_string(&report);
        assert!(csv.contains(
            "api,error,declared_missing_from_repo,missing service directory,\"line one\nline two\""
        ));
    }
}