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', " ")
}
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",
}
}
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()
}
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()
}