use std::collections::HashMap;
use crate::quality::{
ComplianceChecker, ComplianceLevel, ComplianceResult, Violation, ViolationCategory,
ViolationSeverity,
};
use crate::tui::shared::compliance as shared_compliance;
use crate::tui::theme::colors;
use crate::tui::view::app::ViewApp;
use ratatui::{
prelude::*,
widgets::{
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
Table, Tabs,
},
};
pub(crate) struct ViolationGroup<'a> {
pub(crate) severity: ViolationSeverity,
pub(crate) category: ViolationCategory,
pub(crate) pattern: String,
pub(crate) violations: Vec<&'a Violation>,
}
pub(crate) fn build_groups<'a>(
result: &'a ComplianceResult,
severity_filter: SeverityFilter,
) -> Vec<ViolationGroup<'a>> {
let mut map: HashMap<(u8, ViolationCategory, String), Vec<&'a Violation>> = HashMap::new();
for v in &result.violations {
if !severity_filter.matches(v.severity) {
continue;
}
let sev_ord = match v.severity {
ViolationSeverity::Error => 0,
ViolationSeverity::Warning => 1,
ViolationSeverity::Info => 2,
};
let pattern = extract_pattern(&v.message);
map.entry((sev_ord, v.category, pattern))
.or_default()
.push(v);
}
let mut groups: Vec<ViolationGroup<'a>> = map
.into_iter()
.map(|((_, category, pattern), violations)| ViolationGroup {
severity: violations[0].severity,
category,
pattern,
violations,
})
.collect();
groups.sort_by(|a, b| {
let sev_a = severity_ordinal(a.severity);
let sev_b = severity_ordinal(b.severity);
sev_a
.cmp(&sev_b)
.then(b.violations.len().cmp(&a.violations.len()))
.then(a.pattern.cmp(&b.pattern))
});
groups
}
fn extract_pattern(msg: &str) -> String {
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
let before = &msg[..start];
let after = &msg[start + 1 + end + 1..];
return format!("{before}{after}").trim().to_string();
}
msg.to_string()
}
fn severity_ordinal(s: ViolationSeverity) -> u8 {
match s {
ViolationSeverity::Error => 0,
ViolationSeverity::Warning => 1,
ViolationSeverity::Info => 2,
}
}
fn severity_text(s: ViolationSeverity) -> &'static str {
match s {
ViolationSeverity::Error => "ERROR",
ViolationSeverity::Warning => "WARN",
ViolationSeverity::Info => "INFO",
}
}
fn severity_style(s: ViolationSeverity) -> Style {
let scheme = colors();
match s {
ViolationSeverity::Error => Style::default().fg(scheme.error).bold(),
ViolationSeverity::Warning => Style::default().fg(scheme.warning),
ViolationSeverity::Info => Style::default().fg(scheme.info),
}
}
pub fn render_compliance(frame: &mut Frame, area: Rect, app: &mut ViewApp) {
app.ensure_compliance_results();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(7), Constraint::Min(10), Constraint::Length(3), ])
.split(area);
let violations_viewport = chunks[2].height.saturating_sub(4) as usize;
app.compliance_state.adjust_scroll(violations_viewport);
let selected_standard = app.compliance_state.selected_standard;
let selected_violation = app.compliance_state.selected_violation;
let scroll_offset = app.compliance_state.scroll_offset;
let show_detail = app.compliance_state.show_detail;
let grouped = app.compliance_state.grouped;
let affected_scroll = app.compliance_state.affected_scroll;
render_standard_selector(frame, chunks[0], app);
let Some(results) = app.compliance_results.as_ref() else {
return;
};
let result = &results[selected_standard];
render_category_breakdown(frame, chunks[1], result);
let severity_filter = app.compliance_state.severity_filter;
if grouped {
render_grouped_violations(
frame,
chunks[2],
result,
results,
selected_violation,
scroll_offset,
severity_filter,
affected_scroll,
);
} else {
render_flat_violations(
frame,
chunks[2],
result,
results,
selected_violation,
scroll_offset,
severity_filter,
);
}
render_help_bar(frame, chunks[3], severity_filter, grouped);
if show_detail {
if grouped {
let groups = build_groups(result, severity_filter);
if let Some(group) = groups.get(selected_violation) {
shared_compliance::render_violation_detail_overlay(
frame,
area,
group.violations[0],
);
}
} else if let Some(violation) = app
.compliance_results
.as_ref()
.and_then(|rs| rs.get(selected_standard))
.and_then(|r| {
r.violations
.iter()
.filter(|v| severity_filter.matches(v.severity))
.nth(selected_violation)
})
{
shared_compliance::render_violation_detail_overlay(frame, area, violation);
}
}
}
fn render_standard_selector(frame: &mut Frame, area: Rect, app: &ViewApp) {
let scheme = colors();
let Some(compliance_results) = app.compliance_results.as_ref() else {
return;
};
let standards: Vec<Line> = ComplianceLevel::all()
.iter()
.enumerate()
.map(|(i, level)| {
let is_selected = i == app.compliance_state.selected_standard;
let result = &compliance_results[i];
let status = if result.is_compliant {
if result.warning_count > 0 {
("\u{26a0}", scheme.warning)
} else {
("\u{2713}", scheme.success)
}
} else {
("\u{2717}", scheme.error)
};
let style = if is_selected {
Style::default().fg(scheme.text).bold().bg(scheme.selection)
} else {
Style::default().fg(scheme.muted)
};
let total = result.violations.len();
let count_str = if total > 0 {
format!("({total})")
} else {
String::new()
};
Line::from(vec![
Span::styled(format!(" {} ", status.0), Style::default().fg(status.1)),
Span::styled(level.name(), style),
Span::styled(count_str, Style::default().fg(scheme.muted)),
Span::styled(" ", style),
])
})
.collect();
let tabs = Tabs::new(standards)
.block(
Block::default()
.title(" Compliance Standards (\u{2190}/\u{2192} to switch) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.primary)),
)
.highlight_style(Style::default().fg(scheme.text).bold())
.select(app.compliance_state.selected_standard);
frame.render_widget(tabs, area);
}
fn render_category_breakdown(frame: &mut Frame, area: Rect, result: &ComplianceResult) {
let scheme = colors();
let (status_color, status_text) = if result.is_compliant {
if result.warning_count == 0 && result.info_count == 0 {
(scheme.success, "COMPLIANT \u{2014} All checks passed")
} else if result.warning_count == 0 {
(scheme.success, "COMPLIANT \u{2014} With recommendations")
} else {
(scheme.warning, "COMPLIANT \u{2014} With warnings")
}
} else {
(scheme.error, "NON-COMPLIANT \u{2014} Errors must be fixed")
};
let mut cat_counts: HashMap<ViolationCategory, (usize, usize, usize)> = HashMap::new();
for v in &result.violations {
let entry = cat_counts.entry(v.category).or_default();
match v.severity {
ViolationSeverity::Error => entry.0 += 1,
ViolationSeverity::Warning => entry.1 += 1,
ViolationSeverity::Info => entry.2 += 1,
}
}
let mut sorted_cats: Vec<(ViolationCategory, usize, usize, usize)> = cat_counts
.into_iter()
.map(|(cat, (e, w, i))| (cat, e, w, i))
.collect();
sorted_cats.sort_by(|a, b| {
(b.1 + b.2 + b.3)
.cmp(&(a.1 + a.2 + a.3))
.then(a.0.short_name().cmp(b.0.short_name()))
});
let total = result.violations.len().max(1) as f32;
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
let mut lines: Vec<Line> = vec![Line::from(vec![Span::styled(
format!(" {status_text}"),
Style::default().fg(status_color).bold(),
)])];
let bar_max = (h_chunks[0].width as usize).saturating_sub(30).clamp(8, 40);
for (cat, errors, warnings, infos) in sorted_cats.iter().take(4) {
let count = errors + warnings + infos;
let pct = (count as f32 / total) * 100.0;
let filled = ((count as f32 / total) * bar_max as f32).round().max(1.0) as usize;
let empty = bar_max.saturating_sub(filled);
let bar_color = if *errors > 0 {
scheme.error
} else if *warnings > 0 {
scheme.warning
} else {
scheme.info
};
lines.push(Line::from(vec![
Span::styled(
format!(" {:<10}", cat.short_name()),
Style::default().fg(scheme.muted),
),
Span::styled("\u{2588}".repeat(filled), Style::default().fg(bar_color)),
Span::styled("\u{2591}".repeat(empty), Style::default().fg(scheme.border)),
Span::styled(
format!(" {count:>5} ({pct:.1}%)"),
Style::default().fg(scheme.text),
),
]));
}
let left_widget = Paragraph::new(lines).block(
Block::default()
.title(format!(" {} ", result.level.name()))
.borders(Borders::ALL)
.border_style(Style::default().fg(status_color)),
);
frame.render_widget(left_widget, h_chunks[0]);
let right_lines = vec![
Line::from(vec![
Span::styled(" Errors: ", Style::default().fg(scheme.muted)),
Span::styled(
format!("{:>6}", result.error_count),
if result.error_count > 0 {
Style::default().fg(scheme.error).bold()
} else {
Style::default().fg(scheme.success)
},
),
]),
Line::from(vec![
Span::styled(" Warnings: ", Style::default().fg(scheme.muted)),
Span::styled(
format!("{:>6}", result.warning_count),
if result.warning_count > 0 {
Style::default().fg(scheme.warning)
} else {
Style::default().fg(scheme.success)
},
),
]),
Line::from(vec![
Span::styled(" Info: ", Style::default().fg(scheme.muted)),
Span::styled(
format!("{:>6}", result.info_count),
Style::default().fg(scheme.info),
),
]),
Line::from(vec![Span::styled(
" \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}",
Style::default().fg(scheme.border),
)]),
Line::from(vec![
Span::styled(" Total: ", Style::default().fg(scheme.muted)),
Span::styled(
format!("{:>6}", result.violations.len()),
Style::default().fg(scheme.text).bold(),
),
]),
];
let right_widget = Paragraph::new(right_lines).block(
Block::default()
.title(" Summary ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.muted)),
);
frame.render_widget(right_widget, h_chunks[1]);
}
#[allow(clippy::too_many_arguments)]
fn render_grouped_violations(
frame: &mut Frame,
area: Rect,
result: &ComplianceResult,
all_results: &[ComplianceResult],
selected: usize,
scroll_offset: usize,
severity_filter: SeverityFilter,
affected_scroll: usize,
) {
let scheme = colors();
if result.violations.is_empty() {
render_empty_compliance(frame, area, result, all_results);
return;
}
let groups = build_groups(result, severity_filter);
if groups.is_empty() {
render_empty_compliance(frame, area, result, all_results);
return;
}
let total_groups = groups.len();
let total_violations: usize = groups.iter().map(|g| g.violations.len()).sum();
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
let viewport_height = h_chunks[0].height.saturating_sub(4) as usize;
let visible_end = (scroll_offset + viewport_height).min(total_groups);
let rows: Vec<Row> = groups
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_end.saturating_sub(scroll_offset))
.map(|(i, group)| {
let is_selected = i == selected;
let row_style = if is_selected {
Style::default().bg(scheme.selection)
} else {
Style::default()
};
Row::new(vec![
Cell::from(severity_text(group.severity)).style(severity_style(group.severity)),
Cell::from(group.category.short_name()),
Cell::from(group.pattern.clone()),
Cell::from(format!("{}", group.violations.len()))
.style(Style::default().fg(scheme.text).bold()),
])
.style(row_style)
})
.collect();
let header = Row::new(vec!["Severity", "Category", "Issue Pattern", "Count"])
.style(Style::default().fg(scheme.primary).bold())
.bottom_margin(1);
let widths = [
Constraint::Length(8),
Constraint::Length(10),
Constraint::Min(20),
Constraint::Length(7),
];
let filter_label = if severity_filter == SeverityFilter::All {
String::new()
} else {
format!(" [{}]", severity_filter.label())
};
let title = format!(
" Violations (grouped: {total_groups} patterns, {total_violations} total){filter_label} ",
);
let table = Table::new(rows, widths).header(header).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(if result.is_compliant {
scheme.warning
} else {
scheme.error
})),
);
frame.render_widget(table, h_chunks[0]);
if total_groups > viewport_height {
let mut scrollbar_state = ScrollbarState::new(total_groups.saturating_sub(viewport_height))
.position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
scrollbar,
h_chunks[0].inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
if let Some(group) = groups.get(selected) {
render_affected_elements(frame, h_chunks[1], group, affected_scroll);
}
}
fn render_affected_elements(
frame: &mut Frame,
area: Rect,
group: &ViolationGroup<'_>,
scroll_offset: usize,
) {
let scheme = colors();
let count = group.violations.len();
let max_width = area.width.saturating_sub(5) as usize;
let mut content_lines: Vec<Line> = Vec::new();
let pattern_display = if group.pattern.len() > max_width {
format!("{}\u{2026}", &group.pattern[..max_width.saturating_sub(1)])
} else {
group.pattern.clone()
};
content_lines.push(Line::from(vec![Span::styled(
format!(" {pattern_display}"),
Style::default().fg(scheme.warning).italic(),
)]));
let mut element_counts: std::collections::BTreeMap<&str, usize> =
std::collections::BTreeMap::new();
for v in &group.violations {
let name = v.element.as_deref().unwrap_or("(document)");
*element_counts.entry(name).or_default() += 1;
}
let mut named: Vec<(&str, usize)> = Vec::new();
let mut paths: Vec<(&str, usize)> = Vec::new();
for (name, cnt) in &element_counts {
if name.contains('/') || name.starts_with('.') {
paths.push((name, *cnt));
} else {
named.push((name, *cnt));
}
}
named.sort_by(|a, b| a.0.len().cmp(&b.0.len()).then(a.0.cmp(b.0)));
paths.sort_by(|a, b| a.0.cmp(b.0));
let named_total: usize = named.iter().map(|(_, c)| c).sum();
let path_total: usize = paths.iter().map(|(_, c)| c).sum();
let mut summary_spans = vec![Span::styled(" ", Style::default())];
if !named.is_empty() {
summary_spans.push(Span::styled(
format!("{named_total} named"),
Style::default().fg(scheme.text),
));
}
if !named.is_empty() && !paths.is_empty() {
summary_spans.push(Span::styled(
" \u{2502} ",
Style::default().fg(scheme.border),
));
}
if !paths.is_empty() {
summary_spans.push(Span::styled(
format!("{path_total} paths"),
Style::default().fg(scheme.muted),
));
}
content_lines.push(Line::from(summary_spans));
let sep_width = max_width.min(30);
content_lines.push(Line::styled(
format!(" {}", "\u{2500}".repeat(sep_width)),
Style::default().fg(scheme.border),
));
for (name, dup_count) in &named {
let mut spans = vec![Span::styled(
format!(" {name}"),
Style::default().fg(scheme.text),
)];
if *dup_count > 1 {
spans.push(Span::styled(
format!(" (\u{00d7}{dup_count})"),
Style::default().fg(scheme.muted),
));
}
content_lines.push(Line::from(spans));
}
if !paths.is_empty() {
let common_prefix = find_common_prefix(&paths);
if !common_prefix.is_empty() {
let prefix_display = if common_prefix.len() > max_width.saturating_sub(4) {
format!(
"\u{2026}{}",
&common_prefix[common_prefix
.len()
.saturating_sub(max_width.saturating_sub(5))..]
)
} else {
common_prefix.clone()
};
content_lines.push(Line::from(""));
content_lines.push(Line::from(vec![
Span::styled(" Paths: ", Style::default().fg(scheme.muted)),
Span::styled(prefix_display, Style::default().fg(scheme.muted).italic()),
]));
} else {
content_lines.push(Line::from(""));
content_lines.push(Line::styled(" Paths:", Style::default().fg(scheme.muted)));
}
let mut dir_groups: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for (path, cnt) in &paths {
let suffix = if !common_prefix.is_empty() && path.starts_with(&common_prefix) {
&path[common_prefix.len()..]
} else {
path
};
let dir = if let Some(slash_pos) = suffix.find('/') {
format!("{}/", &suffix[..slash_pos])
} else {
suffix.to_string()
};
*dir_groups.entry(dir).or_default() += cnt;
}
let mut dir_sorted: Vec<(String, usize)> = dir_groups.into_iter().collect();
dir_sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (dir, file_count) in &dir_sorted {
let dir_display = if dir.len() > max_width.saturating_sub(12) {
format!("{}\u{2026}", &dir[..max_width.saturating_sub(13)])
} else {
dir.clone()
};
content_lines.push(Line::from(vec![
Span::styled(
format!(" {dir_display}"),
Style::default().fg(scheme.text),
),
Span::styled(
format!(" ({file_count})"),
Style::default().fg(scheme.muted),
),
]));
}
}
let viewport_height = area.height.saturating_sub(2) as usize; let total_lines = content_lines.len();
let max_scroll = total_lines.saturating_sub(viewport_height);
let effective_scroll = scroll_offset.min(max_scroll);
let visible_lines: Vec<Line> = content_lines
.into_iter()
.skip(effective_scroll)
.take(viewport_height)
.collect();
let scroll_hint = if total_lines > viewport_height {
" K/J scroll "
} else {
""
};
let title = format!(" Affected ({count}){scroll_hint}");
let widget = Paragraph::new(visible_lines).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.info)),
);
frame.render_widget(widget, area);
if total_lines > viewport_height {
let mut scrollbar_state = ScrollbarState::new(max_scroll).position(effective_scroll);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn find_common_prefix(paths: &[(&str, usize)]) -> String {
if paths.is_empty() {
return String::new();
}
let first = paths[0].0;
let mut prefix_len = match first.rfind('/') {
Some(pos) => pos + 1,
None => return String::new(),
};
for (path, _) in &paths[1..] {
while prefix_len > 0 && !path.starts_with(&first[..prefix_len]) {
prefix_len = match first[..prefix_len.saturating_sub(1)].rfind('/') {
Some(pos) => pos + 1,
None => 0,
};
}
if prefix_len == 0 {
break;
}
}
let prefix = &first[..prefix_len];
if prefix.len() <= 2 {
String::new()
} else {
prefix.to_string()
}
}
fn render_flat_violations(
frame: &mut Frame,
area: Rect,
result: &ComplianceResult,
all_results: &[ComplianceResult],
selected_violation: usize,
scroll_offset: usize,
severity_filter: SeverityFilter,
) {
let scheme = colors();
if result.violations.is_empty() {
render_empty_compliance(frame, area, result, all_results);
return;
}
let filtered: Vec<(usize, &Violation)> = result
.violations
.iter()
.enumerate()
.filter(|(_, v)| severity_filter.matches(v.severity))
.collect();
let viewport_height = area.height.saturating_sub(4) as usize;
let visible_end = (scroll_offset + viewport_height).min(filtered.len());
let rows: Vec<Row> = filtered
.iter()
.skip(scroll_offset)
.take(visible_end.saturating_sub(scroll_offset))
.map(|&(i, violation)| {
let is_selected = i == selected_violation;
let row_style = if is_selected {
Style::default().bg(scheme.selection)
} else {
Style::default()
};
Row::new(vec![
Cell::from(severity_text(violation.severity))
.style(severity_style(violation.severity)),
Cell::from(violation.category.short_name()),
Cell::from(violation.message.clone()),
Cell::from(violation.element.clone().unwrap_or_default())
.style(Style::default().fg(scheme.muted)),
])
.style(row_style)
})
.collect();
let header = Row::new(vec!["Severity", "Category", "Issue", "Element"])
.style(Style::default().fg(scheme.primary).bold())
.bottom_margin(1);
let widths = [
Constraint::Length(8),
Constraint::Length(10),
Constraint::Min(30),
Constraint::Length(20),
];
let filter_label = if severity_filter == SeverityFilter::All {
String::new()
} else {
format!(" [{}]", severity_filter.label())
};
let title = if scroll_offset > 0 || visible_end < filtered.len() {
format!(
" Violations ({}/{}) [{}-{}/{}]{} ",
filtered.len(),
result.violations.len(),
scroll_offset + 1,
visible_end,
filtered.len(),
filter_label,
)
} else {
format!(" Violations ({}){} ", filtered.len(), filter_label,)
};
let table = Table::new(rows, widths).header(header).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(if result.is_compliant {
scheme.warning
} else {
scheme.error
})),
);
frame.render_widget(table, area);
if filtered.len() > viewport_height {
let mut scrollbar_state =
ScrollbarState::new(filtered.len().saturating_sub(viewport_height))
.position(scroll_offset);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn render_empty_compliance(
frame: &mut Frame,
area: Rect,
result: &ComplianceResult,
all_results: &[ComplianceResult],
) {
let scheme = colors();
let mut lines = vec![
Line::from(""),
Line::styled(
" \u{2713} All compliance checks passed!",
Style::default().fg(scheme.success).bold(),
),
Line::from(""),
Line::styled(
format!(" This SBOM meets {} requirements.", result.level.name()),
Style::default().fg(scheme.text),
),
Line::styled(
format!(" {}", result.level.description()),
Style::default().fg(scheme.muted),
),
Line::from(""),
];
lines.push(Line::styled(
" Cross-Standard Overview:",
Style::default().fg(scheme.text).bold(),
));
lines.push(Line::from(""));
let all_levels = ComplianceLevel::all();
for (i, level) in all_levels.iter().enumerate() {
if let Some(r) = all_results.get(i) {
let (icon, icon_color) = if r.is_compliant {
if r.warning_count > 0 {
("\u{26a0}", scheme.warning)
} else {
("\u{2713}", scheme.success)
}
} else {
("\u{2717}", scheme.error)
};
let detail = if r.is_compliant && r.violations.is_empty() {
"passed".to_string()
} else if r.is_compliant {
format!("{} warnings", r.warning_count)
} else {
format!("{} errors, {} warnings", r.error_count, r.warning_count)
};
lines.push(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(icon_color)),
Span::styled(
format!("{:<25}", level.name()),
Style::default().fg(scheme.text),
),
Span::styled(detail, Style::default().fg(scheme.muted)),
]));
}
}
let widget = Paragraph::new(lines).block(
Block::default()
.title(" Compliance Status ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.success)),
);
frame.render_widget(widget, area);
}
fn render_help_bar(frame: &mut Frame, area: Rect, severity_filter: SeverityFilter, grouped: bool) {
let scheme = colors();
let group_label = if grouped { "Grouped" } else { "Flat" };
let help = Line::from(vec![
Span::styled("\u{2190}/\u{2192}", Style::default().fg(scheme.primary)),
Span::styled(" standard ", Style::default().fg(scheme.muted)),
Span::styled("j/k", Style::default().fg(scheme.primary)),
Span::styled(" navigate ", Style::default().fg(scheme.muted)),
Span::styled("Enter", Style::default().fg(scheme.primary)),
Span::styled(" details ", Style::default().fg(scheme.muted)),
Span::styled("g", Style::default().fg(scheme.primary)),
Span::styled(
format!(" view [{group_label}] "),
Style::default().fg(scheme.muted),
),
Span::styled("f", Style::default().fg(scheme.primary)),
Span::styled(
format!(" filter [{}] ", severity_filter.label()),
Style::default().fg(scheme.muted),
),
Span::styled("E", Style::default().fg(scheme.primary)),
Span::styled(" export ", Style::default().fg(scheme.muted)),
Span::styled("?", Style::default().fg(scheme.primary)),
Span::styled(" help", Style::default().fg(scheme.muted)),
]);
let paragraph = Paragraph::new(help).block(Block::default().borders(Borders::ALL));
frame.render_widget(paragraph, area);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SeverityFilter {
#[default]
All,
ErrorsOnly,
WarningsAndAbove,
}
impl SeverityFilter {
pub const fn next(self) -> Self {
match self {
Self::All => Self::ErrorsOnly,
Self::ErrorsOnly => Self::WarningsAndAbove,
Self::WarningsAndAbove => Self::All,
}
}
pub const fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::ErrorsOnly => "Errors",
Self::WarningsAndAbove => "Warn+",
}
}
pub const fn matches(self, severity: ViolationSeverity) -> bool {
match self {
Self::All => true,
Self::ErrorsOnly => matches!(severity, ViolationSeverity::Error),
Self::WarningsAndAbove => matches!(
severity,
ViolationSeverity::Error | ViolationSeverity::Warning
),
}
}
}
#[derive(Debug, Clone)]
pub struct StandardComplianceState {
pub selected_standard: usize,
pub selected_violation: usize,
pub scroll_offset: usize,
pub show_detail: bool,
pub severity_filter: SeverityFilter,
pub grouped: bool,
pub affected_scroll: usize,
}
impl Default for StandardComplianceState {
fn default() -> Self {
Self {
selected_standard: 2, selected_violation: 0,
scroll_offset: 0,
show_detail: false,
severity_filter: SeverityFilter::All,
grouped: true, affected_scroll: 0,
}
}
}
impl StandardComplianceState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub const fn next_standard(&mut self) {
let max = ComplianceLevel::all().len();
self.selected_standard = (self.selected_standard + 1) % max;
self.selected_violation = 0;
self.scroll_offset = 0;
self.affected_scroll = 0;
}
pub const fn prev_standard(&mut self) {
let max = ComplianceLevel::all().len();
self.selected_standard = if self.selected_standard == 0 {
max - 1
} else {
self.selected_standard - 1
};
self.selected_violation = 0;
self.scroll_offset = 0;
self.affected_scroll = 0;
}
pub fn select_next(&mut self, max_violations: usize) {
if max_violations > 0 {
let new = (self.selected_violation + 1).min(max_violations - 1);
if new != self.selected_violation {
self.selected_violation = new;
self.affected_scroll = 0; }
}
}
pub const fn select_prev(&mut self) {
let old = self.selected_violation;
self.selected_violation = self.selected_violation.saturating_sub(1);
if self.selected_violation != old {
self.affected_scroll = 0;
}
}
pub fn toggle_grouped(&mut self) {
self.grouped = !self.grouped;
self.selected_violation = 0;
self.scroll_offset = 0;
self.affected_scroll = 0;
}
pub const fn affected_scroll_up(&mut self) {
self.affected_scroll = self.affected_scroll.saturating_sub(1);
}
pub fn affected_scroll_down(&mut self, max_lines: usize) {
if self.affected_scroll < max_lines.saturating_sub(1) {
self.affected_scroll += 1;
}
}
pub const fn adjust_scroll(&mut self, viewport_height: usize) {
if viewport_height == 0 {
return;
}
if self.selected_violation < self.scroll_offset {
self.scroll_offset = self.selected_violation;
} else if self.selected_violation >= self.scroll_offset + viewport_height {
self.scroll_offset = self.selected_violation + 1 - viewport_height;
}
}
}
#[must_use]
pub fn compute_compliance_results(sbom: &crate::model::NormalizedSbom) -> Vec<ComplianceResult> {
ComplianceLevel::all()
.iter()
.map(|level| {
let checker = ComplianceChecker::new(*level);
checker.check(sbom)
})
.collect()
}