verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use crate::report::data::{ReportData, SlowRule, TimingMode};
use crate::rules::core::{ArtifactCacheStats, CacheCounter, RuleStatus, Severity};
use comfy_table::modifiers::UTF8_ROUND_CORNERS;
use comfy_table::presets::UTF8_FULL;
use comfy_table::{Cell, Color, Table};
use textwrap::wrap;

pub fn render_table(report: &ReportData, timing_mode: TimingMode) -> String {
    let mut table = Table::new();
    let mut header = vec![
        "Rule", "Target", "Category", "Severity", "Status", "Message",
    ];
    if timing_mode == TimingMode::Full {
        header.push("Time");
    }
    table
        .load_preset(UTF8_FULL)
        .apply_modifier(UTF8_ROUND_CORNERS)
        .set_header(header);

    for res in &report.results {
        let severity_cell = match res.severity {
            Severity::Error => Cell::new("ERROR").fg(Color::Red),
            Severity::Warning => Cell::new("WARNING").fg(Color::Yellow),
            Severity::Info => Cell::new("INFO").fg(Color::Blue),
        };

        let status_cell = match res.status {
            RuleStatus::Pass => Cell::new("PASS").fg(Color::Green),
            RuleStatus::Fail => Cell::new("FAIL").fg(Color::Red),
            RuleStatus::Error => Cell::new("ERROR").fg(Color::Red),
            RuleStatus::Skip => Cell::new("SKIP").fg(Color::Yellow),
        };

        let message = res.message.clone().unwrap_or_else(|| "PASS".to_string());
        let wrapped = wrap(&message, 40).join("\n");

        let mut row = vec![
            Cell::new(res.rule_name.clone()),
            Cell::new(res.target.clone()).fg(Color::Cyan),
            Cell::new(format!("{:?}", res.category)),
            severity_cell,
            status_cell,
            Cell::new(wrapped),
        ];
        if timing_mode == TimingMode::Full {
            row.push(Cell::new(format!("{} ms", res.duration_ms)));
        }
        table.add_row(row);
    }

    if timing_mode != TimingMode::Off {
        let slow_rules = format_slow_rules(report.slow_rules.clone());
        let cache_summary = format_cache_stats(&report.cache_stats);
        format!(
            "{}\nTotal scan time: {} ms{}{}\n",
            table, report.total_duration_ms, slow_rules, cache_summary
        )
    } else {
        format!("{table}")
    }
}

pub fn render_json(report: &ReportData) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(report)
}

pub fn render_sarif(report: &ReportData) -> Result<String, serde_json::Error> {
    let mut rules = Vec::new();
    let mut results = Vec::new();

    for item in &report.results {
        rules.push(serde_json::json!({
            "id": item.rule_id,
            "name": item.rule_name,
            "shortDescription": { "text": item.rule_name },
            "fullDescription": { "text": item.message.clone().unwrap_or_default() },
            "help": { "text": item.recommendation },
            "properties": {
                "category": format!("{:?}", item.category),
                "severity": format!("{:?}", item.severity),
                "durationMs": item.duration_ms,
            }
        }));

        if item.status == RuleStatus::Fail || item.status == RuleStatus::Error {
            results.push(serde_json::json!({
                "ruleId": item.rule_id,
                "level": sarif_level(item.severity),
                "message": {
                    "text": item.message.clone().unwrap_or_else(|| item.rule_name.clone())
                },
                "properties": {
                    "category": format!("{:?}", item.category),
                    "evidence": item.evidence.clone().unwrap_or_default(),
                    "durationMs": item.duration_ms,
                }
            }));
        }
    }

    let sarif = serde_json::json!({
        "version": "2.1.0",
        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
        "runs": [
            {
                "invocations": [
                    {
                        "executionSuccessful": true,
                        "properties": {
                            "totalDurationMs": report.total_duration_ms,
                            "slowRules": sarif_slow_rules(&report.slow_rules),
                            "cacheStats": report.cache_stats,
                        }
                    }
                ],
                "tool": {
                    "driver": {
                        "name": "verifyos-cli",
                        "semanticVersion": report.ruleset_version,
                        "rules": rules
                    }
                },
                "properties": {
                    "totalDurationMs": report.total_duration_ms,
                    "slowRules": sarif_slow_rules(&report.slow_rules),
                    "cacheStats": report.cache_stats,
                },
                "results": results
            }
        ]
    });

    serde_json::to_string_pretty(&sarif)
}

