sbom-tools 0.2.0

Semantic SBOM diff and analysis tool
Documentation
//! Shared helpers for TUI snapshot and event tests.
//!
//! Test-only module. Constructs apps from the demo fixtures with a pinned
//! theme so that rendered output is deterministic across runs, and renders a
//! [`TestBackend`] buffer down to plain trimmed text suitable for `insta`
//! string snapshots (colors/styles are intentionally dropped — only the cell
//! glyphs are captured, which is what makes the snapshots stable).

use ratatui::Terminal;
use ratatui::backend::TestBackend;

use crate::diff::{DiffEngine, DiffResult};
use crate::model::{BomProfile, NormalizedSbom};
use crate::parsers::parse_sbom_str;

/// Old demo SBOM (CycloneDX 1.5) used as the diff "before" side.
pub(crate) const DEMO_OLD: &str = include_str!("../../tests/fixtures/demo-old.cdx.json");
/// New demo SBOM (CycloneDX 1.5) used as the diff "after" side.
pub(crate) const DEMO_NEW: &str = include_str!("../../tests/fixtures/demo-new.cdx.json");

/// A complete BSI-aligned AI-BOM fixture (ML model + dataset components).
/// Used by AI-BOM `ViewApp` tab tests; `BomProfile::detect` classifies it as
/// `BomProfile::AiBom`.
pub(crate) const AIBOM_BSI: &str =
    include_str!("../../tests/fixtures/cyclonedx/bsi-aibom-complete.cdx.json");

/// Snapshot terminal sizes: a standard 80x24 and a wide 120x40.
pub(crate) const SIZES: [(u16, u16); 2] = [(80, 24), (120, 40)];

/// Pin the global theme to `dark` so styling is deterministic.
///
/// The text snapshots do not capture color, but pinning the theme also keeps
/// any theme-dependent glyph choices stable and isolates the tests from a
/// developer's saved theme preference.
pub(crate) fn pin_theme() {
    crate::tui::theme::set_theme(crate::tui::theme::Theme::dark());
}

/// Parse the two demo fixtures and compute a diff with the default engine.
pub(crate) fn demo_diff() -> (DiffResult, NormalizedSbom, NormalizedSbom) {
    let old = parse_sbom_str(DEMO_OLD).expect("demo-old fixture must parse");
    let new = parse_sbom_str(DEMO_NEW).expect("demo-new fixture must parse");
    let result = DiffEngine::new()
        .diff(&old, &new)
        .expect("demo diff must succeed");
    (result, old, new)
}

/// Parse the new demo fixture as a single SBOM for `ViewApp` tests.
pub(crate) fn demo_single() -> (NormalizedSbom, BomProfile) {
    let sbom = parse_sbom_str(DEMO_NEW).expect("demo-new fixture must parse");
    let profile = BomProfile::detect(&sbom);
    (sbom, profile)
}

/// Parse the BSI AI-BOM fixture as a single SBOM for AI-BOM `ViewApp` tests.
pub(crate) fn aibom_single() -> (NormalizedSbom, BomProfile) {
    let sbom = parse_sbom_str(AIBOM_BSI).expect("bsi-aibom fixture must parse");
    let profile = BomProfile::detect(&sbom);
    (sbom, profile)
}

/// Render a closure into a [`TestBackend`] of the given size and return the
/// buffer as newline-joined, right-trimmed text.
pub(crate) fn render_to_text<F>(width: u16, height: u16, draw: F) -> String
where
    F: FnOnce(&mut ratatui::Frame),
{
    let backend = TestBackend::new(width, height);
    let mut terminal = Terminal::new(backend).expect("TestBackend terminal");
    terminal.draw(draw).expect("draw must succeed");
    buffer_to_text(terminal.backend().buffer())
}

/// Convert a ratatui [`Buffer`](ratatui::buffer::Buffer) into plain text:
/// one line per row, cell glyphs concatenated, trailing spaces trimmed.
fn buffer_to_text(buffer: &ratatui::buffer::Buffer) -> String {
    let width = buffer.area.width as usize;
    let mut out = String::new();
    for row in buffer.content.chunks(width) {
        let mut line = String::new();
        for cell in row {
            line.push_str(cell.symbol());
        }
        out.push_str(line.trim_end());
        out.push('\n');
    }
    out
}

