license-trace 0.1.3

A recursive license tracer, obligations evaluator, and OSS compliance analyzer across dependency graphs
Documentation
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"));
    }
}