Skip to main content

vfa_tui/ui/widgets/
coverage_grid.rs

1/// Coverage grid widget — renders the coverage matrix as a scrollable,
2/// color-coded grid where rows = catalog assets, columns = workspaces.
3///
4/// Req 3.2: color-coded cells (green=Installed/Current, yellow=Outdated,
5///           red=Drifted, gray=NotInstalled).
6/// Req 29.2: text glyphs alongside color so the widget is readable without color.
7use ratatui::{
8    buffer::Buffer,
9    layout::Rect,
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, Paragraph, Widget},
13    Frame,
14};
15
16use crate::models::coverage::{AssetType, CellStatus, CoverageMatrix, CoverageRow};
17use crate::ui::theme::Theme;
18
19/// Text glyph for each cell status (color-independent, Req 29.2).
20pub fn cell_glyph(status: &CellStatus) -> &'static str {
21    match status {
22        CellStatus::Installed => "[OK]",
23        CellStatus::Outdated => "[OLD]",
24        CellStatus::Drifted => "[DRF]",
25        CellStatus::NotInstalled => "[--]",
26    }
27}
28
29/// Color for each cell status (falls back gracefully in no-color mode).
30fn cell_color(status: &CellStatus, theme: &Theme) -> Style {
31    if theme.no_color {
32        return Style::default();
33    }
34    match status {
35        CellStatus::Installed => Style::default().fg(Color::Green),
36        CellStatus::Outdated => Style::default().fg(Color::Yellow),
37        CellStatus::Drifted => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
38        CellStatus::NotInstalled => Style::default().fg(Color::DarkGray),
39    }
40}
41
42/// Active filter state for the coverage grid.
43#[derive(Debug, Default, Clone)]
44pub struct CoverageGridFilter {
45    /// Restrict to a specific asset type (`None` = all types).
46    pub asset_type: Option<AssetType>,
47    /// Restrict to a provider string (`None` = all providers).
48    pub provider: Option<String>,
49    /// Restrict columns to workspaces whose name contains this substring.
50    pub workspace: Option<String>,
51}
52
53impl CoverageGridFilter {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    fn row_passes(&self, row: &CoverageRow) -> bool {
59        if let Some(ref at) = self.asset_type {
60            if &row.asset_type != at {
61                return false;
62            }
63        }
64        if let Some(ref prov) = self.provider {
65            let row_prov = serde_json::to_value(row.provider)
66                .ok()
67                .and_then(|v| v.as_str().map(|s| s.to_string()))
68                .unwrap_or_default();
69            if !row_prov.contains(prov.as_str()) {
70                return false;
71            }
72        }
73        true
74    }
75
76    fn col_passes(&self, workspace: &str) -> bool {
77        if let Some(ref ws) = self.workspace {
78            return workspace.contains(ws.as_str());
79        }
80        true
81    }
82}
83
84/// State for the coverage grid viewport (scroll offsets).
85#[derive(Debug, Default, Clone)]
86pub struct CoverageGridState {
87    /// First visible row index (asset dimension).
88    pub row_offset: usize,
89    /// First visible column index (workspace dimension).
90    pub col_offset: usize,
91}
92
93impl CoverageGridState {
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    pub fn scroll_down(&mut self, n: usize) {
99        self.row_offset = self.row_offset.saturating_add(n);
100    }
101
102    pub fn scroll_up(&mut self, n: usize) {
103        self.row_offset = self.row_offset.saturating_sub(n);
104    }
105
106    pub fn scroll_right(&mut self, n: usize) {
107        self.col_offset = self.col_offset.saturating_add(n);
108    }
109
110    pub fn scroll_left(&mut self, n: usize) {
111        self.col_offset = self.col_offset.saturating_sub(n);
112    }
113}
114
115/// Render the coverage grid into a [`Frame`].
116///
117/// The grid header shows workspace names (column headers), and each
118/// subsequent line shows one asset row with a cell glyph per workspace.
119pub fn render_coverage_grid(
120    matrix: &CoverageMatrix,
121    state: &CoverageGridState,
122    filter: &CoverageGridFilter,
123    area: Rect,
124    frame: &mut Frame,
125    theme: &Theme,
126) {
127    let lines = build_grid_lines(matrix, state, filter, theme, area.width as usize);
128    let paragraph = Paragraph::new(lines)
129        .block(
130            Block::default()
131                .borders(Borders::ALL)
132                .title("Coverage Matrix  [OK]=Installed [OLD]=Outdated [DRF]=Drifted [--]=Missing")
133                .border_style(theme.border_style()),
134        )
135        .scroll((0, 0));
136    frame.render_widget(paragraph, area);
137}
138
139/// Pure helper — build the rendered lines for a coverage grid.
140///
141/// Exposed so tests can call it directly on a `Buffer`.
142pub fn build_grid_lines<'a>(
143    matrix: &'a CoverageMatrix,
144    state: &CoverageGridState,
145    filter: &CoverageGridFilter,
146    theme: &Theme,
147    max_width: usize,
148) -> Vec<Line<'a>> {
149    // Filtered rows and columns
150    let rows: Vec<&CoverageRow> = matrix
151        .rows
152        .iter()
153        .filter(|r| filter.row_passes(r))
154        .collect();
155    let cols: Vec<&String> = matrix
156        .columns
157        .iter()
158        .filter(|c| filter.col_passes(c))
159        .collect();
160
161    // Skip rows/cols before viewport offset
162    let visible_rows = rows.into_iter().skip(state.row_offset);
163    let visible_cols: Vec<&String> = cols.into_iter().skip(state.col_offset).collect();
164
165    // Cell width: "[DRF]" is 5 chars; we pad to CELL_W chars total
166    const CELL_W: usize = 6;
167    const NAME_W: usize = 22;
168
169    let mut lines: Vec<Line<'a>> = Vec::new();
170
171    // ── header line ──────────────────────────────────────────────────────────
172    let mut header_spans: Vec<Span<'a>> = vec![Span::styled(
173        format!("{:<width$}", "Asset", width = NAME_W),
174        Style::default().add_modifier(Modifier::BOLD),
175    )];
176    let mut col_count = 0;
177    for ws in &visible_cols {
178        let used = NAME_W + col_count * CELL_W;
179        if used + CELL_W > max_width {
180            break;
181        }
182        let label = if ws.len() >= CELL_W {
183            ws[..CELL_W - 1].to_string()
184        } else {
185            format!("{:<width$}", &ws[..], width = CELL_W)
186        };
187        header_spans.push(Span::styled(
188            label,
189            Style::default().add_modifier(Modifier::BOLD),
190        ));
191        col_count += 1;
192    }
193    let visible_cols_truncated = &visible_cols[..col_count.min(visible_cols.len())];
194    lines.push(Line::from(header_spans));
195
196    // ── data rows ─────────────────────────────────────────────────────────────
197    for row in visible_rows {
198        let asset_label = if row.asset_name.len() >= NAME_W {
199            format!("{:.width$}", row.asset_name, width = NAME_W - 1)
200        } else {
201            format!("{:<width$}", row.asset_name, width = NAME_W)
202        };
203
204        let mut spans: Vec<Span<'a>> = vec![Span::raw(asset_label)];
205        for ws in visible_cols_truncated {
206            let key = (row.asset_id.clone(), ws.as_str().to_string());
207            let cell = matrix.cells.get(&key);
208            let (glyph, style) = match cell {
209                Some(c) => (cell_glyph(&c.status), cell_color(&c.status, theme)),
210                None => ("[--]", Style::default().fg(Color::DarkGray)),
211            };
212            spans.push(Span::styled(
213                format!("{:<width$}", glyph, width = CELL_W),
214                style,
215            ));
216        }
217        lines.push(Line::from(spans));
218    }
219
220    if lines.len() == 1 {
221        lines.push(Line::from(vec![Span::raw(
222            "  (no assets match current filter)",
223        )]));
224    }
225
226    lines
227}
228
229/// Render coverage grid directly into a `Buffer` (useful for testing).
230pub fn render_coverage_grid_buffer(
231    matrix: &CoverageMatrix,
232    state: &CoverageGridState,
233    filter: &CoverageGridFilter,
234    area: Rect,
235    buf: &mut Buffer,
236    theme: &Theme,
237) {
238    let lines = build_grid_lines(matrix, state, filter, theme, area.width as usize);
239    let paragraph = Paragraph::new(lines).block(
240        Block::default()
241            .borders(Borders::ALL)
242            .title("Coverage Matrix")
243            .border_style(theme.border_style()),
244    );
245    Widget::render(paragraph, area, buf);
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::models::coverage::{
252        AssetType, CellStatus, CoverageCell, CoverageMatrix, CoverageRow,
253    };
254    use crate::models::provider::Provider;
255    use crate::ui::theme::{ColorSupport, Theme};
256    use ratatui::backend::TestBackend;
257    use ratatui::buffer::Buffer;
258    use ratatui::layout::Rect;
259    use ratatui::Terminal;
260    use std::collections::HashMap;
261
262    fn make_matrix() -> CoverageMatrix {
263        let rows = vec![
264            CoverageRow {
265                asset_id: "agent-alpha".to_string(),
266                asset_type: AssetType::Agent,
267                asset_name: "Alpha Agent".to_string(),
268                provider: Provider::Aws,
269            },
270            CoverageRow {
271                asset_id: "skill-beta".to_string(),
272                asset_type: AssetType::Skill,
273                asset_name: "Beta Skill".to_string(),
274                provider: Provider::Aws,
275            },
276        ];
277        let columns = vec!["prod".to_string(), "staging".to_string()];
278        let mut cells = HashMap::new();
279        cells.insert(
280            ("agent-alpha".to_string(), "prod".to_string()),
281            CoverageCell {
282                status: CellStatus::Installed,
283                installed_version: Some("1.0.0".to_string()),
284                canonical_version: "1.0.0".to_string(),
285                installed_hash: Some("abc".to_string()),
286                canonical_hash: Some("abc".to_string()),
287            },
288        );
289        cells.insert(
290            ("agent-alpha".to_string(), "staging".to_string()),
291            CoverageCell {
292                status: CellStatus::Outdated,
293                installed_version: Some("0.9.0".to_string()),
294                canonical_version: "1.0.0".to_string(),
295                installed_hash: Some("xyz".to_string()),
296                canonical_hash: Some("abc".to_string()),
297            },
298        );
299        cells.insert(
300            ("skill-beta".to_string(), "prod".to_string()),
301            CoverageCell {
302                status: CellStatus::Drifted,
303                installed_version: Some("1.0.0".to_string()),
304                canonical_version: "1.0.0".to_string(),
305                installed_hash: Some("WRONG".to_string()),
306                canonical_hash: Some("abc".to_string()),
307            },
308        );
309        // skill-beta / staging → not installed (no entry in map)
310        let mut workspace_scores = HashMap::new();
311        workspace_scores.insert("prod".to_string(), 50.0);
312        workspace_scores.insert("staging".to_string(), 0.0);
313
314        CoverageMatrix {
315            rows,
316            columns,
317            cells,
318            workspace_scores,
319        }
320    }
321
322    #[test]
323    fn coverage_grid_renders_ok_glyph() {
324        let matrix = make_matrix();
325        let state = CoverageGridState::new();
326        let filter = CoverageGridFilter::new();
327        let theme = Theme::with_color_support(ColorSupport::None);
328        let area = Rect::new(0, 0, 80, 20);
329        let mut buf = Buffer::empty(area);
330        render_coverage_grid_buffer(&matrix, &state, &filter, area, &mut buf, &theme);
331
332        let content: String = buf
333            .content
334            .iter()
335            .map(|c| c.symbol().chars().next().unwrap_or(' '))
336            .collect();
337        assert!(
338            content.contains("[OK]"),
339            "expected [OK] glyph for Installed"
340        );
341    }
342
343    #[test]
344    fn coverage_grid_renders_old_glyph() {
345        let matrix = make_matrix();
346        let state = CoverageGridState::new();
347        let filter = CoverageGridFilter::new();
348        let theme = Theme::with_color_support(ColorSupport::None);
349        let area = Rect::new(0, 0, 80, 20);
350        let mut buf = Buffer::empty(area);
351        render_coverage_grid_buffer(&matrix, &state, &filter, area, &mut buf, &theme);
352
353        let content: String = buf
354            .content
355            .iter()
356            .map(|c| c.symbol().chars().next().unwrap_or(' '))
357            .collect();
358        assert!(
359            content.contains("[OLD]"),
360            "expected [OLD] glyph for Outdated"
361        );
362    }
363
364    #[test]
365    fn coverage_grid_renders_drf_glyph() {
366        let matrix = make_matrix();
367        let state = CoverageGridState::new();
368        let filter = CoverageGridFilter::new();
369        let theme = Theme::with_color_support(ColorSupport::None);
370        let area = Rect::new(0, 0, 80, 20);
371        let mut buf = Buffer::empty(area);
372        render_coverage_grid_buffer(&matrix, &state, &filter, area, &mut buf, &theme);
373
374        let content: String = buf
375            .content
376            .iter()
377            .map(|c| c.symbol().chars().next().unwrap_or(' '))
378            .collect();
379        assert!(
380            content.contains("[DRF]"),
381            "expected [DRF] glyph for Drifted"
382        );
383    }
384
385    #[test]
386    fn coverage_grid_renders_missing_glyph_for_not_installed() {
387        let matrix = make_matrix();
388        let state = CoverageGridState::new();
389        let filter = CoverageGridFilter::new();
390        let theme = Theme::with_color_support(ColorSupport::None);
391        let area = Rect::new(0, 0, 80, 20);
392        let mut buf = Buffer::empty(area);
393        render_coverage_grid_buffer(&matrix, &state, &filter, area, &mut buf, &theme);
394
395        let content: String = buf
396            .content
397            .iter()
398            .map(|c| c.symbol().chars().next().unwrap_or(' '))
399            .collect();
400        assert!(
401            content.contains("[--]"),
402            "expected [--] glyph for NotInstalled/missing"
403        );
404    }
405
406    #[test]
407    fn coverage_grid_filter_by_asset_type() {
408        let matrix = make_matrix();
409        let state = CoverageGridState::new();
410        let filter = CoverageGridFilter {
411            asset_type: Some(AssetType::Skill),
412            ..Default::default()
413        };
414        let theme = Theme::with_color_support(ColorSupport::None);
415        let area = Rect::new(0, 0, 80, 20);
416        let mut buf = Buffer::empty(area);
417        render_coverage_grid_buffer(&matrix, &state, &filter, area, &mut buf, &theme);
418
419        let content: String = buf
420            .content
421            .iter()
422            .map(|c| c.symbol().chars().next().unwrap_or(' '))
423            .collect();
424        // Only Beta Skill row should appear; Alpha Agent row should be absent
425        assert!(
426            content.contains("Beta Skill"),
427            "skill row should appear when filtering by Skill"
428        );
429        assert!(
430            !content.contains("Alpha Agent"),
431            "agent row should be filtered out"
432        );
433    }
434
435    #[test]
436    fn cell_glyph_all_variants_are_nonempty() {
437        for status in [
438            CellStatus::Installed,
439            CellStatus::Outdated,
440            CellStatus::Drifted,
441            CellStatus::NotInstalled,
442        ] {
443            let g = cell_glyph(&status);
444            assert!(!g.is_empty(), "glyph for {:?} must not be empty", status);
445        }
446    }
447
448    #[test]
449    fn coverage_grid_via_test_backend() {
450        let matrix = make_matrix();
451        let state = CoverageGridState::new();
452        let filter = CoverageGridFilter::new();
453        let theme = Theme::with_color_support(ColorSupport::None);
454
455        let backend = TestBackend::new(80, 20);
456        let mut terminal = Terminal::new(backend).unwrap();
457        terminal
458            .draw(|frame| {
459                render_coverage_grid(&matrix, &state, &filter, frame.area(), frame, &theme);
460            })
461            .unwrap();
462
463        let buf = terminal.backend().buffer().clone();
464        let content: String = buf
465            .content
466            .iter()
467            .map(|c| c.symbol().chars().next().unwrap_or(' '))
468            .collect();
469        assert!(content.contains("[OK]"));
470    }
471}