pub fn render_markdown(
    report: &ReportData,
    suppressed: Option<usize>,
    timing_mode: TimingMode,
) -> String {
    let total = report.results.len();
    let fail_count = report
        .results
        .iter()
        .filter(|r| matches!(r.status, RuleStatus::Fail | RuleStatus::Error))
        .count();
    let warn_count = report
        .results
        .iter()
        .filter(|r| r.severity == Severity::Warning)
        .count();
    let error_count = report
        .results
        .iter()
        .filter(|r| r.severity == Severity::Error)
        .count();

    let mut out = String::new();
    out.push_str("# verifyOS-cli Report\n\n");
    out.push_str(&format!("- Total rules: {total}\n"));
    out.push_str(&format!("- Failures: {fail_count}\n"));
    out.push_str(&format!(
        "- Severity: error={error_count}, warning={warn_count}\n"
    ));
    if timing_mode != TimingMode::Off {
        out.push_str(&format!(
            "- Total scan time: {} ms\n",
            report.total_duration_ms
        ));
        if !report.slow_rules.is_empty() {
            out.push_str("- Slowest rules:\n");
            for item in &report.slow_rules {
                out.push_str(&format!(
                    "  - {} (`{}`): {} ms\n",
                    item.rule_name, item.rule_id, item.duration_ms
                ));
            }
        }
        let cache_lines = markdown_cache_stats(&report.cache_stats);
        if !cache_lines.is_empty() {
            out.push_str("- Cache activity:\n");
            for line in cache_lines {
                out.push_str(&format!("  - {}\n", line));
            }
        }
    }
    if let Some(suppressed) = suppressed {
        out.push_str(&format!("- Baseline suppressed: {suppressed}\n"));
    }
    out.push('\n');

    out.push_str("## Findings\n\n");
    for target in &report.scanned_targets {
        let target_findings: Vec<_> = report
            .results
            .iter()
            .filter(|r| {
                &r.target == target && matches!(r.status, RuleStatus::Fail | RuleStatus::Error)
            })
            .collect();

        if target_findings.is_empty() {
            continue;
        }

        out.push_str(&format!("### Target: `{target}`\n\n"));
        for item in target_findings {
            out.push_str(&format!("- **{}** (`{}`)\n", item.rule_name, item.rule_id));
            out.push_str(&format!("  - Category: `{:?}`\n", item.category));
            out.push_str(&format!("  - Severity: `{:?}`\n", item.severity));
            out.push_str(&format!("  - Status: `{:?}`\n", item.status));
            if let Some(message) = &item.message {
                out.push_str(&format!("  - Message: {}\n", message));
            }
            if let Some(evidence) = &item.evidence {
                out.push_str(&format!("  - Evidence: {}\n", evidence));
            }
            if !item.recommendation.is_empty() {
                out.push_str(&format!("  - Recommendation: {}\n", item.recommendation));
            }
            if timing_mode == TimingMode::Full {
                out.push_str(&format!("  - Time: {} ms\n", item.duration_ms));
            }
            out.push('\n');
        }
    }

    out
}

fn format_slow_rules(items: Vec<SlowRule>) -> String {
    if items.is_empty() {
        return String::new();
    }

    let parts: Vec<String> = items
        .into_iter()
        .map(|item| format!("{} ({} ms)", item.rule_id, item.duration_ms))
        .collect();
    format!("\nSlowest rules: {}", parts.join(", "))
}

fn format_cache_stats(stats: &ArtifactCacheStats) -> String {
    let lines = markdown_cache_stats(stats);
    if lines.is_empty() {
        return String::new();
    }

    format!("\nCache activity: {}", lines.join(", "))
}

fn markdown_cache_stats(stats: &ArtifactCacheStats) -> Vec<String> {
    let counters = [
        ("nested_bundles", stats.nested_bundles),
        ("usage_scan", stats.usage_scan),
        ("private_api_scan", stats.private_api_scan),
        ("sdk_scan", stats.sdk_scan),
        ("capability_scan", stats.capability_scan),
        ("signature_summary", stats.signature_summary),
        ("bundle_plist", stats.bundle_plist),
        ("entitlements", stats.entitlements),
        ("provisioning_profile", stats.provisioning_profile),
        ("bundle_files", stats.bundle_files),
    ];

    counters
        .into_iter()
        .filter(|(_, counter)| counter.hits > 0 || counter.misses > 0)
        .map(|(name, counter)| format_cache_counter(name, counter))
        .collect()
}

fn format_cache_counter(name: &str, counter: CacheCounter) -> String {
    format!("{name} h/m={}/{}", counter.hits, counter.misses)
}

fn sarif_slow_rules(items: &[SlowRule]) -> Vec<serde_json::Value> {
    items
        .iter()
        .map(|item| {
            serde_json::json!({
                "ruleId": item.rule_id,
                "ruleName": item.rule_name,
                "durationMs": item.duration_ms,
            })
        })
        .collect()
}

fn sarif_level(severity: Severity) -> &'static str {
    match severity {
        Severity::Error => "error",
        Severity::Warning => "warning",
        Severity::Info => "note",
    }
}