use crate::quality::{QualityReport, SCORING_ENGINE_VERSION};
use crate::tui::shared::quality as shared;
use crate::tui::theme::colors;
use crate::tui::view::app::ViewApp;
use ratatui::{
Frame,
layout::Rect,
prelude::*,
widgets::{Block, Borders, Paragraph, Row, Table},
};
pub fn render_ai_readiness(frame: &mut Frame, area: Rect, app: &ViewApp) {
render_ai_summary(frame, area, &app.quality_report, app.ai_readiness_scroll);
}
fn ai_check_rationale(message: &str) -> Option<&'static str> {
const RATIONALES: [(&str, &str); 11] = [
(
"AI-001",
"Model cards are the primary documentation consumers audit",
),
(
"AI-002",
"Architecture family informs risk and compatibility review",
),
(
"AI-003",
"Dataset references establish data provenance for review",
),
(
"AI-004",
"Metrics substantiate the model's fitness-for-purpose",
),
(
"AI-005",
"Documents bias risks required by model-card standards",
),
(
"AI-006",
"Energy disclosure supports sustainability reporting",
),
(
"AI-007",
"Declared use-cases expose out-of-scope deployment",
),
("AI-008", "Known limitations warn against unsafe contexts"),
("AI-009", "Expected by AI governance and ethics frameworks"),
(
"AI-010",
"Weight hashes let consumers verify against tampering",
),
(
"AI-011",
"Links the model to OSV/KEV/EPSS/VEX security tooling",
),
];
RATIONALES
.iter()
.find(|(id, _)| message.contains(id))
.map(|(_, rationale)| *rationale)
}
pub(crate) fn render_ai_summary(
frame: &mut Frame,
area: Rect,
report: &QualityReport,
scroll: usize,
) {
let scheme = colors();
let Some(metrics) = report.ai_readiness_metrics.as_ref() else {
let widget = Paragraph::new(Line::styled(
" AI readiness metrics are unavailable for this document.",
Style::default().fg(scheme.warning),
))
.block(
Block::default()
.title(" AI Readiness ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.warning)),
);
frame.render_widget(widget, area);
return;
};
if area.height < 3 {
frame.render_widget(Paragraph::new(score_line(report, metrics)), area);
return;
}
let rec_total = report.recommendations.len();
let rec_full: u16 = if rec_total == 0 {
3 } else {
(rec_total.min(6) as u16) * 3 + 2 };
let rec_min: u16 = if rec_total == 0 { 3 } else { 5 };
const CHECKS_MIN: u16 = 6;
let rest = area.height.saturating_sub(4);
let show_checks = rest >= 4;
let rec_h = {
let avail = rest.saturating_sub(CHECKS_MIN);
let h = rec_full.min(avail);
if h < rec_min { 0 } else { h }
};
let mut constraints = vec![Constraint::Length(4)];
if show_checks {
constraints.push(Constraint::Min(4));
}
if rec_h > 0 {
constraints.push(Constraint::Length(rec_h));
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
render_score_header(frame, chunks[0], report, metrics);
if show_checks {
render_checks_table(frame, chunks[1], metrics, scroll);
}
if rec_h > 0 {
render_recommendations(frame, chunks[chunks.len() - 1], report, scroll);
}
}
fn score_line(
report: &QualityReport,
metrics: &crate::quality::AiReadinessMetrics,
) -> Line<'static> {
let scheme = colors();
if metrics.is_not_applicable() {
return Line::styled(
" AI Readiness: N/A (not applicable)",
Style::default().fg(scheme.warning).bold(),
);
}
let score = report.overall_score.round() as u16;
Line::from(vec![
Span::styled(" AI Readiness: ", Style::default().fg(scheme.text)),
Span::styled(
format!("{score}/100 ({})", report.grade.letter()),
Style::default()
.fg(shared::grade_color(report.grade))
.bold(),
),
])
}
fn render_score_header(
frame: &mut Frame,
area: Rect,
report: &QualityReport,
metrics: &crate::quality::AiReadinessMetrics,
) {
let scheme = colors();
let na = metrics.is_not_applicable();
let (line1, border_color) = if na {
(
Line::from(vec![
Span::styled(" N/A ", Style::default().fg(scheme.warning).bold()),
Span::styled("Not applicable", Style::default().fg(scheme.text)),
Span::styled(" Profile: ", Style::default().fg(scheme.muted)),
Span::styled(
shared::profile_display_label(report.profile),
Style::default().fg(scheme.primary),
),
Span::styled(
format!(" Engine v{SCORING_ENGINE_VERSION}"),
Style::default().fg(scheme.muted),
),
]),
scheme.warning,
)
} else {
let score = report.overall_score.round() as u16;
let (grade_col, grade_word) = shared::grade_color_and_label(report.grade);
let bar_max = 20usize;
let filled = ((f32::from(score.min(100)) / 100.0) * bar_max as f32).round() as usize;
let bar: String = "\u{2588}".repeat(filled) + &"\u{2591}".repeat(bar_max - filled);
(
Line::from(vec![
Span::styled(
format!(" {} ", report.grade.letter()),
Style::default().fg(grade_col).bold(),
),
Span::styled(format!("{grade_word} "), Style::default().fg(scheme.text)),
Span::styled(bar, Style::default().fg(grade_col)),
Span::styled(
format!(" {score}/100"),
Style::default().fg(scheme.text).bold(),
),
Span::styled(" Profile: ", Style::default().fg(scheme.muted)),
Span::styled(
shared::profile_display_label(report.profile),
Style::default().fg(scheme.primary),
),
Span::styled(
format!(" Engine v{SCORING_ENGINE_VERSION}"),
Style::default().fg(scheme.muted),
),
]),
grade_col,
)
};
let line2 = if na {
Line::styled(
format!(
" {}",
metrics
.na_reason
.clone()
.unwrap_or_else(|| "No ML model components found".to_string())
),
Style::default().fg(scheme.warning),
)
} else {
Line::from(vec![
Span::styled(" ML Components: ", Style::default().fg(scheme.text_muted)),
Span::styled(
metrics.ml_component_count.to_string(),
Style::default().fg(scheme.primary).bold(),
),
Span::styled(
" \u{2502} Fully documented: ",
Style::default().fg(scheme.text_muted),
),
Span::styled(
metrics.components_fully_documented.to_string(),
Style::default().fg(scheme.success).bold(),
),
])
};
let widget = Paragraph::new(vec![line1, line2]).block(
Block::default()
.title(" AI Readiness Score ")
.title_style(Style::default().bold().fg(scheme.text))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
);
frame.render_widget(widget, area);
}
fn render_checks_table(
frame: &mut Frame,
area: Rect,
metrics: &crate::quality::AiReadinessMetrics,
scroll: usize,
) {
let scheme = colors();
if metrics.checks.is_empty() {
let widget = Paragraph::new(Line::styled(
" No AI readiness checks were evaluated (no ML components found).",
Style::default().fg(scheme.text_muted),
))
.block(
Block::default()
.title(" AI Readiness Checks ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent)),
);
frame.render_widget(widget, area);
return;
}
let rows: Vec<Row> = metrics
.checks
.iter()
.map(|check| {
let status = if check.passed { "PASS" } else { "FAIL" };
let status_style = if check.passed {
Style::default().fg(scheme.success)
} else {
Style::default().fg(scheme.error)
};
Row::new(vec![
check.id.clone(),
check.name.clone(),
format!("{:.0}%", check.weight * 100.0),
status.to_string(),
])
.style(status_style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(8),
Constraint::Min(28),
Constraint::Length(8),
Constraint::Length(8),
],
)
.block(
Block::default()
.title(" AI Readiness Checks (PASS = every ML component passes) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.accent)),
)
.header(
Row::new(vec!["Check", "Description", "Weight", "Status"])
.style(Style::default().fg(scheme.accent).bold())
.bottom_margin(1),
);
let n_checks = metrics.checks.len();
let offset = scroll.min(n_checks.saturating_sub(1));
let mut table_state = ratatui::widgets::TableState::default().with_offset(offset);
frame.render_stateful_widget(table, area, &mut table_state);
let visible = area.height.saturating_sub(2 + 2) as usize;
if n_checks > visible {
crate::tui::widgets::render_scrollbar(
frame,
area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
n_checks,
offset,
);
}
}
fn render_recommendations(frame: &mut Frame, area: Rect, report: &QualityReport, scroll: usize) {
let scheme = colors();
let rec_total = report.recommendations.len();
let inner_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if rec_total == 0 {
lines.push(Line::from(vec![
Span::styled(" \u{2713} ", Style::default().fg(scheme.success)),
Span::styled(
"All AI readiness checks passed for every ML component.",
Style::default().fg(scheme.text),
),
]));
} else {
let offset = scroll.min(rec_total.saturating_sub(1));
for rec in report.recommendations.iter().skip(offset) {
let prefix_width = 6 + rec.category.name().len() + 3;
let msg_width = inner_width.saturating_sub(prefix_width);
let message = crate::tui::widgets::truncate_str(&rec.message, msg_width);
lines.push(Line::from(vec![
Span::styled(
format!(" [P{}] ", rec.priority),
shared::priority_style(rec.priority),
),
Span::styled(
format!("[{}] ", rec.category.name()),
Style::default().fg(scheme.info),
),
Span::styled(message, Style::default().fg(scheme.text)),
]));
let affected = if rec.affected_count == 0 {
"document-level".to_string()
} else {
format!("{} affected", rec.affected_count)
};
let pts_color = if rec.impact >= 5.0 {
scheme.success
} else if rec.impact >= 2.0 {
scheme.warning
} else {
scheme.muted
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(affected, Style::default().fg(scheme.muted)),
Span::styled(" | ", Style::default().fg(scheme.border)),
Span::styled(
format!("+{:.1}pts", rec.impact),
Style::default().fg(pts_color),
),
]));
if let Some(rationale) = ai_check_rationale(&rec.message) {
let why = crate::tui::widgets::truncate_str(
&format!(" Why: {rationale}"),
inner_width,
);
lines.push(Line::styled(why, Style::default().fg(scheme.text_muted)));
}
}
}
let content_rows = area.height.saturating_sub(2);
let overflow = rec_total * 3 > content_rows as usize;
let title = if overflow {
format!(" Recommendations ({rec_total}) [\u{2191}\u{2193} scroll] ")
} else {
format!(" Recommendations ({rec_total}) ")
};
let widget = Paragraph::new(lines).block(
Block::default()
.title(title)
.title_style(Style::default().fg(scheme.error).bold())
.borders(Borders::ALL)
.border_style(Style::default().fg(scheme.error)),
);
frame.render_widget(widget, area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_support::{AIBOM_BSI, aibom_single, pin_theme, render_to_text};
use crate::tui::view::ViewApp;
fn aibom_app() -> ViewApp {
pin_theme();
let (sbom, profile) = aibom_single();
ViewApp::new(sbom, AIBOM_BSI, profile)
}
#[test]
fn score_header_visible_at_min_size_content_area() {
let app = aibom_app();
let expected = format!("{}/100", app.quality_report.overall_score.round() as u16);
let text = render_to_text(80, 17, |frame| {
render_ai_readiness(frame, Rect::new(0, 0, 80, 17), &app);
});
assert!(
text.contains("AI Readiness Score"),
"score header panel must render at min size:\n{text}"
);
assert!(
text.contains(&expected),
"headline score {expected} must be visible at min size:\n{text}"
);
assert!(
text.contains("ML Components:"),
"ML component count must stay visible at min size:\n{text}"
);
}
#[test]
fn score_header_matches_quality_presentation() {
let app = aibom_app();
let text = render_to_text(120, 33, |frame| {
render_ai_readiness(frame, Rect::new(0, 0, 120, 33), &app);
});
let (_, grade_word) =
crate::tui::shared::quality::grade_color_and_label(app.quality_report.grade);
assert!(
text.contains(grade_word),
"grade word `{grade_word}` must render like the Quality header:\n{text}"
);
assert!(
text.contains('\u{2588}') || text.contains('\u{2591}'),
"gauge bar glyphs must render like the Quality header:\n{text}"
);
assert!(
text.contains(&format!("Engine v{SCORING_ENGINE_VERSION}")),
"engine version must render like the Quality header:\n{text}"
);
}
#[test]
fn recommendations_panel_is_honest() {
let app = aibom_app();
let text = render_to_text(120, 33, |frame| {
render_ai_readiness(frame, Rect::new(0, 0, 120, 33), &app);
});
assert!(
!text.contains("'v' to switch view"),
"'v' does nothing on the AI-Readiness tab; the hint must be gone:\n{text}"
);
assert!(
!text.contains("> [P"),
"no selection state exists, so no cursor marker may render:\n{text}"
);
assert!(
!text.contains("Complete data enables accurate vulnerability scanning"),
"boilerplate category rationale must not be shown for AI checks:\n{text}"
);
assert!(
text.contains("Recommendations (4)"),
"recommendations panel must still render with its count:\n{text}"
);
assert!(
text.contains("Why:"),
"failed checks must carry a check-specific rationale:\n{text}"
);
}
#[test]
fn checks_table_states_pass_semantics() {
let app = aibom_app();
let text = render_to_text(120, 33, |frame| {
render_ai_readiness(frame, Rect::new(0, 0, 120, 33), &app);
});
assert!(
!text.contains("Checks passing for every ML component are shown below"),
"old misleading sentence must be gone:\n{text}"
);
assert!(
text.contains("PASS = every ML component passes"),
"the PASS semantics must be stated:\n{text}"
);
assert!(
text.contains("FAIL"),
"failing checks stay listed in the table:\n{text}"
);
}
}