use crate::diff::QualityDelta;
use crate::quality::QualityReport;
use crate::tui::app::AppMode;
use crate::tui::render_context::RenderContext;
use crate::tui::shared::quality as shared;
use crate::tui::theme::{ColorScheme, colors};
use crate::tui::widgets;
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph},
};
pub fn render_quality(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
match ctx.mode {
AppMode::Diff => render_diff_quality(frame, area, ctx),
AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => {}
}
}
fn render_diff_quality(frame: &mut Frame, area: Rect, ctx: &RenderContext) {
let old_report = ctx.old_quality;
let new_report = ctx.new_quality;
if old_report.is_none() && new_report.is_none() {
render_no_quality_data(frame, area);
return;
}
let quality_delta = ctx
.diff_result
.and_then(|r| r.quality_delta.as_ref())
.filter(|qd| {
matches!((old_report, new_report), (Some(o), Some(n))
if (qd.overall_score_delta - (n.overall_score - o.overall_score)).abs() < 0.75)
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(12), Constraint::Min(8), ])
.split(area);
render_quality_header(frame, chunks[0], old_report, new_report, quality_delta);
render_category_bar_chart(frame, chunks[1], old_report, new_report, quality_delta);
render_combined_recommendations(frame, chunks[2], old_report, new_report, ctx, quality_delta);
}
fn render_no_quality_data(frame: &mut Frame, area: Rect) {
widgets::render_empty_state_enhanced(
frame,
area,
"--",
"Quality analysis unavailable",
Some("Quality scoring requires a valid SBOM to analyze"),
Some("Ensure the SBOM was successfully parsed"),
);
}
fn render_quality_header(
frame: &mut Frame,
area: Rect,
old_report: Option<&QualityReport>,
new_report: Option<&QualityReport>,
quality_delta: Option<&QualityDelta>,
) {
let scheme = colors();
let old_score = old_report.map_or(0.0, |r| r.overall_score);
let new_score = new_report.map_or(0.0, |r| r.overall_score);
let old_grade = old_report.map_or("?", |r| r.grade.letter());
let new_grade = new_report.map_or("?", |r| r.grade.letter());
let delta_label = if let Some(qd) = quality_delta {
if qd.overall_score_delta > 0.5 {
"improved"
} else if qd.overall_score_delta < -0.5 {
"regressed"
} else {
"unchanged"
}
} else {
""
};
let border_color = if delta_label == "improved" {
scheme.added
} else if delta_label == "regressed" {
scheme.removed
} else {
scheme.border
};
let title = format!(
" Quality: {old_score:.0}/100 ({old_grade}) \u{2192} {new_score:.0}/100 ({new_grade}) {delta_label} "
);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height >= 1 {
render_text_gauge(
frame,
inner.x,
inner.y,
inner.width,
old_score,
"Old",
&scheme,
);
}
if inner.height >= 2 {
let separator = "\u{2500}".repeat(inner.width as usize);
frame.buffer_mut().set_string(
inner.x,
inner.y + 1,
separator,
Style::default().fg(scheme.border),
);
}
if inner.height >= 3 {
render_text_gauge(
frame,
inner.x,
inner.y + 2,
inner.width,
new_score,
"New",
&scheme,
);
}
}
fn render_text_gauge(
frame: &mut Frame,
x: u16,
y: u16,
width: u16,
score: f32,
label: &str,
scheme: &ColorScheme,
) {
let label_prefix = format!(" {label}: ");
let prefix_len = label_prefix.len();
let bar_width = (width as usize).saturating_sub(prefix_len + 10); let filled = ((score / 100.0) * bar_width as f32) as usize;
let filled = filled.min(bar_width);
let bar_color = if score >= 80.0 {
scheme.added
} else if score >= 50.0 {
scheme.warning
} else {
scheme.removed
};
let spans = vec![
Span::styled(label_prefix, Style::default().fg(scheme.muted)),
Span::styled("\u{2588}".repeat(filled), Style::default().fg(bar_color)),
Span::styled(
"\u{2591}".repeat(bar_width.saturating_sub(filled)),
Style::default().fg(scheme.muted),
),
Span::styled(
format!(" {score:>3.0}% {label}"),
Style::default().fg(scheme.text),
),
];
frame.buffer_mut().set_line(x, y, &Line::from(spans), width);
}
fn render_category_bar_chart(
frame: &mut Frame,
area: Rect,
old_report: Option<&QualityReport>,
new_report: Option<&QualityReport>,
quality_delta: Option<&QualityDelta>,
) {
let scheme = colors();
let (Some(old), Some(new)) = (old_report, new_report) else {
let label = if old_report.is_some() {
"Only old report available"
} else {
"Only new report available"
};
let paragraph = Paragraph::new(format!(
" {label} — category comparison requires both SBOMs"
))
.block(
Block::default()
.title(" Category Scores ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.border)),
);
frame.render_widget(paragraph, area);
return;
};
let new_rows = shared::profile_category_rows(new);
let old_rows = shared::profile_category_rows(old);
let mut categories: Vec<(String, f32, f32, f32)> = new_rows
.into_iter()
.map(|(name, new_s, weight)| {
let old_s = old_rows
.iter()
.find(|(n, _, _)| *n == name)
.map_or(0.0, |(_, s, _)| *s);
(name, old_s, new_s, weight)
})
.collect();
if categories.is_empty() {
let paragraph = Paragraph::new(" No per-category data for this scoring profile").block(
Block::default()
.title(" Category Scores ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.border)),
);
frame.render_widget(paragraph, area);
return;
}
categories.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
let title = if new.profile == crate::quality::ScoringProfile::AiReadiness {
" AI Readiness Checks (pass/fail) "
} else {
" Category Scores "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let col_name: u16 = 1;
let col_bar: u16 = 16;
let bar_width: u16 = 20;
let col_old = col_bar + bar_width + 1;
let col_new = col_old + 6;
let col_delta = col_new + 6;
let col_wt = col_delta + 6;
let col_warn = col_wt + 8;
let buf = frame.buffer_mut();
let x = inner.x;
let bold_muted = Style::default().fg(scheme.muted).bold();
if inner.height >= 1 {
buf.set_string(x + col_old, inner.y, " Old", bold_muted);
buf.set_string(x + col_new, inner.y, " New", bold_muted);
buf.set_string(x + col_delta, inner.y, " Delta", bold_muted);
buf.set_string(x + col_wt, inner.y, " Weight", bold_muted);
}
let visible_rows = inner.height.saturating_sub(1) as usize;
let shown = if categories.len() > visible_rows {
visible_rows.saturating_sub(1)
} else {
categories.len()
};
if shown < categories.len() {
let y = inner.y + 1 + shown as u16;
if y < inner.y + inner.height {
buf.set_string(
x + col_name,
y,
format!(
"\u{2026} +{} more (enlarge terminal)",
categories.len() - shown
),
Style::default().fg(scheme.warning),
);
}
}
for (i, (name, old_s, new_s, weight)) in categories.iter().take(shown).enumerate() {
let y = inner.y + 1 + i as u16;
if y >= inner.y + inner.height {
break;
}
let delta = quality_delta
.and_then(|qd| qd.category_deltas.iter().find(|cd| cd.category == *name))
.map_or(new_s - old_s, |cd| cd.delta);
let name_color = if delta < -0.5 {
scheme.removed
} else {
scheme.text
};
buf.set_string(
x + col_name,
y,
format!("{:<14}", widgets::truncate_str(name, 14)),
Style::default().fg(name_color).bold(),
);
let filled = ((*new_s / 100.0) * bar_width as f32) as usize;
let filled = filled.min(bar_width as usize);
let bar_color = if *new_s >= 80.0 {
scheme.added
} else if *new_s >= 50.0 {
scheme.warning
} else {
scheme.removed
};
for c in 0..bar_width as usize {
let ch = if c < filled { "\u{2588}" } else { "\u{2591}" };
let color = if c < filled { bar_color } else { scheme.muted };
buf.set_string(x + col_bar + c as u16, y, ch, Style::default().fg(color));
}
buf.set_string(
x + col_old,
y,
format!("{old_s:>4.0}%"),
Style::default().fg(scheme.muted),
);
buf.set_string(
x + col_new,
y,
format!("{new_s:>4.0}%"),
Style::default().fg(scheme.text).bold(),
);
if delta > 0.5 {
buf.set_string(
x + col_delta,
y,
format!(" \u{25b2}+{delta:.0}"),
Style::default().fg(scheme.added),
);
} else if delta < -0.5 {
buf.set_string(
x + col_delta,
y,
format!(" \u{25bc}{delta:.0}"),
Style::default().fg(scheme.removed),
);
}
buf.set_string(
x + col_wt,
y,
format!(" \u{00d7}{:.0}%", weight * 100.0),
Style::default().fg(scheme.muted),
);
if *new_s == 0.0 {
buf.set_string(
x + col_warn,
y,
" \u{2717}",
Style::default().fg(scheme.removed),
);
} else if *new_s < 30.0 {
buf.set_string(
x + col_warn,
y,
" \u{26a0}low",
Style::default().fg(scheme.warning),
);
}
}
}
#[allow(dead_code)]
fn render_score_comparison(
frame: &mut Frame,
area: Rect,
old_report: Option<&QualityReport>,
new_report: Option<&QualityReport>,
) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
if let Some(report) = old_report {
shared::render_score_gauge(frame, chunks[0], report, "Old SBOM Quality");
} else {
render_empty_gauge(frame, chunks[0], "Old SBOM Quality");
}
if let Some(report) = new_report {
shared::render_score_gauge(frame, chunks[1], report, "New SBOM Quality");
} else {
render_empty_gauge(frame, chunks[1], "New SBOM Quality");
}
}
#[allow(dead_code)]
fn render_empty_gauge(frame: &mut Frame, area: Rect, title: &str) {
use ratatui::widgets::Gauge;
let scheme = colors();
let gauge = Gauge::default()
.block(
Block::default()
.title(format!(" {title} "))
.title_style(Style::default().fg(scheme.muted))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.muted)),
)
.gauge_style(Style::default().fg(scheme.muted))
.percent(0)
.label("N/A");
frame.render_widget(gauge, area);
}
#[allow(dead_code)]
fn render_metrics_comparison(
frame: &mut Frame,
area: Rect,
old_report: Option<&QualityReport>,
new_report: Option<&QualityReport>,
quality_delta: Option<&QualityDelta>,
) {
if let (Some(old), Some(new), Some(qd)) = (old_report, new_report, quality_delta) {
render_unified_metrics_table(frame, area, old, new, qd);
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
if let Some(report) = old_report {
render_metrics_panel_with_explanation(frame, chunks[0], report, "Old");
} else {
render_empty_metrics(frame, chunks[0], "Old");
}
if let Some(report) = new_report {
render_metrics_panel_with_explanation(frame, chunks[1], report, "New");
} else {
render_empty_metrics(frame, chunks[1], "New");
}
}
#[allow(dead_code)]
fn render_unified_metrics_table(
frame: &mut Frame,
area: Rect,
old: &QualityReport,
new: &QualityReport,
qd: &QualityDelta,
) {
let scheme = colors();
let weights = shared::get_profile_weights(new.profile);
let categories: Vec<(&str, f32, f32, f32)> = vec![
(
"Completeness",
old.completeness_score,
new.completeness_score,
weights.0,
),
(
"Identifiers",
old.identifier_score,
new.identifier_score,
weights.1,
),
("Licenses", old.license_score, new.license_score, weights.2),
(
"VulnDocs",
old.vulnerability_score.unwrap_or(0.0),
new.vulnerability_score.unwrap_or(0.0),
if new.vulnerability_score.is_some() {
weights.3
} else {
0.0
},
),
(
"Dependencies",
old.dependency_score,
new.dependency_score,
weights.4,
),
(
"Integrity",
old.integrity_score,
new.integrity_score,
weights.5,
),
(
"Provenance",
old.provenance_score,
new.provenance_score,
weights.6,
),
(
"Lifecycle",
old.lifecycle_score.unwrap_or(0.0),
new.lifecycle_score.unwrap_or(0.0),
if new.lifecycle_score.is_some() {
weights.7
} else {
0.0
},
),
];
let rows: Vec<ratatui::widgets::Row> = categories
.iter()
.map(|(name, old_s, new_s, w)| {
let delta = qd
.category_deltas
.iter()
.find(|cd| cd.category == *name)
.map_or(new_s - old_s, |cd| cd.delta);
let (arrow, delta_color) = if delta > 0.5 {
("\u{25b2}", scheme.added) } else if delta < -0.5 {
("\u{25bc}", scheme.removed) } else {
("\u{2014}", scheme.muted) };
let is_na = (*name == "VulnDocs"
&& old.vulnerability_score.is_none()
&& new.vulnerability_score.is_none())
|| (*name == "Lifecycle"
&& old.lifecycle_score.is_none()
&& new.lifecycle_score.is_none());
let old_str = if is_na {
"N/A".to_string()
} else {
format!("{old_s:.0}%")
};
let new_str = if is_na {
"N/A".to_string()
} else {
format!("{new_s:.0}%")
};
let delta_cell = if is_na {
Span::styled("\u{2014}", Style::default().fg(scheme.muted))
} else {
Span::styled(
format!("{arrow} {delta:+.1}"),
Style::default().fg(delta_color),
)
};
ratatui::widgets::Row::new(vec![
ratatui::widgets::Cell::from(name.to_string()),
ratatui::widgets::Cell::from(old_str),
ratatui::widgets::Cell::from(new_str),
ratatui::widgets::Cell::from(delta_cell),
ratatui::widgets::Cell::from(format!("\u{00d7}{:.0}%", w * 100.0)),
])
})
.collect();
let widths = [
Constraint::Length(14),
Constraint::Length(7),
Constraint::Length(7),
Constraint::Length(10),
Constraint::Length(6),
];
let table = ratatui::widgets::Table::new(rows, widths)
.block(
Block::default()
.title(" Score Factors (Old vs New) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info)),
)
.header(
ratatui::widgets::Row::new(vec!["Category", "Old", "New", "Delta", "Weight"])
.style(Style::default().fg(scheme.primary).bold())
.bottom_margin(1),
);
frame.render_widget(table, area);
}
#[allow(dead_code)]
fn render_metrics_panel_with_explanation(
frame: &mut Frame,
area: Rect,
report: &QualityReport,
label: &str,
) {
let scheme = colors();
let weights = shared::get_profile_weights(report.profile);
let rows = vec![
ratatui::widgets::Row::new(vec![
"Completeness".to_string(),
format!("{:.0}%", report.completeness_score),
format!("×{:.0}%", weights.0 * 100.0),
shared::explain_completeness_score(report),
]),
ratatui::widgets::Row::new(vec![
"Identifiers".to_string(),
format!("{:.0}%", report.identifier_score),
format!("×{:.0}%", weights.1 * 100.0),
shared::explain_identifier_score(report),
]),
ratatui::widgets::Row::new(vec![
"Licenses".to_string(),
format!("{:.0}%", report.license_score),
format!("×{:.0}%", weights.2 * 100.0),
shared::explain_license_score(report),
]),
ratatui::widgets::Row::new(vec![
"Vulnerabilities".to_string(),
match report.vulnerability_score {
Some(score) => format!("{score:.0}%"),
None => "N/A".to_string(),
},
format!(
"×{:.0}%",
if report.vulnerability_score.is_some() {
weights.3 * 100.0
} else {
0.0
}
),
shared::explain_vulnerability_score(report),
]),
ratatui::widgets::Row::new(vec![
"Dependencies".to_string(),
format!("{:.0}%", report.dependency_score),
format!("×{:.0}%", weights.4 * 100.0),
shared::explain_dependency_score(report),
]),
];
let widths = [
Constraint::Length(14),
Constraint::Length(7),
Constraint::Length(6),
Constraint::Min(15),
];
let table = ratatui::widgets::Table::new(rows, widths)
.block(
Block::default()
.title(format!(" {label} - Score Factors "))
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info)),
)
.header(
ratatui::widgets::Row::new(vec!["Category", "Score", "Weight", "Reason"])
.style(Style::default().fg(scheme.primary).bold())
.bottom_margin(1),
);
frame.render_widget(table, area);
}
#[allow(dead_code)]
fn render_empty_metrics(frame: &mut Frame, area: Rect, label: &str) {
crate::tui::widgets::render_empty_state_enhanced(
frame,
area,
"--",
&format!("No {} metrics available", label.to_lowercase()),
Some("Quality analysis could not be performed for this SBOM"),
Some("SBOM may lack the required metadata for scoring"),
);
}
fn render_combined_recommendations(
frame: &mut Frame,
area: Rect,
old_report: Option<&QualityReport>,
new_report: Option<&QualityReport>,
ctx: &RenderContext,
quality_delta: Option<&QualityDelta>,
) {
let scheme = colors();
let mut lines: Vec<Line> = vec![];
if let (Some(old), Some(new)) = (old_report, new_report) {
let score_diff = quality_delta.map_or_else(
|| f64::from(new.overall_score as i32 - old.overall_score as i32),
|qd| f64::from(qd.overall_score_delta),
);
let shown = score_diff.round();
let (icon, color, text) = if shown > 5.0 {
(
"↑",
scheme.added,
format!("Quality improved by {shown:.0} points"),
)
} else if shown < -5.0 {
(
"↓",
scheme.removed,
format!("Quality decreased by {:.0} points", shown.abs()),
)
} else {
("→", scheme.warning, "Quality score unchanged".to_string())
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color).bold()),
Span::styled(text, Style::default().fg(color)),
]));
if let Some(qd) = quality_delta {
if !qd.regressions.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Regressed: ", Style::default().fg(scheme.text_muted)),
Span::styled(
qd.regressions
.iter()
.map(|c| shared::category_display_name(c))
.collect::<Vec<_>>()
.join(", "),
Style::default().fg(scheme.removed).bold(),
),
]));
}
if !qd.improvements.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Improved: ", Style::default().fg(scheme.text_muted)),
Span::styled(
qd.improvements
.iter()
.map(|c| shared::category_display_name(c))
.collect::<Vec<_>>()
.join(", "),
Style::default().fg(scheme.added).bold(),
),
]));
}
if qd.violation_count_delta != 0 {
let c = if qd.violation_count_delta > 0 {
scheme.error
} else {
scheme.success
};
lines.push(Line::from(vec![
Span::styled(
" Compliance violations: ",
Style::default().fg(scheme.text_muted),
),
Span::styled(
format!("{:+}", qd.violation_count_delta),
Style::default().fg(c).bold(),
),
]));
}
}
lines.push(Line::from(""));
if let Some(qd) = quality_delta {
add_change_reasons(&mut lines, qd);
}
}
if let Some(report) = new_report {
lines.push(Line::from(""));
lines.push(Line::styled(
" Top Actions to Improve Score:",
Style::default().fg(scheme.primary).bold(),
));
let profile = report.profile;
let pts_are_real = |cat: crate::quality::RecommendationCategory| -> bool {
use crate::quality::{RecommendationCategory as RC, ScoringProfile as SP};
match profile {
SP::AiReadiness => false,
SP::Cbom => matches!(cat, RC::Licenses | RC::Provenance),
_ => true,
}
};
for (i, rec) in report.recommendations.iter().take(4).enumerate() {
let is_selected = i == ctx.quality.selected_recommendation;
let prefix = if is_selected { "▶ " } else { " " };
let style = if is_selected {
Style::default().fg(scheme.text).bold()
} else {
Style::default().fg(scheme.text)
};
let mut spans = vec![
Span::styled(prefix, Style::default().fg(scheme.primary)),
Span::styled(
format!("[P{}] ", rec.priority),
shared::priority_style(rec.priority),
),
Span::styled(&rec.message, style),
];
if pts_are_real(rec.category) {
spans.push(Span::styled(
format!(" (+{:.0}pts)", rec.impact),
Style::default().fg(scheme.success),
));
}
spans.push(Span::styled(
format!(" {}", rec.category.name()),
Style::default().fg(scheme.accent),
));
lines.push(Line::from(spans));
}
}
let total_lines = lines.len();
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.title(" Quality Analysis ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.error)),
)
.scroll((ctx.quality.scroll_offset as u16, 0));
frame.render_widget(paragraph, area);
if total_lines > area.height.saturating_sub(2) as usize {
widgets::render_scrollbar(
frame,
area.inner(ratatui::prelude::Margin {
vertical: 1,
horizontal: 0,
}),
total_lines,
ctx.quality.scroll_offset,
);
}
}
fn add_change_reasons(lines: &mut Vec<Line>, qd: &QualityDelta) {
let scheme = colors();
for cd in &qd.category_deltas {
let (name, old_score, new_score) = (cd.category.as_str(), cd.old_score, cd.new_score);
let diff = cd.delta;
if diff.abs() > 5.0 {
let (icon, color) = if diff > 0.0 {
("↑", scheme.added)
} else {
("↓", scheme.removed)
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::styled(format!("{name}: "), Style::default().fg(scheme.text)),
Span::styled(
format!("{old_score:.0}% → {new_score:.0}%"),
Style::default().fg(color),
),
]));
}
}
}