1use 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
20pub 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#[derive(Debug, Default, Clone)]
46pub struct ViolationsState {
47 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#[derive(Debug, Clone)]
67pub struct WorkspaceScore {
68 pub workspace: String,
69 pub compliance_score: f64,
70}
71
72pub 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 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 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 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 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 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
231pub 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
252pub 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 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}