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