Skip to main content

fhir_diff/
report.rs

1//! The report in Markdown (for people), JSON (for tools) and SARIF 2.1.0 (for code scanning
2//! in CI).
3
4use std::fmt::Write;
5
6use serde_json::{Value, json};
7
8use crate::diff::{RULES, Report, Severity, Status};
9
10pub fn json(report: &Report) -> String {
11    serde_json::to_string_pretty(report).unwrap_or_default()
12}
13
14fn escape(text: &str) -> String {
15    text.replace('|', "\\|").replace('\n', " ")
16}
17
18/// Plain text for a terminal or a CI log: one line per change under its profile, then the
19/// totals.
20pub fn text(report: &Report, min_severity: Severity) -> String {
21    let mut out = String::new();
22    let _ = writeln!(out, "fhir-diff {} → {}", report.old, report.new);
23    let shown: Vec<_> = report
24        .profiles
25        .iter()
26        .map(|p| {
27            let changes: Vec<_> = p
28                .changes
29                .iter()
30                .filter(|c| c.severity >= min_severity)
31                .collect();
32            (p, changes)
33        })
34        .filter(|(_, changes)| !changes.is_empty())
35        .collect();
36    let rule_width = shown
37        .iter()
38        .flat_map(|(_, changes)| changes.iter().map(|c| c.rule.len()))
39        .max()
40        .unwrap_or(0);
41    let path_width = shown
42        .iter()
43        .flat_map(|(_, changes)| changes.iter().map(|c| c.path.chars().count()))
44        .max()
45        .unwrap_or(0)
46        .min(48);
47    for (p, changes) in &shown {
48        let _ = writeln!(out, "\n{} ({}, {:?})", p.name, p.type_name, p.status);
49        for c in changes {
50            let line = format!(
51                "  {:<8}  {:<rule_width$}  {:<path_width$}  {}",
52                c.severity.as_str(),
53                c.rule,
54                c.path,
55                c.message.replace('\n', " ")
56            );
57            let _ = writeln!(out, "{}", line.trim_end());
58        }
59    }
60    let count = |status: Status| {
61        report
62            .profiles
63            .iter()
64            .filter(|p| p.status == status)
65            .count()
66    };
67    let _ = writeln!(
68        out,
69        "\n{} profiles: {} added, {} removed, {} changed; {} breaking, {} warnings, {} info",
70        report.profiles.len(),
71        count(Status::Added),
72        count(Status::Removed),
73        count(Status::Changed),
74        report.count(Severity::Breaking),
75        report.count(Severity::Warning),
76        report.count(Severity::Info),
77    );
78    for note in &report.notes {
79        let _ = writeln!(out, "note: {note}");
80    }
81    out
82}
83
84fn badge(severity: Severity) -> &'static str {
85    match severity {
86        Severity::Breaking => "**breaking**",
87        Severity::Warning => "warning",
88        Severity::Info => "info",
89    }
90}
91
92/// Markdown: a summary, the breaking changes, then every changed profile.
93pub fn markdown(report: &Report, min_severity: Severity) -> String {
94    let mut out = String::new();
95    let _ = writeln!(out, "# fhir-diff: {} → {}\n", report.old, report.new);
96    let count = |status: Status| {
97        report
98            .profiles
99            .iter()
100            .filter(|p| p.status == status)
101            .count()
102    };
103    let _ = writeln!(out, "| | Count |\n|---|---:|");
104    for (label, n) in [
105        ("Profiles compared", report.profiles.len()),
106        ("Added", count(Status::Added)),
107        ("Removed", count(Status::Removed)),
108        ("Changed", count(Status::Changed)),
109        ("Unchanged", count(Status::Unchanged)),
110        ("Breaking changes", report.count(Severity::Breaking)),
111        ("Warnings", report.count(Severity::Warning)),
112        ("Other changes", report.count(Severity::Info)),
113    ] {
114        let _ = writeln!(out, "| {label} | {n} |");
115    }
116    out.push('\n');
117
118    let breaking: Vec<_> = report
119        .profiles
120        .iter()
121        .filter(|p| p.worst() == Some(Severity::Breaking))
122        .collect();
123    if !breaking.is_empty() {
124        let _ = writeln!(out, "## Breaking changes by profile\n");
125        let _ = writeln!(out, "| Profile | Breaking | Warnings |\n|---|---:|---:|");
126        for p in &breaking {
127            let n = |s: Severity| p.changes.iter().filter(|c| c.severity == s).count();
128            let _ = writeln!(
129                out,
130                "| [{}](#{}) | {} | {} |",
131                escape(&p.name),
132                anchor(&p.name),
133                n(Severity::Breaking),
134                n(Severity::Warning)
135            );
136        }
137        out.push('\n');
138    }
139
140    let _ = writeln!(out, "## Profiles\n");
141    for p in &report.profiles {
142        let shown: Vec<_> = p
143            .changes
144            .iter()
145            .filter(|c| c.severity >= min_severity)
146            .collect();
147        if shown.is_empty() {
148            continue;
149        }
150        let versions = match (&p.old_version, &p.new_version) {
151            (Some(o), Some(n)) if o != n => format!(" ({o} → {n})"),
152            _ => String::new(),
153        };
154        let _ = writeln!(out, "### {}\n", p.name);
155        let _ = writeln!(
156            out,
157            "`{}`{} — {}, {:?}\n",
158            p.url, versions, p.type_name, p.status
159        );
160        let _ = writeln!(
161            out,
162            "| Severity | Rule | Element | Change |\n|---|---|---|---|"
163        );
164        for c in shown {
165            let _ = writeln!(
166                out,
167                "| {} | `{}` | `{}` | {} |",
168                badge(c.severity),
169                c.rule,
170                escape(&c.path),
171                escape(&c.message)
172            );
173        }
174        out.push('\n');
175    }
176    if !report.notes.is_empty() {
177        let _ = writeln!(out, "## Notes\n");
178        for s in &report.notes {
179            let _ = writeln!(out, "- {}", escape(s));
180        }
181    }
182    out
183}
184
185fn anchor(name: &str) -> String {
186    name.to_lowercase()
187        .chars()
188        .filter_map(|c| match c {
189            'a'..='z' | '0'..='9' | '-' | '_' => Some(c),
190            ' ' => Some('-'),
191            _ => None,
192        })
193        .collect()
194}
195
196/// SARIF 2.1.0: one result per change at or above `min_severity`, located at the profile's
197/// file in the package and, logically, at `url#path`.
198pub fn sarif(report: &Report, min_severity: Severity) -> String {
199    let level = |severity: Severity| match severity {
200        Severity::Breaking => "error",
201        Severity::Warning => "warning",
202        Severity::Info => "note",
203    };
204    let rules: Vec<Value> = RULES
205        .iter()
206        .map(|(id, severity, text)| {
207            json!({
208                "id": id,
209                "shortDescription": {"text": text},
210                "defaultConfiguration": {"level": level(*severity)},
211                "properties": {"severity": severity.as_str()},
212            })
213        })
214        .collect();
215    let results: Vec<Value> = report
216        .profiles
217        .iter()
218        .flat_map(|p| p.changes.iter().map(move |c| (p, c)))
219        .filter(|(_, c)| c.severity >= min_severity)
220        .map(|(p, c)| {
221            json!({
222                "ruleId": c.rule,
223                "level": level(c.severity),
224                "message": {"text": format!("{}: {}", c.path, c.message)},
225                "locations": [{
226                    "physicalLocation": {"artifactLocation": {"uri": format!("package/{}", p.file)}},
227                    "logicalLocations": [{
228                        "fullyQualifiedName": format!("{}#{}", p.url, c.path),
229                        "name": c.path,
230                        "kind": "member",
231                    }],
232                }],
233                "properties": {"profile": p.url, "old": c.old, "new": c.new},
234            })
235        })
236        .collect();
237    let document = json!({
238        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
239        "version": "2.1.0",
240        "runs": [{
241            "tool": {"driver": {
242                "name": "fhir-diff",
243                "version": env!("CARGO_PKG_VERSION"),
244                "informationUri": "https://github.com/octofhir/fhir-diff",
245                "rules": rules,
246            }},
247            "properties": {"old": report.old, "new": report.new},
248            "results": results,
249        }],
250    });
251    serde_json::to_string_pretty(&document).unwrap_or_default()
252}