use super::{CanonicalId, Component, DependencyEdge, NormalizedSbom};
use std::collections::HashMap;
#[derive(Debug, Clone)]
#[must_use]
pub struct NormalizedSbomIndex {
edges_by_source: HashMap<CanonicalId, Vec<usize>>,
edges_by_target: HashMap<CanonicalId, Vec<usize>>,
by_name_lower: HashMap<String, Vec<CanonicalId>>,
sort_keys: HashMap<CanonicalId, ComponentSortKey>,
bom_ref_map: HashMap<String, CanonicalId>,
component_count: usize,
edge_count: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ComponentSortKey {
pub name_lower: String,
pub version_lower: String,
pub ecosystem_lower: String,
pub id_lower: String,
pub purl_lower: String,
pub group_lower: String,
}
impl ComponentSortKey {
#[must_use]
pub fn from_component(comp: &Component) -> Self {
Self {
name_lower: comp.name.to_lowercase(),
version_lower: comp.version.as_deref().unwrap_or("").to_lowercase(),
ecosystem_lower: comp
.ecosystem
.as_ref()
.map(|e| e.to_string().to_lowercase())
.unwrap_or_default(),
id_lower: comp.canonical_id.value().to_lowercase(),
purl_lower: comp
.identifiers
.purl
.as_deref()
.unwrap_or("")
.to_lowercase(),
group_lower: comp.group.as_deref().unwrap_or("").to_lowercase(),
}
}
#[must_use]
pub fn contains(&self, query_lower: &str) -> bool {
self.name_lower.contains(query_lower)
|| self.version_lower.contains(query_lower)
|| self.purl_lower.contains(query_lower)
|| self.id_lower.contains(query_lower)
}
#[must_use]
pub fn name_contains(&self, query_lower: &str) -> bool {
self.name_lower.contains(query_lower)
}
}
impl NormalizedSbomIndex {
pub fn build(sbom: &NormalizedSbom) -> Self {
let mut edges_by_source: HashMap<CanonicalId, Vec<usize>> = HashMap::new();
let mut edges_by_target: HashMap<CanonicalId, Vec<usize>> = HashMap::new();
let mut by_name_lower: HashMap<String, Vec<CanonicalId>> = HashMap::new();
let mut sort_keys: HashMap<CanonicalId, ComponentSortKey> = HashMap::new();
let mut bom_ref_map: HashMap<String, CanonicalId> = HashMap::new();
for (idx, edge) in sbom.edges.iter().enumerate() {
edges_by_source
.entry(edge.from.clone())
.or_default()
.push(idx);
edges_by_target
.entry(edge.to.clone())
.or_default()
.push(idx);
}
for (id, comp) in &sbom.components {
let name_lower = comp.name.to_lowercase();
by_name_lower
.entry(name_lower)
.or_default()
.push(id.clone());
sort_keys.insert(id.clone(), ComponentSortKey::from_component(comp));
let format_id = &comp.identifiers.format_id;
if !format_id.is_empty() {
bom_ref_map.insert(format_id.clone(), id.clone());
}
}
Self {
edges_by_source,
edges_by_target,
by_name_lower,
sort_keys,
bom_ref_map,
component_count: sbom.components.len(),
edge_count: sbom.edges.len(),
}
}
pub fn dependency_indices(&self, id: &CanonicalId) -> &[usize] {
self.edges_by_source
.get(id)
.map_or(&[], std::vec::Vec::as_slice)
}
pub fn dependent_indices(&self, id: &CanonicalId) -> &[usize] {
self.edges_by_target
.get(id)
.map_or(&[], std::vec::Vec::as_slice)
}
#[must_use]
pub fn dependencies_of<'a>(
&self,
id: &CanonicalId,
edges: &'a [DependencyEdge],
) -> Vec<&'a DependencyEdge> {
self.dependency_indices(id)
.iter()
.filter_map(|&idx| edges.get(idx))
.collect()
}
#[must_use]
pub fn dependents_of<'a>(
&self,
id: &CanonicalId,
edges: &'a [DependencyEdge],
) -> Vec<&'a DependencyEdge> {
self.dependent_indices(id)
.iter()
.filter_map(|&idx| edges.get(idx))
.collect()
}
pub fn find_by_name_lower(&self, name_lower: &str) -> &[CanonicalId] {
self.by_name_lower
.get(name_lower)
.map_or(&[], std::vec::Vec::as_slice)
}
#[must_use]
pub fn search_by_name(&self, query_lower: &str) -> Vec<CanonicalId> {
self.by_name_lower
.iter()
.filter(|(name, _)| name.contains(query_lower))
.flat_map(|(_, ids)| ids.iter().cloned())
.collect()
}
#[must_use]
pub fn sort_key(&self, id: &CanonicalId) -> Option<&ComponentSortKey> {
self.sort_keys.get(id)
}
#[must_use]
pub const fn sort_keys(&self) -> &HashMap<CanonicalId, ComponentSortKey> {
&self.sort_keys
}
#[must_use]
pub fn resolve_bom_ref(&self, bom_ref: &str) -> Option<&CanonicalId> {
self.bom_ref_map.get(bom_ref)
}
#[must_use]
pub fn has_dependencies(&self, id: &CanonicalId) -> bool {
self.edges_by_source.get(id).is_some_and(|v| !v.is_empty())
}
#[must_use]
pub fn has_dependents(&self, id: &CanonicalId) -> bool {
self.edges_by_target.get(id).is_some_and(|v| !v.is_empty())
}
pub fn dependency_count(&self, id: &CanonicalId) -> usize {
self.edges_by_source.get(id).map_or(0, std::vec::Vec::len)
}
pub fn dependent_count(&self, id: &CanonicalId) -> usize {
self.edges_by_target.get(id).map_or(0, std::vec::Vec::len)
}
#[must_use]
pub const fn component_count(&self) -> usize {
self.component_count
}
#[must_use]
pub const fn edge_count(&self) -> usize {
self.edge_count
}
#[must_use]
pub fn root_count(&self) -> usize {
self.component_count
.saturating_sub(self.edges_by_target.len())
+ self
.edges_by_target
.values()
.filter(|v| v.is_empty())
.count()
}
#[must_use]
pub fn leaf_count(&self) -> usize {
self.component_count
.saturating_sub(self.edges_by_source.len())
+ self
.edges_by_source
.values()
.filter(|v| v.is_empty())
.count()
}
}
#[derive(Debug, Default)]
#[must_use]
pub struct SbomIndexBuilder {
index_names: bool,
build_sort_keys: bool,
}
impl SbomIndexBuilder {
pub const fn new() -> Self {
Self {
index_names: true,
build_sort_keys: true,
}
}
pub const fn minimal() -> Self {
Self {
index_names: false,
build_sort_keys: false,
}
}
pub const fn with_name_index(mut self) -> Self {
self.index_names = true;
self
}
pub const fn with_sort_keys(mut self) -> Self {
self.build_sort_keys = true;
self
}
pub fn build(&self, sbom: &NormalizedSbom) -> NormalizedSbomIndex {
let mut edges_by_source: HashMap<CanonicalId, Vec<usize>> = HashMap::new();
let mut edges_by_target: HashMap<CanonicalId, Vec<usize>> = HashMap::new();
let mut by_name_lower: HashMap<String, Vec<CanonicalId>> = HashMap::new();
let mut sort_keys: HashMap<CanonicalId, ComponentSortKey> = HashMap::new();
let mut bom_ref_map: HashMap<String, CanonicalId> = HashMap::new();
for (idx, edge) in sbom.edges.iter().enumerate() {
edges_by_source
.entry(edge.from.clone())
.or_default()
.push(idx);
edges_by_target
.entry(edge.to.clone())
.or_default()
.push(idx);
}
if self.index_names || self.build_sort_keys {
for (id, comp) in &sbom.components {
if self.index_names {
let name_lower = comp.name.to_lowercase();
by_name_lower
.entry(name_lower)
.or_default()
.push(id.clone());
}
if self.build_sort_keys {
sort_keys.insert(id.clone(), ComponentSortKey::from_component(comp));
}
let format_id = &comp.identifiers.format_id;
if !format_id.is_empty() {
bom_ref_map.insert(format_id.clone(), id.clone());
}
}
}
NormalizedSbomIndex {
edges_by_source,
edges_by_target,
by_name_lower,
sort_keys,
bom_ref_map,
component_count: sbom.components.len(),
edge_count: sbom.edges.len(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DependencyType, DocumentMetadata};
fn make_test_sbom() -> NormalizedSbom {
let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
let comp_a = Component::new("ComponentA".to_string(), "comp-a".to_string());
let comp_b = Component::new("ComponentB".to_string(), "comp-b".to_string());
let comp_c = Component::new("componentb".to_string(), "comp-c".to_string());
let id_a = comp_a.canonical_id.clone();
let id_b = comp_b.canonical_id.clone();
let id_c = comp_c.canonical_id.clone();
sbom.add_component(comp_a);
sbom.add_component(comp_b);
sbom.add_component(comp_c);
sbom.add_edge(DependencyEdge::new(
id_a.clone(),
id_b.clone(),
DependencyType::DependsOn,
));
sbom.add_edge(DependencyEdge::new(
id_a.clone(),
id_c.clone(),
DependencyType::DependsOn,
));
sbom
}
#[test]
fn test_build_index() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
assert_eq!(index.component_count(), 3);
assert_eq!(index.edge_count(), 2);
}
#[test]
fn test_dependency_lookup() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
let comp_a_id = sbom.components.keys().next().unwrap();
assert_eq!(index.dependency_count(comp_a_id), 2);
let deps = index.dependencies_of(comp_a_id, &sbom.edges);
assert_eq!(deps.len(), 2);
}
#[test]
fn test_dependent_lookup() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
let comp_b_id = sbom.components.keys().nth(1).unwrap();
assert_eq!(index.dependent_count(comp_b_id), 1);
}
#[test]
fn test_name_lookup() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
let matches = index.find_by_name_lower("componentb");
assert_eq!(matches.len(), 2);
}
#[test]
fn test_name_search() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
let matches = index.search_by_name("component");
assert_eq!(matches.len(), 3);
}
#[test]
fn test_sort_keys() {
let sbom = make_test_sbom();
let index = NormalizedSbomIndex::build(&sbom);
let comp_a_id = sbom.components.keys().next().unwrap();
let sort_key = index.sort_key(comp_a_id).unwrap();
assert_eq!(sort_key.name_lower, "componenta");
}
#[test]
fn test_sort_key_contains() {
let mut comp = Component::new("MyPackage".to_string(), "pkg-1".to_string());
comp.version = Some("1.2.3".to_string());
let key = ComponentSortKey::from_component(&comp);
assert!(key.contains("mypack"));
assert!(key.contains("1.2.3"));
assert!(!key.contains("notfound"));
}
#[test]
fn test_builder_minimal() {
let sbom = make_test_sbom();
let index = SbomIndexBuilder::minimal().build(&sbom);
assert_eq!(index.edge_count(), 2);
let matches = index.find_by_name_lower("componenta");
assert!(matches.is_empty());
}
#[test]
fn test_empty_sbom() {
let sbom = NormalizedSbom::default();
let index = NormalizedSbomIndex::build(&sbom);
assert_eq!(index.component_count(), 0);
assert_eq!(index.edge_count(), 0);
}
}