sbom-tools 0.2.0

Semantic SBOM diff and analysis tool
Documentation
//! Shared license rendering functions used by both diff-mode and view-mode.
//!
//! Pure rendering functions that take domain values directly, with no
//! dependency on `App` or `ViewApp`.

use crate::tui::license_utils::{LicenseCategory, LicenseInfo, RiskLevel};
use crate::tui::theme::colors;
use ratatui::prelude::*;

/// Map a `LicenseCategory` to a theme color.
pub fn category_color(category: LicenseCategory) -> Color {
    let scheme = colors();
    match category {
        LicenseCategory::Permissive | LicenseCategory::PublicDomain => scheme.success,
        LicenseCategory::WeakCopyleft => scheme.info,
        LicenseCategory::StrongCopyleft => scheme.warning,
        LicenseCategory::NetworkCopyleft | LicenseCategory::Proprietary => scheme.error,
        LicenseCategory::Unknown => scheme.text_muted,
    }
}

/// Map a `LicenseCategory` to the redundant (colorblind-safe) glyph the legend
/// overlay advertises. Keep in sync with `category_glyph_str`.
pub fn category_glyph(category: LicenseCategory) -> &'static str {
    match category {
        LicenseCategory::Permissive | LicenseCategory::PublicDomain => "",
        LicenseCategory::StrongCopyleft | LicenseCategory::NetworkCopyleft => "©",
        LicenseCategory::WeakCopyleft => "",
        LicenseCategory::Proprietary => "",
        LicenseCategory::Unknown => "?",
    }
}

/// String-typed variant of [`category_glyph`] for the view path, keyed on
/// `LicenseCategory::as_str()` names (case-insensitive).
pub fn category_glyph_str(category: &str) -> &'static str {
    match category.to_lowercase().as_str() {
        "permissive" | "public domain" => "",
        "copyleft" | "network copyleft" | "strong copyleft" => "©",
        "weak copyleft" => "",
        "proprietary" | "commercial" => "",
        _ => "?",
    }
}

/// Map a `RiskLevel` to a theme color.
pub fn risk_level_color(risk: RiskLevel) -> Color {
    let scheme = colors();
    match risk {
        RiskLevel::Low => scheme.success,
        RiskLevel::Medium => scheme.info,
        RiskLevel::High => scheme.warning,
        RiskLevel::Critical => scheme.error,
    }
}

/// Render license metadata lines: title (with dual-license indicator), category badge,
/// risk badge, family, and component count.
pub fn render_license_metadata_lines(
    license: &str,
    category: LicenseCategory,
    risk_level: RiskLevel,
    family: &str,
    component_count: usize,
    is_dual_licensed: bool,
) -> Vec<Line<'static>> {
    let scheme = colors();
    let mut lines = vec![];

    // Title with optional dual-license indicator
    let title = if is_dual_licensed {
        format!("{license} (Dual/Multi License)")
    } else {
        license.to_string()
    };

    lines.push(Line::from(vec![Span::styled(
        title,
        Style::default().fg(scheme.text).bold(),
    )]));

    lines.push(Line::from(""));

    // Category
    lines.push(Line::from(vec![
        Span::styled("Category: ", Style::default().fg(scheme.text_muted)),
        Span::styled(
            category.as_str(),
            Style::default().fg(category_color(category)).bold(),
        ),
    ]));

    // Risk
    lines.push(Line::from(vec![
        Span::styled("Risk: ", Style::default().fg(scheme.text_muted)),
        Span::styled(
            risk_level.as_str(),
            Style::default().fg(risk_level_color(risk_level)).bold(),
        ),
    ]));

    // Family
    lines.push(Line::from(vec![
        Span::styled("Family: ", Style::default().fg(scheme.text_muted)),
        Span::styled(family.to_string(), Style::default().fg(scheme.accent)),
    ]));

    // Component count
    lines.push(Line::from(vec![
        Span::styled("Components: ", Style::default().fg(scheme.text_muted)),
        Span::styled(
            component_count.to_string(),
            Style::default().fg(scheme.primary),
        ),
    ]));

    lines
}

