mod detail;
pub mod explain;
mod preview;
mod progress;
mod render;
mod shared;
#[cfg(test)]
mod tests;
pub use shared::{render_code_block, render_fix_diff, render_fix_text};
pub use explain::explain_finding;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::Frame;
use crate::app::App;
use crate::types::Severity;
#[derive(Debug, Clone)]
pub struct LayerProgress {
pub name: &'static str,
pub short: &'static str,
pub current: u32,
pub total: u32,
pub status: LayerStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayerStatus {
Waiting,
Running,
Complete,
Skipped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingsFilter {
All,
Critical,
High,
Medium,
Low,
}
impl FindingsFilter {
pub const fn from_key(key: char) -> Option<Self> {
match key {
'a' => Some(Self::All),
'c' => Some(Self::Critical),
'h' => Some(Self::High),
'm' => Some(Self::Medium),
'l' => Some(Self::Low),
_ => None,
}
}
pub const fn matches(self, severity: Severity) -> bool {
match self {
Self::All => true,
Self::Critical => matches!(severity, Severity::Critical),
Self::High => matches!(severity, Severity::High),
Self::Medium => matches!(severity, Severity::Medium),
Self::Low => matches!(severity, Severity::Low | Severity::Info),
}
}
}
#[derive(Debug, Clone)]
pub struct ScanViewState {
pub layer_progress: [LayerProgress; 5],
pub findings_filter: FindingsFilter,
pub selected_finding: Option<usize>,
pub detail_open: bool,
pub scanning: bool,
pub show_passed: bool,
pub preview_scroll: usize,
pub scan_split_pct: u16,
pub progress_collapsed: bool,
pub scan_error: Option<String>,
}
impl Default for ScanViewState {
fn default() -> Self {
Self {
layer_progress: [
LayerProgress { name: "Files", short: "L1", current: 0, total: 0, status: LayerStatus::Waiting },
LayerProgress { name: "Docs", short: "L2", current: 0, total: 0, status: LayerStatus::Waiting },
LayerProgress { name: "Config", short: "L3", current: 0, total: 0, status: LayerStatus::Waiting },
LayerProgress { name: "Patterns", short: "L4", current: 0, total: 0, status: LayerStatus::Waiting },
LayerProgress { name: "LLM", short: "L5", current: 0, total: 0, status: LayerStatus::Waiting },
],
findings_filter: FindingsFilter::All,
selected_finding: None,
detail_open: false,
scanning: false,
show_passed: false,
preview_scroll: 0,
scan_split_pct: 45,
progress_collapsed: false,
scan_error: None,
}
}
}
impl ScanViewState {
pub fn navigate_up(&mut self) {
let current = self.selected_finding.unwrap_or(0);
self.selected_finding = Some(current.saturating_sub(1));
}
pub fn navigate_down(&mut self, max: usize) {
if max == 0 {
return;
}
let current = self.selected_finding.unwrap_or(0);
self.selected_finding = Some((current + 1).min(max.saturating_sub(1)));
}
pub fn set_complete(&mut self, files_scanned: u32) {
self.layer_progress[0] = LayerProgress {
name: "Files", short: "L1",
current: files_scanned, total: files_scanned, status: LayerStatus::Complete,
};
self.layer_progress[1] = LayerProgress {
name: "Docs", short: "L2",
current: files_scanned / 3, total: files_scanned / 3, status: LayerStatus::Complete,
};
self.layer_progress[2] = LayerProgress {
name: "Config", short: "L3",
current: 5, total: 5, status: LayerStatus::Complete,
};
self.layer_progress[3] = LayerProgress {
name: "Patterns", short: "L4",
current: files_scanned, total: files_scanned, status: LayerStatus::Complete,
};
self.layer_progress[4] = LayerProgress {
name: "LLM", short: "L5",
current: 0, total: 0, status: LayerStatus::Skipped,
};
self.scanning = false;
self.scan_error = None;
self.progress_collapsed = true;
}
}
pub(super) fn sort_findings_for_display(
filtered: &mut [&crate::types::Finding],
file_agent_map: &[(String, String)],
) {
if file_agent_map.is_empty() {
filtered.sort_by_key(|f| f.severity.sort_key());
} else {
filtered.sort_by(|a, b| {
let agent_a = render::resolve_agent_name(a.file.as_deref(), file_agent_map);
let agent_b = render::resolve_agent_name(b.file.as_deref(), file_agent_map);
agent_a.cmp(agent_b).then_with(|| a.severity.sort_key().cmp(&b.severity.sort_key()))
});
}
}
pub fn resolve_selected_finding<'a>(
findings: &'a [crate::types::Finding],
filter: FindingsFilter,
selected_index: usize,
passports: &[serde_json::Value],
) -> Option<&'a crate::types::Finding> {
let mut filtered: Vec<&crate::types::Finding> = findings
.iter()
.filter(|f| filter.matches(f.severity))
.collect();
let file_agent_map = render::build_file_agent_map(passports);
sort_findings_for_display(&mut filtered, &file_agent_map);
filtered.get(selected_index).copied()
}
pub fn render_scan_view(frame: &mut Frame, area: Rect, app: &App) {
if app.last_scan.is_none() && !app.scan_view.scanning {
progress::render_no_scan(frame, area, app.scan_view.scan_error.as_deref());
return;
}
let progress_height = if app.scan_view.progress_collapsed && !app.scan_view.scanning {
1_u16 } else {
10 };
let top = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(progress_height), Constraint::Length(1), Constraint::Length(1), Constraint::Min(5), ])
.split(area);
if app.scan_view.progress_collapsed && !app.scan_view.scanning {
progress::render_progress_summary(frame, top[0], app);
} else {
let progress_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(5),
])
.split(top[0]);
progress::render_puzzle_header(frame, progress_chunks[0], &app.scan_view);
progress::render_layer_progress(frame, progress_chunks[1], app);
}
progress::render_scan_header(frame, top[1], app);
render::render_filter_bar(frame, top[2], app);
if app.last_scan.is_some() {
let left_pct = app.scan_view.scan_split_pct.clamp(25, 75);
let right_pct = 100 - left_pct;
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(left_pct),
Constraint::Percentage(right_pct),
])
.split(top[3]);
render::render_findings_list(frame, split[0], app);
if app.scan_view.detail_open {
detail::render_finding_detail(frame, split[1], app);
} else {
preview::render_scan_preview(frame, split[1], app);
}
} else {
render::render_findings_list(frame, top[3], app);
}
}