1use 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
18pub 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
92pub 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
196pub 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}