use crate::model::graph::DependencyGraph;
use crate::model::package::PackageInfo;
use serde_json::json;
use uuid::Uuid;
pub struct CycloneDxReporter;
impl CycloneDxReporter {
pub fn render(graph: &DependencyGraph) -> String {
let serial_number = format!("urn:uuid:{}", Uuid::new_v4());
let mut components = Vec::new();
let mut dependencies = Vec::new();
for node in graph.all_packages() {
let ecosystem = ecosystem_for(node);
let purl = format!("pkg:{}/{}@{}", ecosystem, node.id.name, node.id.version);
let bom_ref = format!("{}@{}", node.id.name, node.id.version);
let license_obj = if let Some(expr) = &node.license.normalized {
if expr.trim().is_empty() || expr.eq_ignore_ascii_case("UNKNOWN") {
json!([ { "license": { "name": "NOASSERTION" } } ])
} else {
json!([ { "license": { "id": expr } } ])
}
} else if node.license.raw.trim().is_empty()
|| node.license.raw.eq_ignore_ascii_case("UNKNOWN")
{
json!([ { "license": { "name": "NOASSERTION" } } ])
} else {
json!([ { "license": { "name": node.license.raw } } ])
};
components.push(json!({
"type": "library",
"bom-ref": bom_ref,
"name": node.id.name,
"version": node.id.version,
"purl": purl,
"licenses": license_obj
}));
let dep_refs: Vec<String> = graph
.direct_dependencies_of(&node.id)
.into_iter()
.map(|dep| format!("{}@{}", dep.id.name, dep.id.version))
.collect();
dependencies.push(json!({
"ref": bom_ref,
"dependsOn": dep_refs
}));
}
let bom = json!({
"$schema": "http://cyclonedx.org/schema/bom-1.5.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": serial_number,
"version": 1,
"metadata": {
"timestamp": chrono::Utc::now().to_rfc3339(),
"tools": [
{
"vendor": "license-trace",
"name": "license-trace",
"version": env!("CARGO_PKG_VERSION")
}
]
},
"components": components,
"dependencies": dependencies
});
serde_json::to_string_pretty(&bom).unwrap_or_default()
}
}
fn ecosystem_for(node: &PackageInfo) -> &'static str {
if let Some(path_str) = &node.manifest_path {
let path = path_str.to_lowercase().replace('\\', "/");
if path.ends_with("cargo.toml") || path.ends_with("cargo.lock") {
return "cargo";
}
if path.ends_with("package.json")
|| path.ends_with("package-lock.json")
|| path.ends_with("yarn.lock")
|| path.ends_with("pnpm-lock.yaml")
{
return "npm";
}
if path.ends_with("pyproject.toml")
|| path.ends_with("requirements.txt")
|| path.ends_with("uv.lock")
|| path.ends_with("poetry.lock")
|| path.ends_with("pipfile")
{
return "pypi";
}
if path.ends_with("go.mod") || path.ends_with("go.sum") {
return "golang";
}
}
let name = &node.id.name;
if name.starts_with('@') && name.contains('/') {
return "npm";
}
"generic"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::package::{DependencyScope, DependencyType, PackageId};
#[test]
fn test_cyclonedx_render() {
let mut root_pkg = PackageInfo::new(
PackageId::new("my-project", "0.1.0"),
"MIT",
DependencyType::Direct,
DependencyScope::Production,
);
root_pkg.manifest_path = Some("Cargo.toml".to_string());
let mut graph = DependencyGraph::new(root_pkg);
let mut pkg1 = PackageInfo::new(
PackageId::new("serde", "1.0.0"),
"MIT OR Apache-2.0",
DependencyType::Direct,
DependencyScope::Production,
);
pkg1.manifest_path = Some("Cargo.toml".to_string());
graph.get_or_add_node(pkg1);
let output = CycloneDxReporter::render(&graph);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("valid CycloneDX JSON");
assert_eq!(parsed["bomFormat"], "CycloneDX");
assert_eq!(parsed["specVersion"], "1.5");
let components = parsed["components"].as_array().unwrap();
assert_eq!(components.len(), 2);
assert!(components
.iter()
.any(|c| c["name"] == "serde" && c["purl"] == "pkg:cargo/serde@1.0.0"));
}
}