allow-report 0.1.1

Report and receipt rendering for cargo-allow source exception scans.
Documentation
use allow_core::{MatchOutcome, json_escape};

use crate::{
    ARTIFACT_STATUS_FAILED, ARTIFACT_STATUS_PASSED, ArtifactContract, CLAIM_BOUNDARY,
    InventoryContext, SCANNER_LIMITATIONS,
};

pub(crate) fn push_json_artifact_header(
    out: &mut String,
    contract: ArtifactContract,
    command: &str,
) {
    if let Some(fixed_command) = contract.fixed_command {
        debug_assert_eq!(fixed_command, command);
    }
    let schema_version = contract.schema_version;
    out.push_str(&format!("  \"schema_version\": {schema_version},\n"));
    out.push_str(&format!(
        "  \"schema_id\": \"{}\",\n",
        json_escape(contract.schema_id)
    ));
    out.push_str("  \"tool\": \"cargo-allow\",\n");
    out.push_str(&format!("  \"command\": \"{}\",\n", json_escape(command)));
}

pub(crate) fn push_json_artifact_preamble(
    out: &mut String,
    contract: ArtifactContract,
    command: &str,
    inventory: InventoryContext<'_>,
) {
    push_json_artifact_header(out, contract, command);
    push_json_artifact_source_context(out, inventory);
}

pub(crate) fn push_json_fixed_artifact_preamble(
    out: &mut String,
    contract: ArtifactContract,
    inventory: InventoryContext<'_>,
) {
    let Some(command) = contract.fixed_command else {
        std::panic::panic_any("fixed artifact preamble requires a fixed-command artifact contract");
    };
    push_json_artifact_preamble(out, contract, command, inventory);
}

pub(crate) fn push_json_artifact_source_context(out: &mut String, inventory: InventoryContext<'_>) {
    out.push_str(&format!(
        "  \"claim_boundary\": {},\n",
        render_claim_boundary_json()
    ));
    out.push_str(&format!(
        "  \"scanner_limitations\": {},\n",
        render_scanner_limitations_json()
    ));
    out.push_str("  \"inventory\": ");
    out.push_str(&render_inventory_json(inventory, "  "));
    out.push_str(",\n");
}

pub(crate) fn push_json_status_fields(out: &mut String, failed: bool) {
    out.push_str(&format!(
        "  \"status\": \"{}\",\n",
        if failed {
            ARTIFACT_STATUS_FAILED
        } else {
            ARTIFACT_STATUS_PASSED
        }
    ));
    out.push_str(&format!("  \"failed\": {},\n", bool_json(failed)));
}

pub(crate) fn push_json_source_context_properties(
    out: &mut String,
    inventory: InventoryContext<'_>,
    indent: &str,
) {
    out.push_str(&format!("{indent}\"inventory\": "));
    out.push_str(&render_inventory_json(inventory, indent));
    out.push_str(",\n");
    out.push_str(&format!(
        "{indent}\"claim_boundary\": {},\n",
        render_claim_boundary_json()
    ));
    out.push_str(&format!(
        "{indent}\"scanner_limitations\": {}\n",
        render_scanner_limitations_json()
    ));
}

pub(crate) fn option_json(value: Option<&str>) -> String {
    value
        .map(|v| format!("\"{}\"", json_escape(v)))
        .unwrap_or_else(|| "null".to_string())
}

pub(crate) fn bool_json(value: bool) -> &'static str {
    if value { "true" } else { "false" }
}

pub(crate) fn option_u32_json(value: Option<u32>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "null".to_string())
}

pub(crate) fn option_usize_json(value: Option<usize>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "null".to_string())
}

pub(crate) fn render_match_outcome_json(outcome: &MatchOutcome, indent: &str) -> String {
    let fields = MatchOutcomeJsonFields::new(outcome);
    format!(
        "{indent}  {{\n{indent}    \"status\": \"{}\",\n{indent}    \"allow_id\": {},\n{indent}    \"finding_index\": {},\n{indent}    \"score\": {},\n{indent}    \"message\": \"{}\"\n{indent}  }}",
        fields.status, fields.allow_id, fields.finding_index, fields.score, fields.message
    )
}

pub(crate) fn render_match_outcome_json_compact(outcome: &MatchOutcome) -> String {
    let fields = MatchOutcomeJsonFields::new(outcome);
    format!(
        "{{\"status\": \"{}\", \"allow_id\": {}, \"finding_index\": {}, \"score\": {}, \"message\": \"{}\"}}",
        fields.status, fields.allow_id, fields.finding_index, fields.score, fields.message
    )
}

struct MatchOutcomeJsonFields {
    status: &'static str,
    allow_id: String,
    finding_index: String,
    score: u32,
    message: String,
}

impl MatchOutcomeJsonFields {
    fn new(outcome: &MatchOutcome) -> Self {
        Self {
            status: outcome.status.as_str(),
            allow_id: option_json(outcome.allow_id.as_deref()),
            finding_index: option_usize_json(outcome.finding_index),
            score: outcome.score,
            message: json_escape(&outcome.message),
        }
    }
}

pub(crate) fn json_string_array<T: AsRef<str>>(values: &[T]) -> String {
    format!(
        "[{}]",
        values
            .iter()
            .map(|value| format!("\"{}\"", json_escape(value.as_ref())))
            .collect::<Vec<_>>()
            .join(", ")
    )
}

pub fn render_claim_boundary_json() -> String {
    json_string_array(CLAIM_BOUNDARY)
}

pub fn render_scanner_limitations_json() -> String {
    json_string_array(SCANNER_LIMITATIONS)
}

pub fn render_inventory_json(context: InventoryContext<'_>, indent: &str) -> String {
    let mut out = String::new();
    out.push_str("{\n");
    out.push_str(&format!(
        "{indent}  \"scope\": \"{}\",\n",
        json_escape(context.scope)
    ));
    out.push_str(&format!(
        "{indent}  \"scanner\": \"{}\",\n",
        json_escape(context.scanner)
    ));
    out.push_str(&format!(
        "{indent}  \"source\": \"{}\"",
        json_escape(context.source)
    ));
    if let Some(root) = context.root {
        out.push_str(",\n");
        out.push_str(&format!("{indent}  \"root\": \"{}\"", json_escape(root)));
    }
    if let Some(files) = context.files_scanned {
        out.push_str(",\n");
        out.push_str(&format!("{indent}  \"files_scanned\": {files}"));
    }
    out.push('\n');
    out.push_str(&format!("{indent}}}"));
    out
}