use serde::Serialize;
use crate::sbom_generation::domain::dependency_diff::{ChangeType, DependencyDiff};
use crate::shared::Result;
pub struct DiffJsonFormatter;
impl DiffJsonFormatter {
pub fn new() -> Self {
Self
}
pub fn format(&self, diff: &DependencyDiff) -> Result<String> {
let changes: Vec<ChangeDto> = diff
.changes
.iter()
.map(|c| {
let (old_version, new_version) = match c.change_type {
ChangeType::Added => (None, c.new_version.as_deref()),
ChangeType::Removed => (c.old_version.as_deref(), None),
ChangeType::Updated | ChangeType::Unchanged => {
(c.old_version.as_deref(), c.new_version.as_deref())
}
};
ChangeDto {
package: &c.package_name,
change: change_label(&c.change_type),
old_version,
new_version,
license: c.license.as_deref(),
}
})
.collect();
let envelope = DiffEnvelope {
diff: DiffBody {
base: &diff.base_ref,
summary: SummaryDto {
added: diff.summary.added,
removed: diff.summary.removed,
updated: diff.summary.updated,
unchanged: diff.summary.unchanged,
},
changes,
},
};
serde_json::to_string_pretty(&envelope).map_err(Into::into)
}
}
impl Default for DiffJsonFormatter {
fn default() -> Self {
Self::new()
}
}
fn change_label(change_type: &ChangeType) -> &'static str {
match change_type {
ChangeType::Added => "added",
ChangeType::Removed => "removed",
ChangeType::Updated => "updated",
ChangeType::Unchanged => "unchanged",
}
}
#[derive(Serialize)]
struct DiffEnvelope<'a> {
diff: DiffBody<'a>,
}
#[derive(Serialize)]
struct DiffBody<'a> {
base: &'a str,
summary: SummaryDto,
changes: Vec<ChangeDto<'a>>,
}
#[derive(Serialize)]
struct SummaryDto {
added: usize,
removed: usize,
updated: usize,
unchanged: usize,
}
#[derive(Serialize)]
struct ChangeDto<'a> {
package: &'a str,
change: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
old_version: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
new_version: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<&'a str>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom_generation::domain::dependency_diff::{
ChangeType, DependencyDiff, DiffSummary, PackageChange,
};
use serde_json::Value;
fn make_diff(changes: Vec<PackageChange>) -> DependencyDiff {
let summary = DiffSummary {
added: changes
.iter()
.filter(|c| c.change_type == ChangeType::Added)
.count(),
removed: changes
.iter()
.filter(|c| c.change_type == ChangeType::Removed)
.count(),
updated: changes
.iter()
.filter(|c| c.change_type == ChangeType::Updated)
.count(),
unchanged: changes
.iter()
.filter(|c| c.change_type == ChangeType::Unchanged)
.count(),
};
DependencyDiff {
base_ref: "main".to_string(),
changes,
summary,
}
}
fn make_change(
name: &str,
change_type: ChangeType,
old: Option<&str>,
new: Option<&str>,
license: Option<&str>,
vuln_count: usize,
) -> PackageChange {
PackageChange {
package_name: name.to_string(),
change_type,
old_version: old.map(str::to_string),
new_version: new.map(str::to_string),
license: license.map(str::to_string),
vulnerability_count: vuln_count,
}
}
#[test]
fn test_top_level_shape_and_base_ref() {
let diff = make_diff(vec![]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
assert!(json["diff"].is_object());
assert_eq!(json["diff"]["base"], "main");
assert!(json["diff"]["summary"].is_object());
assert!(json["diff"]["changes"].is_array());
}
#[test]
fn test_empty_diff() {
let diff = make_diff(vec![]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
assert_eq!(json["diff"]["summary"]["added"], 0);
assert_eq!(json["diff"]["summary"]["removed"], 0);
assert_eq!(json["diff"]["summary"]["updated"], 0);
assert_eq!(json["diff"]["summary"]["unchanged"], 0);
assert_eq!(json["diff"]["changes"].as_array().unwrap().len(), 0);
}
#[test]
fn test_added_omits_old_version() {
let diff = make_diff(vec![make_change(
"pydantic",
ChangeType::Added,
None,
Some("2.9.0"),
Some("MIT"),
0,
)]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
let change = &json["diff"]["changes"][0];
assert_eq!(change["change"], "added");
assert_eq!(change["new_version"], "2.9.0");
assert!(change["old_version"].is_null());
assert_eq!(change["license"], "MIT");
}
#[test]
fn test_removed_omits_new_version() {
let diff = make_diff(vec![make_change(
"flask",
ChangeType::Removed,
Some("3.0.0"),
None,
Some("BSD-3-Clause"),
0,
)]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
let change = &json["diff"]["changes"][0];
assert_eq!(change["change"], "removed");
assert_eq!(change["old_version"], "3.0.0");
assert!(change["new_version"].is_null());
}
#[test]
fn test_updated_keeps_both_versions() {
let diff = make_diff(vec![make_change(
"requests",
ChangeType::Updated,
Some("2.31.0"),
Some("2.32.0"),
Some("Apache-2.0"),
0,
)]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
let change = &json["diff"]["changes"][0];
assert_eq!(change["change"], "updated");
assert_eq!(change["old_version"], "2.31.0");
assert_eq!(change["new_version"], "2.32.0");
}
#[test]
fn test_unchanged_keeps_both_versions() {
let diff = make_diff(vec![make_change(
"urllib3",
ChangeType::Unchanged,
Some("1.26.0"),
Some("1.26.0"),
None,
0,
)]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
let change = &json["diff"]["changes"][0];
assert_eq!(change["change"], "unchanged");
assert_eq!(change["old_version"], "1.26.0");
assert_eq!(change["new_version"], "1.26.0");
}
#[test]
fn test_missing_license_omits_field() {
let diff = make_diff(vec![make_change(
"somelib",
ChangeType::Added,
None,
Some("1.0.0"),
None,
0,
)]);
let formatter = DiffJsonFormatter::new();
let json_str = formatter.format(&diff).unwrap();
let json: Value = serde_json::from_str(&json_str).unwrap();
assert!(json["diff"]["changes"][0]["license"].is_null());
assert!(!json_str.contains("\"license\""));
}
#[test]
fn test_all_change_types_in_one_diff() {
let diff = make_diff(vec![
make_change("a", ChangeType::Added, None, Some("1.0.0"), Some("MIT"), 0),
make_change(
"b",
ChangeType::Removed,
Some("0.5.0"),
None,
Some("Apache-2.0"),
0,
),
make_change(
"c",
ChangeType::Updated,
Some("2.0.0"),
Some("2.1.0"),
None,
1,
),
make_change(
"d",
ChangeType::Unchanged,
Some("3.0.0"),
Some("3.0.0"),
None,
0,
),
]);
let formatter = DiffJsonFormatter::new();
let json: Value = serde_json::from_str(&formatter.format(&diff).unwrap()).unwrap();
assert_eq!(json["diff"]["summary"]["added"], 1);
assert_eq!(json["diff"]["summary"]["removed"], 1);
assert_eq!(json["diff"]["summary"]["updated"], 1);
assert_eq!(json["diff"]["summary"]["unchanged"], 1);
assert_eq!(json["diff"]["changes"].as_array().unwrap().len(), 4);
}
}