use crate::diff::{MultiDiffResult, SecurityImpact};
use crate::tui::app::{MultiDiffState, MultiViewFilterPreset, MultiViewSortBy, SortDirection};
use crate::tui::theme::colors;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table, Wrap},
};
pub fn render_multi_dashboard(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
status: Option<&str>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(5), Constraint::Min(15), Constraint::Length(3), ])
.split(area);
render_header(f, chunks[0], result, state);
render_baseline_info(f, chunks[1], result);
let main_chunks = if state.show_cross_target {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(40),
Constraint::Percentage(35),
])
.split(chunks[2])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(chunks[2])
};
render_targets_list(f, main_chunks[0], result, state);
let selected_raw = ordered_comparison_indices(result, state)
.get(state.selected_target)
.copied()
.unwrap_or(usize::MAX);
if state.show_cross_target && main_chunks.len() > 2 {
render_cross_target_analysis(f, main_chunks[1], result, state);
render_details_panel(f, main_chunks[2], result, selected_raw, state);
} else if main_chunks.len() > 1 {
render_details_panel(f, main_chunks[1], result, selected_raw, state);
}
render_status_bar(f, chunks[3], result, state, status);
if state.show_detail_modal {
render_detail_modal(f, area, result, state);
}
if state.show_variable_drill_down {
render_variable_drill_down(f, area, result, state);
}
if state.search.active {
render_search_overlay(f, area, state);
}
}
fn render_header(f: &mut Frame, area: Rect, result: &MultiDiffResult, state: &MultiDiffState) {
let scheme = colors();
let title = format!(
" Multi-SBOM Comparison: {} vs {} targets ",
result.baseline.name,
result.comparisons.len()
);
let text = vec![Line::from(vec![
Span::styled(
title,
Style::default()
.fg(scheme.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" │ "),
Span::styled("Filter: ", Style::default().fg(scheme.text_muted)),
Span::styled(
state.filter_preset.label(),
Style::default().fg(scheme.accent),
),
Span::raw(" │ "),
Span::styled("Sort: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!(
"{} {}",
state.sort_by.label(),
state.sort_direction.indicator()
),
Style::default().fg(scheme.accent),
),
if state.heat_map_mode {
Span::styled(" │ Heat Map", Style::default().fg(scheme.warning))
} else {
Span::raw("")
},
])];
let header = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}
fn render_baseline_info(f: &mut Frame, area: Rect, result: &MultiDiffResult) {
let scheme = colors();
let info = &result.baseline;
let text = vec![
Line::from(vec![
Span::styled("Baseline: ", Style::default().fg(scheme.text_muted)),
Span::styled(
&info.name,
Style::default()
.fg(scheme.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Format: ", Style::default().fg(scheme.text_muted)),
Span::styled(&info.format, Style::default().fg(scheme.accent)),
]),
Line::from(vec![
Span::styled("Components: ", Style::default().fg(scheme.text_muted)),
Span::styled(
info.component_count.to_string(),
Style::default().fg(scheme.primary),
),
Span::raw(" "),
Span::styled("Dependencies: ", Style::default().fg(scheme.text_muted)),
Span::styled(
info.dependency_count.to_string(),
Style::default().fg(scheme.primary),
),
Span::raw(" "),
Span::styled("Max Deviation: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format_deviation(result.summary.max_deviation),
Style::default().fg(if result.summary.max_deviation > 0.3 {
scheme.removed
} else if result.summary.max_deviation > 0.1 {
scheme.warning
} else {
scheme.added
}),
),
]),
];
let block = Block::default()
.title(" Baseline ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info));
let paragraph = Paragraph::new(text).block(block);
f.render_widget(paragraph, area);
}
pub(crate) fn format_deviation(deviation: f64) -> String {
let pct = deviation * 100.0;
if pct >= 99.95 {
"100%".to_string()
} else {
format!("{pct:.1}%")
}
}
pub(crate) fn deviation_band(deviation: f64) -> (&'static str, &'static str) {
if deviation > 0.5 {
("critical", "\u{2587}")
} else if deviation > 0.3 {
("high", "\u{2585}")
} else if deviation > 0.1 {
("medium", "\u{2583}")
} else {
("low", "\u{2581}")
}
}
pub(crate) fn ordered_comparison_indices(
result: &MultiDiffResult,
state: &MultiDiffState,
) -> Vec<usize> {
let deviation = |name: &str| {
result
.summary
.deviation_scores
.get(name)
.copied()
.unwrap_or(0.0)
};
let mut idx: Vec<usize> = result
.comparisons
.iter()
.enumerate()
.filter(|(_, comp)| match state.filter_preset {
MultiViewFilterPreset::All => true,
MultiViewFilterPreset::HighDeviation => deviation(&comp.target.name) > 0.3,
MultiViewFilterPreset::ChangesOnly => comp.diff.summary.total_changes > 0,
MultiViewFilterPreset::WithVulnerabilities => {
comp.diff.summary.vulnerabilities_introduced > 0
}
MultiViewFilterPreset::AddedOnly => comp.diff.summary.components_added > 0,
MultiViewFilterPreset::RemovedOnly => comp.diff.summary.components_removed > 0,
})
.map(|(i, _)| i)
.collect();
match state.sort_by {
MultiViewSortBy::Name => idx.sort_by(|a, b| {
result.comparisons[*b]
.target
.name
.cmp(&result.comparisons[*a].target.name)
}),
MultiViewSortBy::Deviation => idx.sort_by(|a, b| {
deviation(&result.comparisons[*b].target.name)
.partial_cmp(&deviation(&result.comparisons[*a].target.name))
.unwrap_or(std::cmp::Ordering::Equal)
}),
MultiViewSortBy::Changes => idx.sort_by(|a, b| {
result.comparisons[*b]
.diff
.summary
.total_changes
.cmp(&result.comparisons[*a].diff.summary.total_changes)
}),
MultiViewSortBy::Components => idx.sort_by(|a, b| {
result.comparisons[*b]
.target
.component_count
.cmp(&result.comparisons[*a].target.component_count)
}),
MultiViewSortBy::Vulnerabilities => idx.sort_by(|a, b| {
result.comparisons[*b]
.diff
.summary
.vulnerabilities_introduced
.cmp(
&result.comparisons[*a]
.diff
.summary
.vulnerabilities_introduced,
)
}),
}
if matches!(state.sort_direction, SortDirection::Ascending) {
idx.reverse();
}
idx
}
fn render_targets_list(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
) {
let scheme = colors();
let is_active = matches!(state.active_panel, MultiDashboardPanel::Targets);
let selected = state.selected_target;
let filtered_comparisons: Vec<(usize, &crate::diff::ComparisonResult)> =
ordered_comparison_indices(result, state)
.into_iter()
.map(|raw| (raw, &result.comparisons[raw]))
.collect();
let rows: Vec<Row> = filtered_comparisons
.iter()
.enumerate()
.map(|(display_idx, (_, comp))| {
let deviation = result
.summary
.deviation_scores
.get(&comp.target.name)
.copied()
.unwrap_or(0.0);
let deviation_color = if deviation > 0.3 {
scheme.removed
} else if deviation > 0.1 {
scheme.warning
} else {
scheme.added
};
let style = if display_idx == selected {
Style::default()
.bg(scheme.selection)
.add_modifier(Modifier::BOLD)
} else if state.heat_map_mode {
let (band, _) = deviation_band(deviation);
Style::default().bg(scheme.severity_bg_tint(band))
} else {
Style::default()
};
let name_style = if state.search.matches.contains(&display_idx) {
style.fg(scheme.accent).add_modifier(Modifier::BOLD)
} else {
style
};
Row::new(vec![
Cell::from(comp.target.name.clone()).style(name_style),
Cell::from(comp.target.component_count.to_string()).style(style),
Cell::from(format!(
"{} {}",
format_deviation(deviation),
deviation_band(deviation).1
))
.style(style.fg(deviation_color)),
Cell::from(comp.diff.summary.total_changes.to_string()).style(style),
])
})
.collect();
let header = Row::new(vec!["Target", "Comps", "Dev %", "Chg"])
.style(
Style::default()
.fg(scheme.primary)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1);
let widths = [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
];
let border_color = if is_active {
scheme.accent
} else {
scheme.text
};
let title = format!(
" Targets ({}/{}) ",
filtered_comparisons.len(),
result.comparisons.len()
);
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.row_highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(table, area);
}
fn render_details_panel(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
selected: usize,
state: &MultiDiffState,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40), Constraint::Percentage(60), ])
.split(area);
if let Some(comp) = result.comparisons.get(selected) {
render_comparison_details(f, chunks[0], comp, result);
}
render_variable_components(f, chunks[1], result, state);
}
fn render_comparison_details(
f: &mut Frame,
area: Rect,
comp: &crate::diff::ComparisonResult,
result: &MultiDiffResult,
) {
let scheme = colors();
let deviation = result
.summary
.deviation_scores
.get(&comp.target.name)
.copied()
.unwrap_or(0.0);
let summary = &comp.diff.summary;
let text = vec![
Line::from(vec![
Span::styled("Target: ", Style::default().fg(scheme.text_muted)),
Span::styled(
&comp.target.name,
Style::default()
.fg(scheme.text)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("+ Added: ", Style::default().fg(scheme.added)),
Span::raw(summary.components_added.to_string()),
Span::raw(" "),
Span::styled("- Removed: ", Style::default().fg(scheme.removed)),
Span::raw(summary.components_removed.to_string()),
Span::raw(" "),
Span::styled("~ Modified: ", Style::default().fg(scheme.modified)),
Span::raw(summary.components_modified.to_string()),
]),
Line::from(vec![
Span::styled("Total Changes: ", Style::default().fg(scheme.text_muted)),
Span::styled(
summary.total_changes.to_string(),
Style::default().fg(scheme.primary),
),
Span::styled(
format!(
" (comps {}, deps {}, graph {}, meta {})",
summary.components_added
+ summary.components_removed
+ summary.components_modified,
summary.dependencies_added + summary.dependencies_removed,
summary.graph_changes_count,
summary.metadata_changes_count,
),
Style::default().fg(scheme.text_muted),
),
]),
Line::from(vec![
Span::styled("Vulnerabilities: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!("+{}", summary.vulnerabilities_introduced),
Style::default().fg(scheme.removed),
),
Span::raw(" / "),
Span::styled(
format!("-{}", summary.vulnerabilities_resolved),
Style::default().fg(scheme.added),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Similarity: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!("{:.1}%", comp.diff.semantic_score),
Style::default().fg(scheme.primary),
),
]),
];
let gauge_area = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(3)])
.split(area);
let block = Block::default()
.title(format!(" {} Details ", comp.target.name))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info));
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
f.render_widget(paragraph, gauge_area[0]);
let gauge_color = if deviation > 0.3 {
scheme.removed
} else if deviation > 0.1 {
scheme.warning
} else {
scheme.added
};
let gauge = Gauge::default()
.block(Block::default().title(" Deviation ").borders(Borders::ALL))
.gauge_style(Style::default().fg(gauge_color))
.percent((deviation * 100.0).clamp(0.0, 100.0) as u16)
.label(format_deviation(deviation));
f.render_widget(gauge, gauge_area[1]);
}
fn render_variable_components(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
) {
let scheme = colors();
if result.summary.variable_components.is_empty() {
let block = Block::default()
.title(" Variable Components (0 total) [v: drill-down] ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.critical));
let placeholder = Paragraph::new(Line::from(Span::styled(
"No variable components \u{2014} all targets match the baseline",
Style::default().fg(scheme.text_muted),
)))
.block(block);
f.render_widget(placeholder, area);
return;
}
let visible_rows = (area.height.saturating_sub(4) as usize).max(1);
let window_start = state
.selected_variable_component
.saturating_sub(visible_rows - 1);
let rows: Vec<Row> = result
.summary
.variable_components
.iter()
.enumerate()
.skip(window_start)
.take(visible_rows)
.map(|(i, vc)| {
let is_selected = i == state.selected_variable_component;
let base_style = if is_selected {
Style::default()
.bg(scheme.selection)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let impact_style = match vc.security_impact {
SecurityImpact::Critical => {
base_style.fg(scheme.critical).add_modifier(Modifier::BOLD)
}
SecurityImpact::High => base_style.fg(scheme.high),
SecurityImpact::Medium => base_style.fg(scheme.medium),
SecurityImpact::Low => base_style.fg(scheme.low),
};
let name_line = Line::from(vec![
crate::tui::theme::severity_indicator(vc.security_impact.label()),
Span::raw(" "),
Span::styled(vc.name.clone(), base_style),
]);
Row::new(vec![
Cell::from(name_line),
Cell::from(vc.version_spread.baseline.clone().unwrap_or_default())
.style(base_style),
Cell::from(format!(
"{} versions",
vc.version_spread.unique_versions.len()
))
.style(base_style),
Cell::from(vc.security_impact.label()).style(impact_style),
])
})
.collect();
let header = Row::new(vec!["Component", "Baseline", "Spread", "Impact"])
.style(
Style::default()
.fg(scheme.primary)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1);
let widths = [
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
];
let table = Table::new(rows, widths).header(header).block(
Block::default()
.title(format!(
" Variable Components ({} total) [v: drill-down] ",
result.summary.variable_components.len()
))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.critical)),
);
f.render_widget(table, area);
}
fn render_status_bar(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
status: Option<&str>,
) {
let scheme = colors();
let universal_count = result.summary.universal_components.len();
let inconsistent_count = result.summary.inconsistent_components.len();
let mut spans = vec![
Span::styled("Universal: ", Style::default().fg(scheme.text_muted)),
Span::styled(
universal_count.to_string(),
Style::default().fg(scheme.added),
),
Span::raw(" "),
Span::styled("Variable: ", Style::default().fg(scheme.text_muted)),
Span::styled(
result.summary.variable_components.len().to_string(),
Style::default().fg(scheme.warning),
),
Span::raw(" "),
Span::styled("Inconsistent: ", Style::default().fg(scheme.text_muted)),
Span::styled(
inconsistent_count.to_string(),
Style::default().fg(scheme.removed),
),
Span::raw(" \u{2502} "),
];
let modal_hints: Option<&[(&str, &str)]> = if state.show_detail_modal {
Some(&[("Esc", "close")])
} else if state.show_variable_drill_down {
Some(&[("j/k", "select"), ("Esc", "close")])
} else {
None
};
if let Some(hints) = modal_hints {
super::matrix::extend_with_modal_hints(&mut spans, hints);
} else {
crate::tui::views::matrix_status_tail(&mut spans, area, status, "multi");
}
let block = Block::default().borders(Borders::ALL);
let paragraph = Paragraph::new(Line::from(spans)).block(block);
f.render_widget(paragraph, area);
}
fn render_cross_target_analysis(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
_state: &MultiDiffState,
) {
let scheme = colors();
let mut cross_target_info: Vec<Line> = vec![
Line::from(vec![Span::styled(
"Cross-Target Analysis",
Style::default()
.fg(scheme.primary)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
cross_target_info.push(Line::from(vec![
Span::styled(
"Inconsistent Components: ",
Style::default().fg(scheme.text_muted),
),
Span::styled(
result.summary.inconsistent_components.len().to_string(),
Style::default().fg(scheme.warning),
),
]));
for (i, vc) in result
.summary
.variable_components
.iter()
.take(8)
.enumerate()
{
let versions_str = vc
.version_spread
.unique_versions
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
cross_target_info.push(Line::from(vec![
Span::styled(
format!("{}. ", i + 1),
Style::default().fg(scheme.text_muted),
),
crate::tui::theme::severity_indicator(vc.security_impact.label()),
Span::raw(" "),
Span::styled(&vc.name, Style::default().fg(scheme.text)),
Span::raw(": "),
Span::styled(versions_str, Style::default().fg(scheme.accent)),
]));
}
cross_target_info.push(Line::from(""));
cross_target_info.push(Line::from(vec![Span::styled(
"Deviation Distribution:",
Style::default().fg(scheme.text_muted),
)]));
let high_dev = result
.comparisons
.iter()
.filter(|c| {
result
.summary
.deviation_scores
.get(&c.target.name)
.copied()
.unwrap_or(0.0)
> 0.3
})
.count();
let med_dev = result
.comparisons
.iter()
.filter(|c| {
let d = result
.summary
.deviation_scores
.get(&c.target.name)
.copied()
.unwrap_or(0.0);
d > 0.1 && d <= 0.3
})
.count();
let low_dev = result.comparisons.len() - high_dev - med_dev;
cross_target_info.push(Line::from(vec![
Span::styled(" High (>30%): ", Style::default().fg(scheme.removed)),
Span::raw(high_dev.to_string()),
Span::raw(" "),
Span::styled("Med (10-30%): ", Style::default().fg(scheme.warning)),
Span::raw(med_dev.to_string()),
Span::raw(" "),
Span::styled("Low (<10%): ", Style::default().fg(scheme.added)),
Span::raw(low_dev.to_string()),
]));
let block = Block::default()
.title(" Cross-Target Analysis ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info));
let paragraph = Paragraph::new(cross_target_info)
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn render_detail_modal(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
) {
let scheme = colors();
let modal_width = area.width * 80 / 100;
let modal_height = area.height * 70 / 100;
let modal_x = (area.width - modal_width) / 2;
let modal_y = (area.height - modal_height) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
f.render_widget(Clear, modal_area);
let Some(comp) = ordered_comparison_indices(result, state)
.get(state.selected_target)
.and_then(|&raw| result.comparisons.get(raw))
else {
return;
};
let deviation = result
.summary
.deviation_scores
.get(&comp.target.name)
.copied()
.unwrap_or(0.0);
let mut lines = vec![
Line::from(vec![
Span::styled("Target: ", Style::default().fg(scheme.text_muted)),
Span::styled(
&comp.target.name,
Style::default()
.fg(scheme.primary)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Deviation: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format_deviation(deviation),
Style::default().fg(if deviation > 0.3 {
scheme.removed
} else {
scheme.added
}),
),
Span::raw(" "),
Span::styled("Similarity: ", Style::default().fg(scheme.text_muted)),
Span::styled(
format!("{:.1}%", comp.diff.semantic_score),
Style::default().fg(scheme.primary),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Component Changes:",
Style::default().fg(scheme.text_muted),
)]),
Line::from(vec![
Span::styled(" + Added: ", Style::default().fg(scheme.added)),
Span::raw(comp.diff.summary.components_added.to_string()),
Span::raw(" "),
Span::styled(" - Removed: ", Style::default().fg(scheme.removed)),
Span::raw(comp.diff.summary.components_removed.to_string()),
Span::raw(" "),
Span::styled(" ~ Modified: ", Style::default().fg(scheme.modified)),
Span::raw(comp.diff.summary.components_modified.to_string()),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Vulnerabilities:",
Style::default().fg(scheme.text_muted),
)]),
Line::from(vec![
Span::styled(" Introduced: ", Style::default().fg(scheme.removed)),
Span::raw(comp.diff.summary.vulnerabilities_introduced.to_string()),
Span::raw(" "),
Span::styled(" Resolved: ", Style::default().fg(scheme.added)),
Span::raw(comp.diff.summary.vulnerabilities_resolved.to_string()),
]),
Line::from(""),
];
lines.push(Line::from(vec![Span::styled(
"Top Added Components:",
Style::default().fg(scheme.added),
)]));
for comp_change in comp.diff.components.added.iter().take(5) {
lines.push(Line::from(vec![
Span::raw(" + "),
Span::styled(&comp_change.name, Style::default().fg(scheme.text)),
Span::raw(" "),
Span::styled(
comp_change.new_version.as_deref().unwrap_or(""),
Style::default().fg(scheme.text_muted),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Top Removed Components:",
Style::default().fg(scheme.removed),
)]));
for comp_change in comp.diff.components.removed.iter().take(5) {
lines.push(Line::from(vec![
Span::raw(" - "),
Span::styled(&comp_change.name, Style::default().fg(scheme.text)),
Span::raw(" "),
Span::styled(
comp_change.old_version.as_deref().unwrap_or(""),
Style::default().fg(scheme.text_muted),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Press ", Style::default().fg(scheme.text_muted)),
Span::styled("Esc", Style::default().fg(scheme.primary)),
Span::styled(" to close", Style::default().fg(scheme.text_muted)),
]));
let block = Block::default()
.title(format!(" {} Details ", comp.target.name))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent))
.style(Style::default().bg(scheme.background_alt));
let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
f.render_widget(paragraph, modal_area);
}
fn render_variable_drill_down(
f: &mut Frame,
area: Rect,
result: &MultiDiffResult,
state: &MultiDiffState,
) {
let scheme = colors();
let modal_width = area.width * 75 / 100;
let modal_height = area.height * 60 / 100;
let modal_x = (area.width - modal_width) / 2;
let modal_y = (area.height - modal_height) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
f.render_widget(Clear, modal_area);
let Some(vc) = result
.summary
.variable_components
.get(state.selected_variable_component)
else {
return;
};
let mut lines = vec![
Line::from(vec![
Span::styled("Security Impact: ", Style::default().fg(scheme.text_muted)),
Span::styled(
vc.security_impact.label(),
match vc.security_impact {
SecurityImpact::Critical => Style::default().fg(scheme.critical),
SecurityImpact::High => Style::default().fg(scheme.high),
SecurityImpact::Medium => Style::default().fg(scheme.medium),
SecurityImpact::Low => Style::default().fg(scheme.low),
},
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Baseline Version: ", Style::default().fg(scheme.text_muted)),
Span::styled(
vc.version_spread.baseline.as_deref().unwrap_or("N/A"),
Style::default().fg(scheme.primary),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Version Spread:",
Style::default().fg(scheme.text_muted),
)]),
];
for version in &vc.version_spread.unique_versions {
lines.push(Line::from(vec![
Span::raw(" • "),
Span::styled(version, Style::default().fg(scheme.accent)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Targets with this component:",
Style::default().fg(scheme.text_muted),
)]));
let present: Vec<(&str, String)> = result
.comparisons
.iter()
.filter(|comp| {
vc.targets_with_component
.iter()
.any(|t| t == &comp.target.name)
})
.map(|comp| {
let version = comp
.diff
.components
.modified
.iter()
.chain(comp.diff.components.added.iter())
.find(|c| crate::diff::strip_purl_version(&c.id) == vc.id)
.and_then(|c| c.new_version.clone())
.or_else(|| vc.version_spread.baseline.clone())
.unwrap_or_else(|| "version unknown".to_string());
(comp.target.name.as_str(), version)
})
.collect();
if present.is_empty() {
lines.push(Line::from(vec![Span::styled(
" (none — only the baseline has it)",
Style::default().fg(scheme.text_muted),
)]));
}
let shown = present.len().min(10);
for (name, version) in &present[..shown] {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled((*name).to_string(), Style::default().fg(scheme.text)),
Span::raw(": "),
Span::styled(version.clone(), Style::default().fg(scheme.accent)),
]));
}
if present.len() > shown {
lines.push(Line::from(vec![Span::styled(
format!(" … and {} more", present.len() - shown),
Style::default().fg(scheme.text_muted),
)]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(scheme.primary)),
Span::raw(": navigate "),
Span::styled("Esc", Style::default().fg(scheme.primary)),
Span::raw(": close"),
]));
let block = Block::default()
.title(format!(" Variable Component: {} ", vc.name))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.warning))
.style(Style::default().bg(scheme.background_alt));
let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
f.render_widget(paragraph, modal_area);
}
fn render_search_overlay(f: &mut Frame, area: Rect, state: &MultiDiffState) {
crate::tui::views::render_multi_search_bar(f, area, "Search: ", &state.search);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MultiDashboardPanel {
Targets,
Details,
}
#[cfg(test)]
mod ordering_tests {
use super::*;
use crate::tui::test_support::demo_multi_diff;
fn state(
sort: MultiViewSortBy,
dir: SortDirection,
filter: MultiViewFilterPreset,
) -> MultiDiffState {
let mut s = MultiDiffState::new();
s.sort_by = sort;
s.sort_direction = dir;
s.filter_preset = filter;
s
}
#[test]
fn name_ascending_is_a_to_z_and_reorders_raw_indices() {
let result = demo_multi_diff();
let order = ordered_comparison_indices(
&result,
&state(
MultiViewSortBy::Name,
SortDirection::Ascending,
MultiViewFilterPreset::All,
),
);
let names: Vec<&str> = order
.iter()
.map(|&i| result.comparisons[i].target.name.as_str())
.collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "Ascending Name sort should be A→Z");
assert_ne!(order, (0..result.comparisons.len()).collect::<Vec<_>>());
let mut perm = order.clone();
perm.sort_unstable();
assert_eq!(perm, (0..result.comparisons.len()).collect::<Vec<_>>());
}
#[test]
fn descending_name_is_the_reverse_of_ascending() {
let result = demo_multi_diff();
let asc = ordered_comparison_indices(
&result,
&state(
MultiViewSortBy::Name,
SortDirection::Ascending,
MultiViewFilterPreset::All,
),
);
let mut desc = ordered_comparison_indices(
&result,
&state(
MultiViewSortBy::Name,
SortDirection::Descending,
MultiViewFilterPreset::All,
),
);
desc.reverse();
assert_eq!(asc, desc);
}
#[test]
fn with_vulnerabilities_filter_keeps_only_matching() {
let result = demo_multi_diff();
let order = ordered_comparison_indices(
&result,
&state(
MultiViewSortBy::Name,
SortDirection::Ascending,
MultiViewFilterPreset::WithVulnerabilities,
),
);
assert!(order.len() <= result.comparisons.len());
for &i in &order {
assert!(
result.comparisons[i]
.diff
.summary
.vulnerabilities_introduced
> 0
);
}
}
}
#[cfg(test)]
mod truthfulness_tests {
use super::*;
use crate::tui::test_support::{demo_multi_diff, pin_theme, render_to_text};
#[test]
fn format_deviation_is_percent_with_unit() {
assert_eq!(format_deviation(0.0), "0.0%");
assert_eq!(format_deviation(0.465), "46.5%");
assert_eq!(format_deviation(0.4649), "46.5%");
assert_eq!(format_deviation(0.9996), "100%");
assert_eq!(format_deviation(1.0), "100%");
}
#[test]
fn dashboard_renders_percentages_not_double_scaled_values() {
pin_theme();
let result = demo_multi_diff();
assert!(
result.summary.max_deviation <= 1.0,
"engine contract: deviation is a 0-1 fraction"
);
let mut state = MultiDiffState::new();
state.total_targets = result.comparisons.len();
state.total_variable_components = result.summary.variable_components.len();
let text = render_to_text(120, 40, |f| {
render_multi_dashboard(f, f.area(), &result, &state, None);
});
let expected_max = format!(
"Max Deviation: {}",
format_deviation(result.summary.max_deviation)
);
assert!(
text.contains(&expected_max),
"max deviation must render as a true percentage:\n{text}"
);
for bogus in ["10000", "7000.0%", "4651.2%", "4225.8%"] {
assert!(
!text.contains(bogus),
"no >100% deviation may render ({bogus}):\n{text}"
);
}
assert!(
text.contains("Similarity:") && !text.contains("Semantic Score"),
"the similarity metric must use the Matrix mode's name and % unit:\n{text}"
);
assert!(
text.contains("Total Changes:") && text.contains("comps "),
"the Chg column must be reconciled by the Total Changes breakdown:\n{text}"
);
}
#[test]
fn status_counts_are_logical_not_version_qualified() {
let result = demo_multi_diff();
let mut names: Vec<&str> = result
.summary
.inconsistent_components
.iter()
.map(|c| c.name.as_str())
.collect();
let total = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(
names.len(),
total,
"a version-bumped package must not count twice in Inconsistent"
);
assert!(
result
.summary
.variable_components
.iter()
.any(|vc| vc.name == "lodash"),
"a purl-versioned upgrade (lodash 4.17.20 -> 4.17.21) must be Variable"
);
}
#[test]
fn variable_drilldown_lists_only_targets_that_have_the_component() {
pin_theme();
let result = demo_multi_diff();
let idx = result
.summary
.variable_components
.iter()
.position(|vc| vc.name == "acme-webapp")
.expect("demo fixture has the acme-webapp variable component");
let mut state = MultiDiffState::new();
state.total_targets = result.comparisons.len();
state.total_variable_components = result.summary.variable_components.len();
state.selected_variable_component = idx;
state.show_variable_drill_down = true;
let text = render_to_text(120, 40, |f| {
render_multi_dashboard(f, f.area(), &result, &state, None);
});
assert!(
text.contains("webapp: 2.0.0"),
"the target that has the component must list it with ITS version:\n{text}"
);
assert!(
!text.contains("ai-service:"),
"ai-service does not contain acme-webapp and must not be listed:\n{text}"
);
}
}
#[cfg(test)]
mod deviation_band_tests {
use super::deviation_band;
#[test]
fn deviation_band_maps_to_theme_tint() {
crate::tui::test_support::pin_theme();
let scheme = crate::tui::theme::colors();
assert_eq!(deviation_band(0.55), ("critical", "\u{2587}"));
assert_eq!(deviation_band(0.35), ("high", "\u{2585}"));
assert_eq!(deviation_band(0.15), ("medium", "\u{2583}"));
assert_eq!(deviation_band(0.05), ("low", "\u{2581}"));
assert_eq!(
scheme.severity_bg_tint(deviation_band(0.55).0),
scheme.severity_bg_tint("critical")
);
}
}