Skip to main content

vfa_tui/ui/widgets/
violations.rs

1/// Violations dashboard widget.
2///
3/// Req 15.1–15.4: violations grouped by Severity (Critical→Warning→Info)
4///                then by workspace, ranked by compliance score ascending
5///                (worst first).
6/// Req 29.2: text severity prefixes [CRIT]/[WARN]/[INFO] ensure readability
7///           without color.
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, Paragraph, Widget},
14    Frame,
15};
16
17use crate::models::policy::{PolicyViolation, Severity};
18use crate::ui::theme::Theme;
19
20/// Text prefix for each severity level (color-independent, Req 29.2).
21pub fn severity_prefix(severity: &Severity) -> &'static str {
22    match severity {
23        Severity::Critical => "[CRIT]",
24        Severity::Warning => "[WARN]",
25        Severity::Info => "[INFO]",
26    }
27}
28
29fn severity_style(severity: &Severity, theme: &Theme) -> Style {
30    if theme.no_color {
31        return match severity {
32            Severity::Critical => Style::default().add_modifier(Modifier::BOLD),
33            Severity::Warning => Style::default(),
34            Severity::Info => Style::default().add_modifier(Modifier::DIM),
35        };
36    }
37    match severity {
38        Severity::Critical => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
39        Severity::Warning => Style::default().fg(Color::Yellow),
40        Severity::Info => Style::default().fg(Color::Cyan),
41    }
42}
43
44/// State for the violations viewport (scroll offset).
45#[derive(Debug, Default, Clone)]
46pub struct ViolationsState {
47    /// First visible line index.
48    pub row_offset: usize,
49}
50
51impl ViolationsState {
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    pub fn scroll_down(&mut self, n: usize) {
57        self.row_offset = self.row_offset.saturating_add(n);
58    }
59
60    pub fn scroll_up(&mut self, n: usize) {
61        self.row_offset = self.row_offset.saturating_sub(n);
62    }
63}
64
65/// A (workspace, compliance_score) pair used for ranking.
66#[derive(Debug, Clone)]
67pub struct WorkspaceScore {
68    pub workspace: String,
69    pub compliance_score: f64,
70}
71
72/// Build the rendered lines for the violations dashboard.
73///
74/// `violations` — all violations to display.
75/// `workspace_scores` — per-workspace compliance scores (worst first when sorted ascending).
76pub fn build_violations_lines<'a>(
77    violations: &'a [PolicyViolation],
78    workspace_scores: &'a [WorkspaceScore],
79    state: &ViolationsState,
80    theme: &Theme,
81) -> Vec<Line<'a>> {
82    let mut lines: Vec<Line<'a>> = Vec::new();
83
84    // ── header ──────────────────────────────────────────────────────────────
85    lines.push(Line::from(vec![Span::styled(
86        "Policy Violations  [CRIT]=Critical [WARN]=Warning [INFO]=Info",
87        Style::default().add_modifier(Modifier::BOLD),
88    )]));
89    lines.push(Line::from(vec![Span::raw("")]));
90
91    if violations.is_empty() {
92        lines.push(Line::from(vec![Span::styled(
93            "  No policy violations detected.",
94            Style::default().fg(Color::Green),
95        )]));
96        return lines.into_iter().skip(state.row_offset).collect();
97    }
98
99    // ── workspace compliance score summary (ascending = worst first) ────────
100    if !workspace_scores.is_empty() {
101        let mut sorted_scores = workspace_scores.to_vec();
102        sorted_scores.sort_by(|a, b| {
103            a.compliance_score
104                .partial_cmp(&b.compliance_score)
105                .unwrap_or(std::cmp::Ordering::Equal)
106                .then(a.workspace.cmp(&b.workspace))
107        });
108
109        lines.push(Line::from(vec![Span::styled(
110            "Workspace Compliance (worst first):",
111            Style::default().add_modifier(Modifier::BOLD),
112        )]));
113        for ws in &sorted_scores {
114            let score_str = format!("{:.1}%", ws.compliance_score);
115            let score_style = if ws.compliance_score < 50.0 {
116                if theme.no_color {
117                    Style::default().add_modifier(Modifier::BOLD)
118                } else {
119                    Style::default().fg(Color::Red)
120                }
121            } else if ws.compliance_score < 80.0 {
122                if theme.no_color {
123                    Style::default()
124                } else {
125                    Style::default().fg(Color::Yellow)
126                }
127            } else if theme.no_color {
128                Style::default()
129            } else {
130                Style::default().fg(Color::Green)
131            };
132            lines.push(Line::from(vec![
133                Span::raw("  "),
134                Span::raw(ws.workspace.clone()),
135                Span::raw(": "),
136                Span::styled(score_str, score_style),
137            ]));
138        }
139        lines.push(Line::from(vec![Span::raw("")]));
140    }
141
142    // ── violations grouped by severity then workspace ─────────────────────
143    for sev in [Severity::Critical, Severity::Warning, Severity::Info] {
144        let sev_violations: Vec<&PolicyViolation> = violations
145            .iter()
146            .filter(|v| v.rule.severity == sev)
147            .collect();
148
149        if sev_violations.is_empty() {
150            continue;
151        }
152
153        let prefix = severity_prefix(&sev);
154        let sev_style = severity_style(&sev, theme);
155
156        lines.push(Line::from(vec![
157            Span::styled(prefix, sev_style),
158            Span::styled(
159                format!(" {} violations", sev_violations.len()),
160                Style::default().add_modifier(Modifier::BOLD),
161            ),
162        ]));
163
164        // Group by workspace and sort workspaces by compliance score (ascending)
165        let mut workspaces_seen: Vec<&str> = sev_violations
166            .iter()
167            .map(|v| v.workspace.as_str())
168            .collect();
169        workspaces_seen.sort_unstable();
170        workspaces_seen.dedup();
171
172        // Sort workspace order by score if available (worst first)
173        let score_map: std::collections::HashMap<&str, f64> = workspace_scores
174            .iter()
175            .map(|ws| (ws.workspace.as_str(), ws.compliance_score))
176            .collect();
177        workspaces_seen.sort_by(|a, b| {
178            let sa = score_map.get(a).copied().unwrap_or(100.0);
179            let sb = score_map.get(b).copied().unwrap_or(100.0);
180            sa.partial_cmp(&sb)
181                .unwrap_or(std::cmp::Ordering::Equal)
182                .then(a.cmp(b))
183        });
184
185        for ws in &workspaces_seen {
186            let ws_viols: Vec<&&PolicyViolation> = sev_violations
187                .iter()
188                .filter(|v| v.workspace == *ws)
189                .collect();
190
191            lines.push(Line::from(vec![
192                Span::raw("  "),
193                Span::styled(
194                    ws.to_string(),
195                    Style::default().add_modifier(Modifier::BOLD),
196                ),
197                Span::raw(format!(
198                    " ({} violation{})",
199                    ws_viols.len(),
200                    if ws_viols.len() == 1 { "" } else { "s" }
201                )),
202            ]));
203
204            for v in &ws_viols {
205                lines.push(Line::from(vec![
206                    Span::raw("    "),
207                    Span::styled(prefix, sev_style),
208                    Span::raw(" "),
209                    Span::raw(v.rule.id.clone()),
210                    Span::raw(": "),
211                    Span::raw(v.details.clone()),
212                ]));
213                if !v.remediation.is_empty() {
214                    lines.push(Line::from(vec![
215                        Span::raw("      → "),
216                        Span::styled(
217                            v.remediation.clone(),
218                            Style::default().add_modifier(Modifier::DIM),
219                        ),
220                    ]));
221                }
222            }
223        }
224
225        lines.push(Line::from(vec![Span::raw("")]));
226    }
227
228    lines.into_iter().skip(state.row_offset).collect()
229}
230
231/// Render the violations dashboard into a [`Frame`].
232pub fn render_violations(
233    violations: &[PolicyViolation],
234    workspace_scores: &[WorkspaceScore],
235    state: &ViolationsState,
236    area: Rect,
237    frame: &mut Frame,
238    theme: &Theme,
239) {
240    let lines = build_violations_lines(violations, workspace_scores, state, theme);
241    let paragraph = Paragraph::new(lines)
242        .block(
243            Block::default()
244                .borders(Borders::ALL)
245                .title("Policy Violations")
246                .border_style(theme.border_style()),
247        )
248        .scroll((0, 0));
249    frame.render_widget(paragraph, area);
250}
251
252/// Render the violations dashboard into a [`Buffer`] (useful for testing).
253pub fn render_violations_buffer(
254    violations: &[PolicyViolation],
255    workspace_scores: &[WorkspaceScore],
256    state: &ViolationsState,
257    area: Rect,
258    buf: &mut Buffer,
259    theme: &Theme,
260) {
261    let lines = build_violations_lines(violations, workspace_scores, state, theme);
262    let paragraph = Paragraph::new(lines).block(
263        Block::default()
264            .borders(Borders::ALL)
265            .title("Policy Violations")
266            .border_style(theme.border_style()),
267    );
268    Widget::render(paragraph, area, buf);
269}
270
271#[cfg(test)]
272fn buf_content(buf: &Buffer) -> String {
273    buf.content
274        .iter()
275        .map(|c| c.symbol().chars().next().unwrap_or(' '))
276        .collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::models::policy::{
283        PolicyRule, PolicyRuleType, PolicyScope, PolicyViolation, Severity,
284    };
285    use crate::ui::theme::{ColorSupport, Theme};
286    use ratatui::backend::TestBackend;
287    use ratatui::buffer::Buffer;
288    use ratatui::layout::Rect;
289    use ratatui::Terminal;
290
291    fn make_rule(id: &str, severity: Severity) -> PolicyRule {
292        PolicyRule {
293            id: id.to_string(),
294            rule_type: PolicyRuleType::RequireAsset {
295                asset_id: "some-asset".to_string(),
296            },
297            severity,
298            scope: PolicyScope::All,
299            description: "Test rule".to_string(),
300        }
301    }
302
303    fn make_violation(rule_id: &str, severity: Severity, workspace: &str) -> PolicyViolation {
304        PolicyViolation {
305            rule: make_rule(rule_id, severity),
306            workspace: workspace.to_string(),
307            asset_id: Some("some-asset".to_string()),
308            first_detected: "2025-01-01T00:00:00.000Z".to_string(),
309            details: format!("Asset missing in {workspace}"),
310            remediation: "Install the asset.".to_string(),
311        }
312    }
313
314    fn make_scores(workspaces: &[(&str, f64)]) -> Vec<WorkspaceScore> {
315        workspaces
316            .iter()
317            .map(|(ws, score)| WorkspaceScore {
318                workspace: ws.to_string(),
319                compliance_score: *score,
320            })
321            .collect()
322    }
323
324    #[test]
325    fn violations_shows_critical_prefix() {
326        let violations = vec![make_violation("req-scanner", Severity::Critical, "prod")];
327        let scores = make_scores(&[("prod", 50.0)]);
328        let state = ViolationsState::new();
329        let theme = Theme::with_color_support(ColorSupport::None);
330        let area = Rect::new(0, 0, 100, 30);
331        let mut buf = Buffer::empty(area);
332        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
333
334        let content = buf_content(&buf);
335        assert!(
336            content.contains("[CRIT]"),
337            "expected [CRIT] prefix for Critical severity"
338        );
339    }
340
341    #[test]
342    fn violations_shows_warn_prefix() {
343        let violations = vec![make_violation("req-role", Severity::Warning, "staging")];
344        let scores = make_scores(&[("staging", 75.0)]);
345        let state = ViolationsState::new();
346        let theme = Theme::with_color_support(ColorSupport::None);
347        let area = Rect::new(0, 0, 100, 30);
348        let mut buf = Buffer::empty(area);
349        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
350
351        let content = buf_content(&buf);
352        assert!(
353            content.contains("[WARN]"),
354            "expected [WARN] prefix for Warning severity"
355        );
356    }
357
358    #[test]
359    fn violations_shows_info_prefix() {
360        let violations = vec![make_violation("info-rule", Severity::Info, "dev")];
361        let scores = make_scores(&[("dev", 90.0)]);
362        let state = ViolationsState::new();
363        let theme = Theme::with_color_support(ColorSupport::None);
364        let area = Rect::new(0, 0, 100, 30);
365        let mut buf = Buffer::empty(area);
366        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
367
368        let content = buf_content(&buf);
369        assert!(
370            content.contains("[INFO]"),
371            "expected [INFO] prefix for Info severity"
372        );
373    }
374
375    #[test]
376    fn violations_critical_appears_before_warning() {
377        let violations = vec![
378            make_violation("warn-rule", Severity::Warning, "prod"),
379            make_violation("crit-rule", Severity::Critical, "prod"),
380        ];
381        let scores = make_scores(&[("prod", 40.0)]);
382        let state = ViolationsState::new();
383        let theme = Theme::with_color_support(ColorSupport::None);
384        let area = Rect::new(0, 0, 100, 40);
385        let mut buf = Buffer::empty(area);
386        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
387
388        let content = buf_content(&buf);
389        let crit_pos = content.find("[CRIT]").expect("[CRIT] must appear");
390        let warn_pos = content.find("[WARN]").expect("[WARN] must appear");
391        assert!(
392            crit_pos < warn_pos,
393            "Critical section must appear before Warning section"
394        );
395    }
396
397    #[test]
398    fn violations_worst_workspace_first() {
399        let violations = vec![
400            make_violation("rule1", Severity::Critical, "good-ws"),
401            make_violation("rule2", Severity::Critical, "bad-ws"),
402        ];
403        let scores = make_scores(&[("good-ws", 80.0), ("bad-ws", 20.0)]);
404        let state = ViolationsState::new();
405        let theme = Theme::with_color_support(ColorSupport::None);
406        let area = Rect::new(0, 0, 100, 40);
407        let mut buf = Buffer::empty(area);
408        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
409
410        let content = buf_content(&buf);
411        let bad_pos = content.find("bad-ws").expect("bad-ws must appear");
412        let good_pos = content.find("good-ws").expect("good-ws must appear");
413        // bad-ws has lower score → should appear first in the score table
414        assert!(
415            bad_pos < good_pos,
416            "workspace with lower score should appear first"
417        );
418    }
419
420    #[test]
421    fn violations_empty_shows_no_violations_message() {
422        let violations: Vec<PolicyViolation> = vec![];
423        let scores: Vec<WorkspaceScore> = vec![];
424        let state = ViolationsState::new();
425        let theme = Theme::with_color_support(ColorSupport::None);
426        let area = Rect::new(0, 0, 80, 20);
427        let mut buf = Buffer::empty(area);
428        render_violations_buffer(&violations, &scores, &state, area, &mut buf, &theme);
429
430        let content = buf_content(&buf);
431        assert!(
432            content.contains("No policy violations"),
433            "empty list should show 'No policy violations' message"
434        );
435    }
436
437    #[test]
438    fn severity_prefixes_all_nonempty() {
439        for s in [Severity::Critical, Severity::Warning, Severity::Info] {
440            assert!(
441                !severity_prefix(&s).trim().is_empty(),
442                "prefix for {s:?} must be non-empty"
443            );
444        }
445    }
446
447    #[test]
448    fn violations_via_test_backend() {
449        let violations = vec![make_violation("req-scanner", Severity::Critical, "prod")];
450        let scores = make_scores(&[("prod", 50.0)]);
451        let state = ViolationsState::new();
452        let theme = Theme::with_color_support(ColorSupport::None);
453
454        let backend = TestBackend::new(100, 30);
455        let mut terminal = Terminal::new(backend).unwrap();
456        terminal
457            .draw(|frame| {
458                render_violations(&violations, &scores, &state, frame.area(), frame, &theme);
459            })
460            .unwrap();
461
462        let buf = terminal.backend().buffer().clone();
463        let content = buf_content(&buf);
464        assert!(content.contains("[CRIT]"));
465    }
466}