verifyos-cli 0.13.1

AI agent-friendly Rust CLI for scanning iOS app bundles for App Store rejection risks before submission.
Documentation
use verifyos_cli::report::{
    build_agent_pack, render_agent_pack_markdown, render_json, render_markdown, render_sarif,
    render_table, should_exit_with_failure, top_slow_rules, FailOn, ReportData, ReportItem,
    SlowRule, TimingMode,
};
use verifyos_cli::rules::core::{
    ArtifactCacheStats, CacheCounter, RuleCategory, RuleStatus, Severity,
};

fn sample_report(items: Vec<ReportItem>) -> ReportData {
    ReportData {
        ruleset_version: "0.1.0".to_string(),
        generated_at_unix: 0,
        total_duration_ms: 42,
        cache_stats: ArtifactCacheStats {
            usage_scan: CacheCounter { hits: 2, misses: 1 },
            bundle_plist: CacheCounter { hits: 1, misses: 1 },
            ..ArtifactCacheStats::default()
        },
        slow_rules: vec![SlowRule {
            rule_id: "RULE_SAMPLE".to_string(),
            rule_name: "Sample Rule".to_string(),
            duration_ms: 7,
        }],
        results: items,
        scanned_targets: vec!["TestApp.app".to_string()],
    }
}

fn sample_item(severity: Severity, status: RuleStatus) -> ReportItem {
    ReportItem {
        rule_id: "RULE_SAMPLE".to_string(),
        rule_name: "Sample Rule".to_string(),
        category: RuleCategory::Other,
        severity,
        target: "TestApp.app".to_string(),
        status,
        message: None,
        evidence: None,
        recommendation: String::new(),
        duration_ms: 7,
    }
}

#[test]
fn fail_on_off_never_fails() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    assert!(!should_exit_with_failure(&report, FailOn::Off));
}

#[test]
fn fail_on_error_only_fails_for_error_findings() {
    let warning_report = sample_report(vec![sample_item(Severity::Warning, RuleStatus::Fail)]);
    let error_report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);

    assert!(!should_exit_with_failure(&warning_report, FailOn::Error));
    assert!(should_exit_with_failure(&error_report, FailOn::Error));
}

#[test]
fn fail_on_warning_fails_for_warning_and_error_findings() {
    let warning_report = sample_report(vec![sample_item(Severity::Warning, RuleStatus::Fail)]);
    let error_report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Error)]);

    assert!(should_exit_with_failure(&warning_report, FailOn::Warning));
    assert!(should_exit_with_failure(&error_report, FailOn::Warning));
}

#[test]
fn render_table_omits_timings_by_default() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    let output = render_table(&report, TimingMode::Off);

    assert!(!output.contains("Time"));
    assert!(!output.contains("Total scan time"));
}

#[test]
fn render_table_shows_timings_when_enabled() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    let output = render_table(&report, TimingMode::Full);

    assert!(output.contains("Time"));
    assert!(output.contains("Total scan time: 42 ms"));
    assert!(output.contains("Slowest rules: RULE_SAMPLE (7 ms)"));
    assert!(output.contains("Cache activity: usage_scan h/m=2/1, bundle_plist h/m=1/1"));
}

#[test]
fn render_markdown_shows_timings_when_enabled() {
    let report = sample_report(vec![sample_item(Severity::Warning, RuleStatus::Fail)]);
    let output = render_markdown(&report, Some(1), TimingMode::Full);

    assert!(output.contains("- Total scan time: 42 ms"));
    assert!(output.contains("- Slowest rules:"));
    assert!(output.contains("- Cache activity:"));
    assert!(output.contains("  - usage_scan h/m=2/1"));
    assert!(output.contains("  - Time: 7 ms"));
}

#[test]
fn render_table_summary_mode_hides_per_rule_column() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    let output = render_table(&report, TimingMode::Summary);

    assert!(!output.contains("│ Time"));
    assert!(output.contains("Total scan time: 42 ms"));
    assert!(output.contains("Slowest rules: RULE_SAMPLE (7 ms)"));
}

#[test]
fn render_markdown_summary_mode_hides_per_rule_time_lines() {
    let report = sample_report(vec![sample_item(Severity::Warning, RuleStatus::Fail)]);
    let output = render_markdown(&report, Some(1), TimingMode::Summary);

    assert!(output.contains("- Total scan time: 42 ms"));
    assert!(output.contains("- Cache activity:"));
    assert!(!output.contains("  - Time: 7 ms"));
}

#[test]
fn top_slow_rules_returns_descending_breakdown() {
    let mut slow = sample_item(Severity::Warning, RuleStatus::Pass);
    slow.rule_id = "RULE_SLOW".to_string();
    slow.rule_name = "Slow Rule".to_string();
    slow.duration_ms = 25;

    let mut fast = sample_item(Severity::Info, RuleStatus::Pass);
    fast.rule_id = "RULE_FAST".to_string();
    fast.rule_name = "Fast Rule".to_string();
    fast.duration_ms = 3;

    let mut medium = sample_item(Severity::Error, RuleStatus::Fail);
    medium.rule_id = "RULE_MEDIUM".to_string();
    medium.rule_name = "Medium Rule".to_string();
    medium.duration_ms = 10;

    let report = sample_report(vec![medium, fast, slow]);
    let breakdown = top_slow_rules(&report, 2);

    assert_eq!(breakdown.len(), 2);
    assert_eq!(breakdown[0].rule_id, "RULE_SLOW");
    assert_eq!(breakdown[1].rule_id, "RULE_MEDIUM");
}

