verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
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 {
    /// Path to a verifyOS JSON report generated by `voc --format json`
    #[arg(long)]
    pub report: PathBuf,

    /// Output format
    #[arg(long, value_enum, default_value = "table")]
    pub format: SummaryFormat,

    /// Number of failing findings to include in the summary
    #[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()
}