use serde::Serialize;
use crate::models::{Finding, Severity, SuspectValue};
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
}
}
pub struct JsonFormatter;
impl JsonFormatter {
pub fn new() -> Self {
Self
}
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]);
assert!(serde_json::from_str::<serde_json::Value>(&output).is_ok());
}
}