/// Get license characteristics based on category.
///
/// Returns a list of `(description, allowed)` pairs.
pub fn get_license_characteristics(license: &str) -> Vec<(&'static str, bool)> {
    let info = LicenseInfo::from_spdx(license);

    match info.category {
        LicenseCategory::Permissive | LicenseCategory::PublicDomain => vec![
            ("Commercial use allowed", true),
            ("Modification allowed", true),
            ("Distribution allowed", true),
            ("Patent grant", info.patent_grant),
            ("Copyleft/Share-alike", false),
        ],
        LicenseCategory::StrongCopyleft | LicenseCategory::NetworkCopyleft => vec![
            ("Commercial use allowed", true),
            ("Modification allowed", true),
            ("Distribution allowed", true),
            ("Copyleft/Share-alike", true),
            ("Derivative work must be open", true),
        ],
        LicenseCategory::WeakCopyleft => vec![
            ("Commercial use allowed", true),
            ("Modification allowed", true),
            ("Distribution allowed", true),
            ("Copyleft/Share-alike", true),
            ("Linking allowed", true),
        ],
        LicenseCategory::Proprietary => vec![
            ("Commercial use allowed", false),
            ("Modification allowed", false),
            ("Distribution allowed", false),
            ("Source access", false),
        ],
        LicenseCategory::Unknown => vec![("License terms unknown", false)],
    }
}

/// Render license characteristics as check/cross icon lines.
pub fn render_license_characteristics_lines(license: &str) -> Vec<Line<'static>> {
    let scheme = colors();
    let mut lines = vec![];

    lines.push(Line::styled(
        "Characteristics:",
        Style::default().fg(scheme.primary).bold(),
    ));

    let characteristics = get_license_characteristics(license);
    for (desc, allowed) in characteristics {
        let (icon, color) = if allowed {
            ("", scheme.success)
        } else {
            ("", scheme.error)
        };
        lines.push(Line::from(vec![
            Span::styled(format!("  {icon} "), Style::default().fg(color)),
            Span::raw(desc),
        ]));
    }

    // Network copyleft warning
    let info = LicenseInfo::from_spdx(license);
    if info.network_copyleft {
        lines.push(Line::from(""));
        lines.push(Line::from(vec![
            Span::styled("", Style::default().fg(scheme.warning)),
            Span::styled(
                "Network copyleft (AGPL-style)",
                Style::default().fg(scheme.warning),
            ),
        ]));
    }

    lines
}

#[cfg(test)]
mod glyph_tests {
    use super::{category_glyph, category_glyph_str};
    use crate::tui::license_utils::LicenseCategory;

    /// Every category has a glyph, and the string-typed view path agrees with
    /// the typed one for every variant (regression test for the legend
    /// advertising a symbol system no list row rendered).
    #[test]
    fn category_glyph_covers_all_categories() {
        let all = [
            LicenseCategory::Permissive,
            LicenseCategory::WeakCopyleft,
            LicenseCategory::StrongCopyleft,
            LicenseCategory::NetworkCopyleft,
            LicenseCategory::Proprietary,
            LicenseCategory::PublicDomain,
            LicenseCategory::Unknown,
        ];
        for cat in all {
            let glyph = category_glyph(cat);
            assert!(!glyph.is_empty());
            assert_eq!(
                category_glyph_str(cat.as_str()),
                glyph,
                "string path disagrees for {:?} (as_str: {})",
                cat,
                cat.as_str()
            );
        }
    }

    /// Pins the category -> theme-color mapping: network copyleft (AGPL-class)
    /// and proprietary carry the error slot, public domain and permissive carry
    /// success — real risk colors, not the data-gap gray reserved for Unknown.
    /// A silent regression to text_muted passes every snapshot (styles are
    /// stripped) and the theme ratchet (no raw color literal involved).
    #[test]
    fn category_color_uses_risk_slots_not_data_gap_gray() {
        crate::tui::test_support::pin_theme();
        let s = crate::tui::theme::colors();
        assert_eq!(
            super::category_color(LicenseCategory::NetworkCopyleft),
            s.error,
            "AGPL-class network copyleft must use the error slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::Proprietary),
            s.error,
            "proprietary must use the error slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::PublicDomain),
            s.success,
            "public domain must use the success slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::Permissive),
            s.success,
            "permissive must use the success slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::WeakCopyleft),
            s.info,
            "weak copyleft must use the info slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::StrongCopyleft),
            s.warning,
            "strong copyleft must use the warning slot"
        );
        assert_eq!(
            super::category_color(LicenseCategory::Unknown),
            s.text_muted,
            "only Unknown is the data-gap gray"
        );
        assert_ne!(
            s.error, s.text_muted,
            "dark theme keeps the error slot distinct from the data-gap gray"
        );
        assert_ne!(
            s.success, s.text_muted,
            "dark theme keeps the success slot distinct from the data-gap gray"
        );
    }
}