pub mod generators;
mod tests;
pub use generators::export_report;
pub use generators::{zone_label, GENERATORS};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::App;
use crate::theme;
use crate::types::Zone;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExportStatus {
None,
Done(String),
Error(String),
}
#[derive(Debug, Clone)]
pub struct ReportViewState {
pub scroll_offset: u16,
pub export_status: ExportStatus,
pub selected_generator: usize,
pub viewing_report: bool,
}
impl Default for ReportViewState {
fn default() -> Self {
Self {
scroll_offset: 0,
export_status: ExportStatus::None,
selected_generator: 0,
viewing_report: false,
}
}
}
pub fn render_report_view(frame: &mut Frame, area: Rect, app: &App) {
let t = theme::theme();
if app.report_view.viewing_report {
render_report_detail_view(frame, area, app);
return;
}
let block = Block::default()
.title(" Reports & Exports ")
.title_style(theme::title_style())
.borders(Borders::ALL)
.border_style(Style::default().fg(t.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(14), Constraint::Length(5), Constraint::Length(6), ])
.split(inner);
{
let mut lines: Vec<Line<'_>> = Vec::new();
lines.push(Line::from(Span::styled(
" Generate",
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(sections[0].width.saturating_sub(4) as usize)),
Style::default().fg(t.border),
)));
lines.push(Line::raw(""));
for (i, rg) in GENERATORS.iter().enumerate() {
let is_selected = i == app.report_view.selected_generator;
let prefix = if is_selected { "> " } else { " " };
let key_style = if is_selected {
Style::default().fg(t.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.accent)
};
let name_style = if is_selected {
Style::default().fg(t.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.fg)
};
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(if is_selected { t.accent } else { t.fg })),
Span::styled(format!("[{}] ", rg.key), key_style),
Span::styled(
format!("{:<22}", rg.name),
name_style,
),
Span::styled(rg.description, Style::default().fg(t.muted)),
Span::styled(
format!(" {}", rg.duration),
Style::default().fg(t.muted),
),
]));
}
frame.render_widget(Paragraph::new(lines), sections[0]);
}
{
let mut lines: Vec<Line<'_>> = Vec::new();
lines.push(Line::from(Span::styled(
" Recent",
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(sections[1].width.saturating_sub(4) as usize)),
Style::default().fg(t.border),
)));
match &app.report_view.export_status {
ExportStatus::Done(path) => {
lines.push(Line::from(vec![
Span::styled(" ✓ ", Style::default().fg(t.zone_green)),
Span::styled(path.clone(), Style::default().fg(t.fg)),
]));
}
ExportStatus::Error(err) => {
lines.push(Line::from(vec![
Span::styled(" ✗ ", Style::default().fg(t.zone_red)),
Span::styled(err.clone(), Style::default().fg(t.zone_red)),
]));
}
ExportStatus::None => {
lines.push(Line::from(Span::styled(
" (no reports generated yet)",
Style::default().fg(t.muted),
)));
}
}
frame.render_widget(Paragraph::new(lines), sections[1]);
}
{
let mut lines: Vec<Line<'_>> = Vec::new();
lines.push(Line::from(Span::styled(
" Regulator",
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "─".repeat(sections[2].width.saturating_sub(4) as usize)),
Style::default().fg(t.border),
)));
lines.push(Line::from(vec![
Span::styled(" Country: ", Style::default().fg(t.muted)),
Span::styled("DE", Style::default().fg(t.fg)),
Span::styled(" Sector: ", Style::default().fg(t.muted)),
Span::styled("technology", Style::default().fg(t.fg)),
]));
lines.push(Line::from(vec![
Span::styled(" [r] ", Style::default().fg(t.accent)),
Span::styled("Full regulator details", Style::default().fg(t.fg)),
]));
frame.render_widget(Paragraph::new(lines), sections[2]);
}
}
fn render_report_detail_view(frame: &mut Frame, area: Rect, app: &App) {
let t = theme::theme();
let block = Block::default()
.title(" Compliance Report ")
.title_style(theme::title_style())
.borders(Borders::ALL)
.border_style(Style::default().fg(t.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(scan) = &app.last_scan else {
let lines = vec![
Line::raw(""),
Line::from(Span::styled(
" No scan data. Press Ctrl+S to scan first.",
Style::default().fg(t.muted),
)),
];
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
return;
};
let w = inner.width.saturating_sub(4) as usize;
let mut lines: Vec<Line<'_>> = Vec::new();
let zone = zone_label(scan.score.zone);
let score_color = match scan.score.zone {
Zone::Green => t.zone_green,
Zone::Yellow => t.zone_yellow,
Zone::Red => t.zone_red,
};
lines.push(Line::from(Span::styled(
" Executive Summary",
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "═".repeat(w)),
Style::default().fg(t.accent),
)));
lines.push(Line::raw(""));
let ratio = (scan.score.total_score / 100.0).clamp(0.0, 1.0);
let filled = (ratio * (w.min(50) as f64)) as usize;
let empty = w.min(50).saturating_sub(filled);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled("█".repeat(filled), Style::default().fg(score_color)),
Span::styled("░".repeat(empty), Style::default().fg(t.muted)),
Span::styled(
format!(" {:.0}/100", scan.score.total_score),
Style::default().fg(score_color).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(" Status: ", Style::default().fg(t.muted)),
Span::styled(zone, Style::default().fg(score_color).add_modifier(Modifier::BOLD)),
]));
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" Project: ", Style::default().fg(t.muted)),
Span::styled(scan.project_path.clone(), Style::default().fg(t.fg)),
]));
lines.push(Line::from(vec![
Span::styled(" Scanned: ", Style::default().fg(t.muted)),
Span::styled(scan.scanned_at.clone(), Style::default().fg(t.fg)),
]));
lines.push(Line::from(vec![
Span::styled(" Duration: ", Style::default().fg(t.muted)),
Span::styled(
format!("{:.1}s", scan.duration as f64 / 1000.0),
Style::default().fg(t.fg),
),
]));
lines.push(Line::from(vec![
Span::styled(" Files: ", Style::default().fg(t.muted)),
Span::styled(format!("{}", scan.files_scanned), Style::default().fg(t.fg)),
]));
lines.push(Line::from(vec![
Span::styled(" Checks: ", Style::default().fg(t.muted)),
Span::styled(format!("{}", scan.score.passed_checks), Style::default().fg(t.zone_green)),
Span::styled(" passed ", Style::default().fg(t.muted)),
Span::styled(format!("{}", scan.score.failed_checks), Style::default().fg(t.zone_red)),
Span::styled(" failed ", Style::default().fg(t.muted)),
Span::styled(format!("{}", scan.score.skipped_checks), Style::default().fg(t.muted)),
Span::styled(" skipped", Style::default().fg(t.muted)),
]));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
format!(" All Findings ({})", scan.findings.len()),
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" {}", "═".repeat(w)),
Style::default().fg(t.border),
)));
lines.push(Line::raw(""));
if scan.findings.is_empty() {
lines.push(Line::from(Span::styled(
" No findings. All checks passed.",
Style::default().fg(t.zone_green),
)));
} else {
for (i, f) in scan.findings.iter().enumerate() {
let sev_color = theme::severity_color(f.severity);
let sev_label = f.severity.label().to_string();
lines.push(Line::from(vec![
Span::styled(format!(" {:<3}", i + 1), Style::default().fg(t.muted)),
Span::styled(format!("{:<14}", f.check_id), Style::default().fg(t.fg)),
Span::styled(format!("{sev_label:<10}"), Style::default().fg(sev_color)),
Span::styled(f.message.clone(), Style::default().fg(t.fg)),
]));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(" [e] ", Style::default().fg(t.accent)),
Span::styled("Export as Markdown ", Style::default().fg(t.fg)),
Span::styled("[Esc] ", Style::default().fg(t.accent)),
Span::styled("Back to menu ", Style::default().fg(t.fg)),
Span::styled("[j/k] ", Style::default().fg(t.accent)),
Span::styled("Scroll", Style::default().fg(t.fg)),
]));
let scroll = app.report_view.scroll_offset;
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
frame.render_widget(paragraph, inner);
}