1use 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
19pub 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
29fn 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#[derive(Debug, Default, Clone)]
44pub struct CoverageGridFilter {
45 pub asset_type: Option<AssetType>,
47 pub provider: Option<String>,
49 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#[derive(Debug, Default, Clone)]
86pub struct CoverageGridState {
87 pub row_offset: usize,
89 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
115pub 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
139pub 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 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 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 const CELL_W: usize = 6;
167 const NAME_W: usize = 22;
168
169 let mut lines: Vec<Line<'a>> = Vec::new();
170
171 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 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
229pub 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 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 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}