Skip to main content

verifyos_cli/report/
renderers.rs

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}