use clap::{Parser, ValueEnum};
use comfy_table::Table;
use miette::{IntoDiagnostic, Result};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::PathBuf;
use verifyos_cli::report::{ReportData, SlowRule};
use verifyos_cli::rules::core::{RuleCategory, RuleStatus, Severity};
#[derive(Debug, Clone, ValueEnum)]
pub enum SummaryFormat {
Table,
Json,
}
#[derive(Debug, Parser)]
pub struct SummaryArgs {
#[arg(long)]
pub report: PathBuf,
#[arg(long, value_enum, default_value = "table")]
pub format: SummaryFormat,
#[arg(long, default_value_t = 5)]
pub top: usize,
}
pub fn run(args: SummaryArgs) -> Result<()> {
let raw = std::fs::read_to_string(&args.report).into_diagnostic()?;
let report: ReportData = serde_json::from_str(&raw).into_diagnostic()?;
let summary = build_summary(&report, args.top);
match args.format {
SummaryFormat::Table => {
println!("{}", render_table(&summary));
}
SummaryFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&summary).into_diagnostic()?
);
}
}
Ok(())
}
#[derive(Debug, Serialize)]
struct SummaryData {
ruleset_version: String,
generated_at_unix: u64,
total_duration_ms: u128,
scanned_target_count: usize,
totals: Totals,
status_breakdown: Vec<CountItem>,
severity_breakdown: Vec<CountItem>,
category_breakdown: Vec<CountItem>,
slow_rules: Vec<SlowRule>,
top_findings: Vec<TopFinding>,
}
#[derive(Debug, Serialize)]
struct Totals {
total_rules: usize,
failing_rules: usize,
passing_rules: usize,
skipped_rules: usize,
error_rules: usize,
}
#[derive(Debug, Serialize)]
struct CountItem {
label: String,
count: usize,
}
#[derive(Debug, Serialize)]
struct TopFinding {
rule_id: String,
rule_name: String,
target: String,
severity: String,
status: String,
message: String,
}
fn build_summary(report: &ReportData, top: usize) -> SummaryData {
let total_rules = report.results.len();
let failing_rules = report
.results
.iter()
.filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
.count();
let passing_rules = report
.results
.iter()
.filter(|item| item.status == RuleStatus::Pass)
.count();
let skipped_rules = report
.results
.iter()
.filter(|item| item.status == RuleStatus::Skip)
.count();
let error_rules = report
.results
.iter()
.filter(|item| item.status == RuleStatus::Error)
.count();
SummaryData {
ruleset_version: report.ruleset_version.clone(),
generated_at_unix: report.generated_at_unix,
total_duration_ms: report.total_duration_ms,
scanned_target_count: report.scanned_targets.len(),
totals: Totals {
total_rules,
failing_rules,
passing_rules,
skipped_rules,
error_rules,
},
status_breakdown: count_statuses(report),
severity_breakdown: count_severities(report),
category_breakdown: count_categories(report),
slow_rules: report.slow_rules.clone(),
top_findings: top_findings(report, top),
}
}
fn count_statuses(report: &ReportData) -> Vec<CountItem> {
[
("PASS", RuleStatus::Pass),
("FAIL", RuleStatus::Fail),
("ERROR", RuleStatus::Error),
("SKIP", RuleStatus::Skip),
]
.into_iter()
.map(|(label, status)| CountItem {
label: label.to_string(),
count: report
.results
.iter()
.filter(|item| item.status == status)
.count(),
})
.collect()
}
fn count_severities(report: &ReportData) -> Vec<CountItem> {
[
("ERROR", Severity::Error),
("WARNING", Severity::Warning),
("INFO", Severity::Info),
]
.into_iter()
.map(|(label, severity)| CountItem {
label: label.to_string(),
count: report
.results
.iter()
.filter(|item| item.severity == severity)
.count(),
})
.collect()
}
fn count_categories(report: &ReportData) -> Vec<CountItem> {
let mut counts = BTreeMap::new();
for item in &report.results {
let key = match item.category {
RuleCategory::Privacy => "Privacy",
RuleCategory::Signing => "Signing",
RuleCategory::Bundling => "Bundling",
RuleCategory::Entitlements => "Entitlements",
RuleCategory::Ats => "ATS",
RuleCategory::ThirdParty => "ThirdParty",
RuleCategory::Permissions => "Permissions",
RuleCategory::Metadata => "Metadata",
RuleCategory::Other => "Other",
};
*counts.entry(key.to_string()).or_insert(0usize) += 1;
}
counts
.into_iter()
.map(|(label, count)| CountItem { label, count })
.collect()
}
fn top_findings(report: &ReportData, top: usize) -> Vec<TopFinding> {
let mut items: Vec<TopFinding> = report
.results
.iter()
.filter(|item| matches!(item.status, RuleStatus::Fail | RuleStatus::Error))
.map(|item| TopFinding {
rule_id: item.rule_id.clone(),
rule_name: item.rule_name.clone(),
target: item.target.clone(),
severity: format!("{:?}", item.severity),
status: format!("{:?}", item.status),
message: item
.message
.clone()
.unwrap_or_else(|| item.rule_name.clone()),
})
.collect();
items.sort_by(|a, b| {
severity_rank(&a.severity)
.cmp(&severity_rank(&b.severity))
.then_with(|| a.rule_id.cmp(&b.rule_id))
.then_with(|| a.target.cmp(&b.target))
});
items.truncate(top);
items
}
fn severity_rank(value: &str) -> usize {
match value {
"Error" => 0,
"Warning" => 1,
"Info" => 2,
_ => 3,
}
}
fn render_table(summary: &SummaryData) -> String {
let mut out = String::new();
out.push_str("# verifyOS summary\n\n");
out.push_str(&format!(
"Ruleset `{}` scanned {} target(s) in {} ms.\n\n",
summary.ruleset_version, summary.scanned_target_count, summary.total_duration_ms
));
let mut totals = Table::new();
totals.set_header(vec![
"Total rules",
"Failing",
"Passing",
"Skipped",
"Errors",
]);
totals.add_row(vec![
summary.totals.total_rules.to_string(),
summary.totals.failing_rules.to_string(),
summary.totals.passing_rules.to_string(),
summary.totals.skipped_rules.to_string(),
summary.totals.error_rules.to_string(),
]);
out.push_str("Totals\n");
out.push_str(&totals.to_string());
out.push_str("\n\n");
out.push_str("Status breakdown\n");
out.push_str(&render_count_table(&summary.status_breakdown));
out.push_str("\n\n");
out.push_str("Severity breakdown\n");
out.push_str(&render_count_table(&summary.severity_breakdown));
out.push_str("\n\n");
out.push_str("Category breakdown\n");
out.push_str(&render_count_table(&summary.category_breakdown));
if !summary.slow_rules.is_empty() {
let mut slow_rules = Table::new();
slow_rules.set_header(vec!["Rule ID", "Name", "Time (ms)"]);
for item in &summary.slow_rules {
slow_rules.add_row(vec![
item.rule_id.clone(),
item.rule_name.clone(),
item.duration_ms.to_string(),
]);
}
out.push_str("\n\nSlowest rules\n");
out.push_str(&slow_rules.to_string());
}
if !summary.top_findings.is_empty() {
let mut findings = Table::new();
findings.set_header(vec!["Rule ID", "Severity", "Target", "Status", "Message"]);
for item in &summary.top_findings {
findings.add_row(vec![
item.rule_id.clone(),
item.severity.clone(),
item.target.clone(),
item.status.clone(),
item.message.clone(),
]);
}
out.push_str("\n\nTop findings\n");
out.push_str(&findings.to_string());
}
out
}
fn render_count_table(items: &[CountItem]) -> String {
let mut table = Table::new();
table.set_header(vec!["Label", "Count"]);
for item in items {
table.add_row(vec![item.label.clone(), item.count.to_string()]);
}
table.to_string()
}