#[test]
fn render_json_includes_machine_readable_perf_sections() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    let json = render_json(&report).expect("json render");
    let value: serde_json::Value = serde_json::from_str(&json).expect("parse json");

    assert_eq!(value["total_duration_ms"], 42);
    assert_eq!(value["slow_rules"][0]["rule_id"], "RULE_SAMPLE");
    assert_eq!(value["cache_stats"]["usage_scan"]["hits"], 2);
}

#[test]
fn render_sarif_includes_perf_metadata() {
    let report = sample_report(vec![sample_item(Severity::Error, RuleStatus::Fail)]);
    let sarif = render_sarif(&report).expect("sarif render");
    let value: serde_json::Value = serde_json::from_str(&sarif).expect("parse sarif");

    assert_eq!(
        value["runs"][0]["properties"]["totalDurationMs"],
        serde_json::Value::from(42)
    );
    assert_eq!(
        value["runs"][0]["properties"]["slowRules"][0]["ruleId"],
        "RULE_SAMPLE"
    );
    assert_eq!(
        value["runs"][0]["invocations"][0]["properties"]["cacheStats"]["usage_scan"]["hits"],
        serde_json::Value::from(2)
    );
}

#[test]
fn build_agent_pack_extracts_fix_focused_findings() {
    let mut pass_item = sample_item(Severity::Info, RuleStatus::Pass);
    pass_item.rule_id = "RULE_PASS".to_string();

    let mut fail_item = sample_item(Severity::Error, RuleStatus::Fail);
    fail_item.rule_id = "RULE_PRIVATE_API".to_string();
    fail_item.rule_name = "Private API Usage Detected".to_string();
    fail_item.category = RuleCategory::ThirdParty;
    fail_item.message = Some("Private API signatures found".to_string());
    fail_item.evidence = Some("LSApplicationWorkspace".to_string());
    fail_item.recommendation =
        "Remove private API usage or replace with public alternatives.".to_string();

    let report = sample_report(vec![pass_item, fail_item]);
    let pack = build_agent_pack(&report);

    assert_eq!(pack.total_findings, 1);
    assert_eq!(pack.findings[0].rule_id, "RULE_PRIVATE_API");
    assert_eq!(pack.findings[0].priority, "high");
    assert_eq!(pack.findings[0].suggested_fix_scope, "dependencies");
    assert_eq!(
        pack.findings[0].target_files,
        vec!["Linked SDKs or app binary".to_string()]
    );
    assert!(pack.findings[0]
        .patch_hint
        .contains("Remove or replace private API references"));
    assert!(pack.findings[0]
        .why_it_fails_review
        .contains("Private API usage"));
}

#[test]
fn render_agent_pack_markdown_groups_findings_by_scope() {
    let mut privacy = sample_item(Severity::Warning, RuleStatus::Fail);
    privacy.rule_id = "RULE_USAGE_DESCRIPTIONS".to_string();
    privacy.rule_name = "Missing required usage description keys".to_string();
    privacy.category = RuleCategory::Privacy;
    privacy.message = Some("Missing NSCameraUsageDescription".to_string());
    privacy.recommendation = "Add the missing usage description key.".to_string();

    let mut bundle = sample_item(Severity::Error, RuleStatus::Fail);
    bundle.rule_id = "RULE_BUNDLE_LEAKAGE".to_string();
    bundle.rule_name = "Sensitive Files in Bundle".to_string();
    bundle.category = RuleCategory::Bundling;
    bundle.message = Some("Found .env in app bundle".to_string());
    bundle.evidence = Some(".env".to_string());
    bundle.recommendation = "Remove sensitive files from the shipped app bundle.".to_string();

    let report = sample_report(vec![privacy, bundle]);
    let markdown = render_agent_pack_markdown(&build_agent_pack(&report));

    assert!(markdown.contains("# verifyOS Agent Fix Pack"));
    assert!(markdown.contains("### Info.plist"));
    assert!(markdown.contains("### bundle-resources"));
    assert!(markdown.contains("`RULE_USAGE_DESCRIPTIONS`"));
    assert!(markdown.contains("`RULE_BUNDLE_LEAKAGE`"));
    assert!(markdown.contains("Why it fails review"));
    assert!(markdown.contains("Patch hint"));
}

#[test]
fn build_agent_pack_targets_info_plist_rules() {
    let mut item = sample_item(Severity::Warning, RuleStatus::Fail);
    item.rule_id = "RULE_USAGE_DESCRIPTIONS".to_string();
    item.rule_name = "Missing required usage description keys".to_string();
    item.category = RuleCategory::Privacy;
    item.message = Some("Missing NSCameraUsageDescription".to_string());
    item.recommendation = "Add usage descriptions".to_string();

    let pack = build_agent_pack(&sample_report(vec![item]));

    assert_eq!(pack.findings.len(), 1);
    assert_eq!(
        pack.findings[0].target_files,
        vec!["Info.plist".to_string()]
    );
    assert!(pack.findings[0]
        .patch_hint
        .contains("Update Info.plist with the required NS*UsageDescription"));
    assert!(pack.findings[0]
        .why_it_fails_review
        .contains("protected APIs"));
}