1use crate::report::data::{ReportData, SlowRule, TimingMode};
2use crate::rules::core::{ArtifactCacheStats, CacheCounter, RuleStatus, Severity};
3use comfy_table::modifiers::UTF8_ROUND_CORNERS;
4use comfy_table::presets::UTF8_FULL;
5use comfy_table::{Cell, Color, Table};
6use textwrap::wrap;
7
8pub fn render_table(report: &ReportData, timing_mode: TimingMode) -> String {
9 let mut table = Table::new();
10 let mut header = vec!["Rule", "Category", "Severity", "Status", "Message"];
11 if timing_mode == TimingMode::Full {
12 header.push("Time");
13 }
14 table
15 .load_preset(UTF8_FULL)
16 .apply_modifier(UTF8_ROUND_CORNERS)
17 .set_header(header);
18
19 for res in &report.results {
20 let severity_cell = match res.severity {
21 Severity::Error => Cell::new("ERROR").fg(Color::Red),
22 Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
23 Severity::Info => Cell::new("INFO").fg(Color::Blue),
24 };
25
26 let status_cell = match res.status {
27 RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
28 RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
29 RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
30 RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
31 };
32
33 let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
34 let wrapped = wrap(&message, 50).join("\n");
35
36 let mut row = vec![
37 Cell::new(res.rule_name.clone()),
38 Cell::new(format!("{:?}", res.category)),
39 severity_cell,
40 status_cell,
41 Cell::new(wrapped),
42 ];
43 if timing_mode == TimingMode::Full {
44 row.push(Cell::new(format!("{} ms", res.duration_ms)));
45 }
46 table.add_row(row);
47 }
48
49 if timing_mode != TimingMode::Off {
50 let slow_rules = format_slow_rules(report.slow_rules.clone());
51 let cache_summary = format_cache_stats(&report.cache_stats);
52 format!(
53 "{}\nTotal scan time: {} ms{}{}\n",
54 table, report.total_duration_ms, slow_rules, cache_summary
55 )
56 } else {
57 format!("{table}")
58 }
59}
60
61pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
62 serde_json::to_string_pretty(report)
63}
64
65pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
66 let mut rules = Vec::new();
67 let mut results = Vec::new();
68
69 for item in &report.results {
70 rules.push(serde_json::json!({
71 "id": item.rule_id,
72 "name": item.rule_name,
73 "shortDescription": { "text": item.rule_name },
74 "fullDescription": { "text": item.message.clone().unwrap_or_default() },
75 "help": { "text": item.recommendation },
76 "properties": {
77 "category": format!("{:?}", item.category),
78 "severity": format!("{:?}", item.severity),
79 "durationMs": item.duration_ms,
80 }
81 }));
82
83 if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
84 results.push(serde_json::json!({
85 "ruleId": item.rule_id,
86 "level": sarif_level(item.severity),
87 "message": {
88 "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
89 },
90 "properties": {
91 "category": format!("{:?}", item.category),
92 "evidence": item.evidence.clone().unwrap_or_default(),
93 "durationMs": item.duration_ms,
94 }
95 }));
96 }
97 }
98
99 let sarif = serde_json::json!({
100 "version": "2.1.0",
101 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
102 "runs": [
103 {
104 "invocations": [
105 {
106 "executionSuccessful": true,
107 "properties": {
108 "totalDurationMs": report.total_duration_ms,
109 "slowRules": sarif_slow_rules(&report.slow_rules),
110 "cacheStats": report.cache_stats,
111 }
112 }
113 ],
114 "tool": {
115 "driver": {
116 "name": "verifyos-cli",
117 "semanticVersion": report.ruleset_version,
118 "rules": rules
119 }
120 },
121 "properties": {
122 "totalDurationMs": report.total_duration_ms,
123 "slowRules": sarif_slow_rules(&report.slow_rules),
124 "cacheStats": report.cache_stats,
125 },
126 "results": results
127 }
128 ]
129 });
130
131 serde_json::to_string_pretty(&sarif)
132}
133
134pub fn render_markdown(
135 report: &ReportData,
136 suppressed: Option<usize>,
137 timing_mode: TimingMode,
138) -> String {
139 let total = report.results.len();
140 let fail_count = report
141 .results
142 .iter()
143 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
144 .count();
145 let warn_count = report
146 .results
147 .iter()
148 .filter(|r| r.severity == Severity::Warning)
149 .count();
150 let error_count = report
151 .results
152 .iter()
153 .filter(|r| r.severity == Severity::Error)
154 .count();
155
156 let mut out = String::new();
157 out.push_str("# verifyOS-cli Report\n\n");
158 out.push_str(&format!("- Total rules: {total}\n"));
159 out.push_str(&format!("- Failures: {fail_count}\n"));
160 out.push_str(&format!(
161 "- Severity: error={error_count}, warning={warn_count}\n"
162 ));
163 if timing_mode != TimingMode::Off {
164 out.push_str(&format!(
165 "- Total scan time: {} ms\n",
166 report.total_duration_ms
167 ));
168 if !report.slow_rules.is_empty() {
169 out.push_str("- Slowest rules:\n");
170 for item in &report.slow_rules {
171 out.push_str(&format!(
172 " - {} (`{}`): {} ms\n",
173 item.rule_name, item.rule_id, item.duration_ms
174 ));
175 }
176 }
177 let cache_lines = markdown_cache_stats(&report.cache_stats);
178 if !cache_lines.is_empty() {
179 out.push_str("- Cache activity:\n");
180 for line in cache_lines {
181 out.push_str(&format!(" - {}\n", line));
182 }
183 }
184 }
185 if let Some(suppressed) = suppressed {
186 out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
187 }
188 out.push('\n');
189
190 let mut failures = report
191 .results
192 .iter()
193 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error));
194
195 if failures.next().is_none() {
196 out.push_str("## Findings\n\n- No failing findings.\n");
197 return out;
198 }
199
200 out.push_str("## Findings\n\n");
201 for item in report
202 .results
203 .iter()
204 .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
205 {
206 out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
207 out.push_str(&format!(" - Category: `{:?}`\n", item.category));
208 out.push_str(&format!(" - Severity: `{:?}`\n", item.severity));
209 out.push_str(&format!(" - Status: `{:?}`\n", item.status));
210 if let Some(message) = &item.message {
211 out.push_str(&format!(" - Message: {}\n", message));
212 }
213 if let Some(evidence) = &item.evidence {
214 out.push_str(&format!(" - Evidence: {}\n", evidence));
215 }
216 if !item.recommendation.is_empty() {
217 out.push_str(&format!(" - Recommendation: {}\n", item.recommendation));
218 }
219 if timing_mode == TimingMode::Full {
220 out.push_str(&format!(" - Time: {} ms\n", item.duration_ms));
221 }
222 }
223
224 out
225}
226
227fn format_slow_rules(items: Vec<SlowRule>) -> String {
228 if items.is_empty() {
229 return String::new();
230 }
231
232 let parts: Vec<String> = items
233 .into_iter()
234 .map(|item| format!("{} ({} ms)", item.rule_id, item.duration_ms))
235 .collect();
236 format!("\nSlowest rules: {}", parts.join(", "))
237}
238
239fn format_cache_stats(stats: &ArtifactCacheStats) -> String {
240 let lines = markdown_cache_stats(stats);
241 if lines.is_empty() {
242 return String::new();
243 }
244
245 format!("\nCache activity: {}", lines.join(", "))
246}
247
248fn markdown_cache_stats(stats: &ArtifactCacheStats) -> Vec<String> {
249 let counters = [
250 ("nested_bundles", stats.nested_bundles),
251 ("usage_scan", stats.usage_scan),
252 ("private_api_scan", stats.private_api_scan),
253 ("sdk_scan", stats.sdk_scan),
254 ("capability_scan", stats.capability_scan),
255 ("signature_summary", stats.signature_summary),
256 ("bundle_plist", stats.bundle_plist),
257 ("entitlements", stats.entitlements),
258 ("provisioning_profile", stats.provisioning_profile),
259 ("bundle_files", stats.bundle_files),
260 ];
261
262 counters
263 .into_iter()
264 .filter(|(_, counter)| counter.hits > 0 || counter.misses > 0)
265 .map(|(name, counter)| format_cache_counter(name, counter))
266 .collect()
267}
268
269fn format_cache_counter(name: &str, counter: CacheCounter) -> String {
270 format!("{name} h/m={}/{}", counter.hits, counter.misses)
271}
272
273fn sarif_slow_rules(items: &[SlowRule]) -> Vec<serde_json::Value> {
274 items
275 .iter()
276 .map(|item| {
277 serde_json::json!({
278 "ruleId": item.rule_id,
279 "ruleName": item.rule_name,
280 "durationMs": item.duration_ms,
281 })
282 })
283 .collect()
284}
285
286fn sarif_level(severity: Severity) -> &'static str {
287 match severity {
288 Severity::Error => "error",
289 Severity::Warning => "warning",
290 Severity::Info => "note",
291 }
292}