fhir-diff 0.1.2

Semantic diff of FHIR packages and profiles with a breaking-change gate
Documentation
//! The report in Markdown (for people), JSON (for tools) and SARIF 2.1.0 (for code scanning
//! in CI).

use std::fmt::Write;

use serde_json::{Value, json};

use crate::diff::{RULES, Report, Severity, Status};

pub fn json(report: &Report) -> String {
    serde_json::to_string_pretty(report).unwrap_or_default()
}

fn escape(text: &str) -> String {
    text.replace('|', "\\|").replace('\n', " ")
}

/// Plain text for a terminal or a CI log: one line per change under its profile, then the
/// totals.
pub fn text(report: &Report, min_severity: Severity) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "fhir-diff {} → {}", report.old, report.new);
    let shown: Vec<_> = report
        .profiles
        .iter()
        .map(|p| {
            let changes: Vec<_> = p
                .changes
                .iter()
                .filter(|c| c.severity >= min_severity)
                .collect();
            (p, changes)
        })
        .filter(|(_, changes)| !changes.is_empty())
        .collect();
    let rule_width = shown
        .iter()
        .flat_map(|(_, changes)| changes.iter().map(|c| c.rule.len()))
        .max()
        .unwrap_or(0);
    let path_width = shown
        .iter()
        .flat_map(|(_, changes)| changes.iter().map(|c| c.path.chars().count()))
        .max()
        .unwrap_or(0)
        .min(48);
    for (p, changes) in &shown {
        let _ = writeln!(out, "\n{} ({}, {:?})", p.name, p.type_name, p.status);
        for c in changes {
            let line = format!(
                "  {:<8}  {:<rule_width$}  {:<path_width$}  {}",
                c.severity.as_str(),
                c.rule,
                c.path,
                c.message.replace('\n', " ")
            );
            let _ = writeln!(out, "{}", line.trim_end());
        }
    }
    let count = |status: Status| {
        report
            .profiles
            .iter()
            .filter(|p| p.status == status)
            .count()
    };
    let _ = writeln!(
        out,
        "\n{} profiles: {} added, {} removed, {} changed; {} breaking, {} warnings, {} info",
        report.profiles.len(),
        count(Status::Added),
        count(Status::Removed),
        count(Status::Changed),
        report.count(Severity::Breaking),
        report.count(Severity::Warning),
        report.count(Severity::Info),
    );
    for note in &report.notes {
        let _ = writeln!(out, "note: {note}");
    }
    out
}

fn badge(severity: Severity) -> &'static str {
    match severity {
        Severity::Breaking => "**breaking**",
        Severity::Warning => "warning",
        Severity::Info => "info",
    }
}

/// Markdown: a summary, the breaking changes, then every changed profile.
pub fn markdown(report: &Report, min_severity: Severity) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "# fhir-diff: {} → {}\n", report.old, report.new);
    let count = |status: Status| {
        report
            .profiles
            .iter()
            .filter(|p| p.status == status)
            .count()
    };
    let _ = writeln!(out, "| | Count |\n|---|---:|");
    for (label, n) in [
        ("Profiles compared", report.profiles.len()),
        ("Added", count(Status::Added)),
        ("Removed", count(Status::Removed)),
        ("Changed", count(Status::Changed)),
        ("Unchanged", count(Status::Unchanged)),
        ("Breaking changes", report.count(Severity::Breaking)),
        ("Warnings", report.count(Severity::Warning)),
        ("Other changes", report.count(Severity::Info)),
    ] {
        let _ = writeln!(out, "| {label} | {n} |");
    }
    out.push('\n');

    let breaking: Vec<_> = report
        .profiles
        .iter()
        .filter(|p| p.worst() == Some(Severity::Breaking))
        .collect();
    if !breaking.is_empty() {
        let _ = writeln!(out, "## Breaking changes by profile\n");
        let _ = writeln!(out, "| Profile | Breaking | Warnings |\n|---|---:|---:|");
        for p in &breaking {
            let n = |s: Severity| p.changes.iter().filter(|c| c.severity == s).count();
            let _ = writeln!(
                out,
                "| [{}](#{}) | {} | {} |",
                escape(&p.name),
                anchor(&p.name),
                n(Severity::Breaking),
                n(Severity::Warning)
            );
        }
        out.push('\n');
    }

    let _ = writeln!(out, "## Profiles\n");
    for p in &report.profiles {
        let shown: Vec<_> = p
            .changes
            .iter()
            .filter(|c| c.severity >= min_severity)
            .collect();
        if shown.is_empty() {
            continue;
        }
        let versions = match (&p.old_version, &p.new_version) {
            (Some(o), Some(n)) if o != n => format!(" ({o} → {n})"),
            _ => String::new(),
        };
        let _ = writeln!(out, "### {}\n", p.name);
        let _ = writeln!(
            out,
            "`{}`{} — {}, {:?}\n",
            p.url, versions, p.type_name, p.status
        );
        let _ = writeln!(
            out,
            "| Severity | Rule | Element | Change |\n|---|---|---|---|"
        );
        for c in shown {
            let _ = writeln!(
                out,
                "| {} | `{}` | `{}` | {} |",
                badge(c.severity),
                c.rule,
                escape(&c.path),
                escape(&c.message)
            );
        }
        out.push('\n');
    }
    if !report.notes.is_empty() {
        let _ = writeln!(out, "## Notes\n");
        for s in &report.notes {
            let _ = writeln!(out, "- {}", escape(s));
        }
    }
    out
}

fn anchor(name: &str) -> String {
    name.to_lowercase()
        .chars()
        .filter_map(|c| match c {
            'a'..='z' | '0'..='9' | '-' | '_' => Some(c),
            ' ' => Some('-'),
            _ => None,
        })
        .collect()
}

/// SARIF 2.1.0: one result per change at or above `min_severity`, located at the profile's
/// file in the package and, logically, at `url#path`.
pub fn sarif(report: &Report, min_severity: Severity) -> String {
    let level = |severity: Severity| match severity {
        Severity::Breaking => "error",
        Severity::Warning => "warning",
        Severity::Info => "note",
    };
    let rules: Vec<Value> = RULES
        .iter()
        .map(|(id, severity, text)| {
            json!({
                "id": id,
                "shortDescription": {"text": text},
                "defaultConfiguration": {"level": level(*severity)},
                "properties": {"severity": severity.as_str()},
            })
        })
        .collect();
    let results: Vec<Value> = report
        .profiles
        .iter()
        .flat_map(|p| p.changes.iter().map(move |c| (p, c)))
        .filter(|(_, c)| c.severity >= min_severity)
        .map(|(p, c)| {
            json!({
                "ruleId": c.rule,
                "level": level(c.severity),
                "message": {"text": format!("{}: {}", c.path, c.message)},
                "locations": [{
                    "physicalLocation": {"artifactLocation": {"uri": format!("package/{}", p.file)}},
                    "logicalLocations": [{
                        "fullyQualifiedName": format!("{}#{}", p.url, c.path),
                        "name": c.path,
                        "kind": "member",
                    }],
                }],
                "properties": {"profile": p.url, "old": c.old, "new": c.new},
            })
        })
        .collect();
    let document = json!({
        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": {"driver": {
                "name": "fhir-diff",
                "version": env!("CARGO_PKG_VERSION"),
                "informationUri": "https://github.com/octofhir/fhir-diff",
                "rules": rules,
            }},
            "properties": {"old": report.old, "new": report.new},
            "results": results,
        }],
    });
    serde_json::to_string_pretty(&document).unwrap_or_default()
}