vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/output/json.rs

use serde::Serialize;

use crate::models::{Finding, Severity, SuspectValue};

/// Safely truncate a string at a character boundary
fn truncate_str(s: &str, max_chars: usize) -> String {
    let truncated: String = s.chars().take(max_chars).collect();
    if truncated.len() < s.len() {
        format!("{}...", truncated)
    } else {
        truncated
    }
}

/// JSON formatter for machine-readable output
pub struct JsonFormatter;

impl JsonFormatter {
    pub fn new() -> Self {
        Self
    }

    /// Format findings to JSON
    pub fn format(&self, findings: &[Finding]) -> String {
        let output = JsonOutput {
            version: env!("CARGO_PKG_VERSION").to_string(),
            findings_count: findings.len(),
            findings: findings.iter().map(JsonFinding::from).collect(),
        };

        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
    }
}

impl Default for JsonFormatter {
    fn default() -> Self {
        Self::new()
    }
}

#[derive(Serialize)]
struct JsonOutput {
    version: String,
    findings_count: usize,
    findings: Vec<JsonFinding>,
}

#[derive(Serialize)]
struct JsonFinding {
    id: String,
    severity: String,
    score: i32,
    file: String,
    line: u32,
    column: u32,
    suspect_type: String,
    suspect_name: Option<String>,
    suspect_value_preview: String,
    explanation: String,
    remediation: Option<String>,
    factors: Vec<JsonFactor>,
}

#[derive(Serialize)]
struct JsonFactor {
    category: String,
    reason: String,
    contribution: i32,
}

impl From<&Finding> for JsonFinding {
    fn from(f: &Finding) -> Self {
        let (suspect_type, suspect_name, value) = match &f.suspect {
            SuspectValue::Constant { name, value, .. } => {
                ("constant".to_string(), Some(name.clone()), value.clone())
            }
            SuspectValue::Static { name, value, .. } => {
                ("static".to_string(), Some(name.clone()), value.clone())
            }
            SuspectValue::InlineLiteral { value } => ("literal".to_string(), None, value.clone()),
            SuspectValue::EnvDefault {
                var_name,
                default_value,
            } => (
                "env_default".to_string(),
                Some(var_name.clone()),
                default_value.clone(),
            ),
        };

        let value_preview = truncate_str(&value, 50);

        JsonFinding {
            id: f.id.0.clone(),
            severity: severity_to_string(&f.score.severity),
            score: f.score.total,
            file: f.location.file.to_string_lossy().to_string(),
            line: f.location.line,
            column: f.location.column,
            suspect_type,
            suspect_name,
            suspect_value_preview: value_preview,
            explanation: f.explanation.clone(),
            remediation: f.remediation.clone(),
            factors: f
                .score
                .factors
                .iter()
                .map(|factor| JsonFactor {
                    category: format!("{:?}", factor.category),
                    reason: factor.reason.clone(),
                    contribution: factor.contribution,
                })
                .collect(),
        }
    }
}

fn severity_to_string(severity: &Severity) -> String {
    match severity {
        Severity::Critical => "critical",
        Severity::High => "high",
        Severity::Medium => "medium",
        Severity::Low => "low",
        Severity::Info => "info",
        Severity::None => "none",
    }
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{FindingId, Score, SourceLocation};
    use std::path::PathBuf;

    fn create_test_finding() -> Finding {
        Finding {
            id: FindingId::new("test-finding-1"),
            suspect: SuspectValue::Constant {
                name: "API_KEY".to_string(),
                value: "sk_live_12345".to_string(),
                type_annotation: Some("&str".to_string()),
            },
            location: SourceLocation::new(PathBuf::from("src/main.rs"), 10, 5),
            usage: None,
            context: Default::default(),
            score: Score::from_total(85),
            explanation: "Test explanation".to_string(),
            remediation: Some("Test remediation".to_string()),
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn test_json_format_empty() {
        let formatter = JsonFormatter::new();
        let output = formatter.format(&[]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        assert_eq!(parsed["findings_count"], 0);
        assert!(parsed["findings"].as_array().unwrap().is_empty());
    }

    #[test]
    fn test_json_format_single_finding() {
        let formatter = JsonFormatter::new();
        let finding = create_test_finding();
        let output = formatter.format(&[finding]);
        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

        assert_eq!(parsed["findings_count"], 1);
        let finding = &parsed["findings"][0];
        assert_eq!(finding["id"], "test-finding-1");
        assert_eq!(finding["severity"], "high");
        assert_eq!(finding["score"], 85);
        assert_eq!(finding["suspect_name"], "API_KEY");
    }

    #[test]
    fn test_json_is_valid() {
        let formatter = JsonFormatter::new();
        let finding = create_test_finding();
        let output = formatter.format(&[finding]);

        // Should be valid JSON
        assert!(serde_json::from_str::<serde_json::Value>(&output).is_ok());
    }
}