/// A CycloneDX CBOM fixture with many cryptographic assets (11 algorithms),
/// used to exercise crypto/AI list scrolling.
pub(crate) const CBOM: &str = include_str!("../../tests/fixtures/cyclonedx/cbom-1.7.cdx.json");

/// Parse the CBOM fixture as a single SBOM for crypto-tab `ViewApp` tests.
pub(crate) fn cbom_single() -> (NormalizedSbom, BomProfile) {
    let sbom = parse_sbom_str(CBOM).expect("cbom fixture must parse");
    let profile = BomProfile::detect(&sbom);
    (sbom, profile)
}

/// Build a multi-diff result — baseline vs. two distinctly-named targets — for
/// dashboard tests. Uses the real `MultiDiffEngine` so every summary field is valid.
pub(crate) fn demo_multi_diff() -> crate::diff::MultiDiffResult {
    let baseline = parse_sbom_str(DEMO_OLD).expect("baseline fixture must parse");
    let webapp = parse_sbom_str(DEMO_NEW).expect("webapp fixture must parse");
    let ai = parse_sbom_str(AIBOM_BSI).expect("aibom fixture must parse");
    let targets: [(&NormalizedSbom, &str, &str); 2] = [
        (&webapp, "webapp", "demo-new.cdx.json"),
        (&ai, "ai-service", "aibom.cdx.json"),
    ];
    let mut engine = crate::diff::MultiDiffEngine::new();
    engine
        .diff_multi(&baseline, "baseline", "demo-old.cdx.json", &targets)
        .expect("multi diff must succeed")
}

/// Build a timeline result over three versions (v1 -> v2 -> v3) so both
/// adjacent and cumulative pairs exist. Uses the real `MultiDiffEngine`.
pub(crate) fn demo_timeline() -> crate::diff::TimelineResult {
    let v1 = parse_sbom_str(DEMO_OLD).expect("v1 fixture must parse");
    let v2 = parse_sbom_str(DEMO_NEW).expect("v2 fixture must parse");
    let v3 = parse_sbom_str(AIBOM_BSI).expect("v3 fixture must parse");
    let sboms: [(&NormalizedSbom, &str, &str); 3] = [
        (&v1, "v1", "demo-old.cdx.json"),
        (&v2, "v2", "demo-new.cdx.json"),
        (&v3, "v3", "aibom.cdx.json"),
    ];
    let mut engine = crate::diff::MultiDiffEngine::new();
    engine.timeline(&sboms).expect("timeline must succeed")
}

/// Eight synthetic SBOMs (alternating demo-old/demo-new, names v1..v8) so the
/// matrix column viewport actually clips at 80 cols and clustering yields
/// interleaved members.
pub(crate) fn demo_matrix_large() -> crate::diff::MatrixResult {
    let a = parse_sbom_str(DEMO_OLD).expect("fixture must parse");
    let b = parse_sbom_str(DEMO_NEW).expect("fixture must parse");
    let names = ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8"];
    let sboms: Vec<(&NormalizedSbom, &str, &str)> = names
        .iter()
        .enumerate()
        .map(|(i, name)| (if i % 2 == 0 { &a } else { &b }, *name, "demo.cdx.json"))
        .collect();
    let mut engine = crate::diff::MultiDiffEngine::new();
    // 0.8: identical-content SBOMs (similarity 1.0) cluster together while
    // the ~0.54 cross-pairs stay apart, yielding two interleaved clusters.
    engine
        .matrix(&sboms, Some(0.8))
        .expect("matrix must succeed")
}

/// Build an NxN matrix result over the same three fixtures, with a similarity
/// threshold so `clustering` is populated (needed by the cluster-navigation
/// tests).
pub(crate) fn demo_matrix() -> crate::diff::MatrixResult {
    let a = parse_sbom_str(DEMO_OLD).expect("fixture must parse");
    let b = parse_sbom_str(DEMO_NEW).expect("fixture must parse");
    let c = parse_sbom_str(AIBOM_BSI).expect("fixture must parse");
    let sboms: [(&NormalizedSbom, &str, &str); 3] = [
        (&a, "alpha", "demo-old.cdx.json"),
        (&b, "beta", "demo-new.cdx.json"),
        (&c, "gamma", "aibom.cdx.json"),
    ];
    let mut engine = crate::diff::MultiDiffEngine::new();
    engine
        .matrix(&sboms, Some(0.5))
        .expect("matrix must succeed")
}