Skip to main content

batuta/stack/
tui.rs

1//! TUI Dashboard for PAIML Stack Status
2//!
3//! Provides an interactive terminal UI for visualizing stack health.
4//!
5//! ## Architecture (PROBAR-SPEC-009)
6//!
7//! Migrated from ratatui to presentar-terminal for stack consistency.
8//! Uses direct CellBuffer rendering with crossterm for terminal handling.
9
10use crate::stack::types::{CrateInfo, CrateStatus, StackHealthReport};
11use anyhow::Result;
12#[cfg(feature = "presentar-terminal")]
13use std::io::{self, Write};
14#[cfg(feature = "presentar-terminal")]
15use std::time::Duration;
16
17#[cfg(feature = "presentar-terminal")]
18use crossterm::{
19    cursor,
20    event::{self, Event, KeyCode, KeyEventKind},
21    execute,
22    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
23};
24
25#[cfg(feature = "presentar-terminal")]
26use presentar_terminal::{CellBuffer, Color, DiffRenderer, Modifiers};
27
28/// CYAN color constant (not in presentar-terminal)
29#[cfg(feature = "presentar-terminal")]
30const CYAN: Color = Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
31
32/// TUI Dashboard state
33#[cfg(feature = "presentar-terminal")]
34pub struct Dashboard {
35    /// Health report to display
36    report: StackHealthReport,
37    /// Selected crate index
38    selected: usize,
39    /// Whether to show details panel
40    show_details: bool,
41    /// Cell buffer for rendering
42    buffer: CellBuffer,
43    /// Diff renderer for efficient updates
44    renderer: DiffRenderer,
45    /// Terminal width
46    width: u16,
47    /// Terminal height
48    height: u16,
49}
50
51#[cfg(feature = "presentar-terminal")]
52impl Dashboard {
53    /// Create a new dashboard with a health report
54    pub fn new(report: StackHealthReport) -> Self {
55        let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
56        Self {
57            report,
58            selected: 0,
59            show_details: true,
60            buffer: CellBuffer::new(width, height),
61            renderer: DiffRenderer::new(),
62            width,
63            height,
64        }
65    }
66
67    /// Run the TUI dashboard
68    pub fn run(&mut self) -> Result<()> {
69        // Setup terminal
70        enable_raw_mode()?;
71        let mut stdout = io::stdout();
72        execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
73
74        // Run the main loop
75        let result = self.run_loop(&mut stdout);
76
77        // Restore terminal
78        disable_raw_mode()?;
79        execute!(stdout, LeaveAlternateScreen, cursor::Show)?;
80
81        result
82    }
83
84    /// Main event loop
85    fn run_loop(&mut self, stdout: &mut io::Stdout) -> Result<()> {
86        loop {
87            // Update terminal size
88            let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
89            if w != self.width || h != self.height {
90                self.width = w;
91                self.height = h;
92                self.buffer.resize(w, h);
93                self.renderer.reset();
94            }
95
96            // Clear and render
97            self.buffer.clear();
98            self.render();
99
100            // Flush to terminal
101            self.renderer.flush(&mut self.buffer, stdout)?;
102            stdout.flush()?;
103
104            // Poll for events with timeout
105            if !event::poll(Duration::from_millis(100))? {
106                continue;
107            }
108            let Event::Key(key) = event::read()? else { continue };
109            if key.kind != KeyEventKind::Press {
110                continue;
111            }
112            match key.code {
113                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
114                KeyCode::Up | KeyCode::Char('k') => {
115                    if self.selected > 0 {
116                        self.selected -= 1;
117                    }
118                }
119                KeyCode::Down | KeyCode::Char('j') => {
120                    if self.selected < self.report.crates.len().saturating_sub(1) {
121                        self.selected += 1;
122                    }
123                }
124                KeyCode::Char('d') => {
125                    self.show_details = !self.show_details;
126                }
127                _ => {}
128            }
129        }
130    }
131
132    /// Write a string with color
133    fn write_str(&mut self, x: u16, y: u16, s: &str, fg: Color) {
134        let mut cx = x;
135        for ch in s.chars() {
136            if cx >= self.width {
137                break;
138            }
139            let mut buf = [0u8; 4];
140            let s = ch.encode_utf8(&mut buf);
141            self.buffer.update(cx, y, s, fg, Color::TRANSPARENT, Modifiers::NONE);
142            cx = cx.saturating_add(1);
143        }
144    }
145
146    /// Set a single character with color
147    fn set_char(&mut self, x: u16, y: u16, ch: char, fg: Color) {
148        if x < self.width && y < self.height {
149            let mut buf = [0u8; 4];
150            let s = ch.encode_utf8(&mut buf);
151            self.buffer.update(x, y, s, fg, Color::TRANSPARENT, Modifiers::NONE);
152        }
153    }
154
155    /// Render the dashboard
156    fn render(&mut self) {
157        let w = self.width;
158        let h = self.height;
159
160        // Layout: Title(3) | Table(min 10) | Details(8 if shown) | Help(3)
161        let title_h: u16 = 3;
162        let help_h: u16 = 3;
163        let details_h: u16 = if self.show_details { 8 } else { 0 };
164        let table_h = h.saturating_sub(title_h + details_h + help_h);
165
166        self.render_title(0, 0, w, title_h);
167        self.render_table(0, title_h, w, table_h);
168
169        if self.show_details {
170            self.render_details(0, title_h + table_h, w, details_h);
171            self.render_help(0, h.saturating_sub(help_h), w, help_h);
172        } else {
173            self.render_help(0, h.saturating_sub(help_h), w, help_h);
174        }
175    }
176
177    /// Render the title bar
178    fn render_title(&mut self, x: u16, y: u16, w: u16, h: u16) {
179        self.draw_box(x, y, w, h, "");
180
181        let summary = &self.report.summary;
182        let status_text = if summary.error_count > 0 {
183            format!("X {} errors", summary.error_count)
184        } else if summary.warning_count > 0 {
185            format!("! {} warnings", summary.warning_count)
186        } else {
187            "* All healthy".to_string()
188        };
189
190        let title =
191            format!("PAIML Stack Dashboard | {} crates | {}", summary.total_crates, status_text);
192
193        let max_len = (w.saturating_sub(4)) as usize;
194        self.write_str(x + 2, y + 1, &title[..title.len().min(max_len)], CYAN);
195    }
196
197    /// Render the crate table
198    fn render_table(&mut self, x: u16, y: u16, w: u16, h: u16) {
199        self.draw_box(x, y, w, h, " Crates ");
200
201        // Header
202        let header =
203            format!("{:4} {:15} {:12} {:12} {:8}", "Stat", "Crate", "Local", "Crates.io", "Issues");
204        let max_len = (w.saturating_sub(2)) as usize;
205        self.write_str(x + 1, y + 1, &header[..header.len().min(max_len)], Color::YELLOW);
206
207        // Separator
208        let sep = "─".repeat(max_len);
209        self.write_str(x + 1, y + 2, &sep[..sep.len().min(max_len)], Color::WHITE);
210
211        // Rows - collect data first to avoid borrow conflict
212        let content_y = y + 3;
213        let content_h = h.saturating_sub(4) as usize;
214
215        let rows: Vec<_> = self
216            .report
217            .crates
218            .iter()
219            .take(content_h)
220            .enumerate()
221            .map(|(i, crate_info)| {
222                let status_icon = match crate_info.status {
223                    CrateStatus::Healthy => "*",
224                    CrateStatus::Warning => "!",
225                    CrateStatus::Error => "X",
226                    CrateStatus::Unknown => "?",
227                };
228
229                let crates_io = crate_info
230                    .crates_io_version
231                    .as_ref()
232                    .map(|v| v.to_string())
233                    .unwrap_or_else(|| "—".to_string());
234
235                let issue_count = crate_info.issues.len();
236                let issue_str =
237                    if issue_count > 0 { issue_count.to_string() } else { "—".to_string() };
238
239                let is_selected = i == self.selected;
240                let fg_color = if is_selected { Color::YELLOW } else { Color::WHITE };
241
242                let row = format!(
243                    "{:4} {:15} {:12} {:12} {:8}",
244                    status_icon,
245                    &crate_info.name[..crate_info.name.len().min(15)],
246                    crate_info.local_version.to_string(),
247                    crates_io,
248                    issue_str
249                );
250
251                let marker = if is_selected { "> " } else { "  " };
252                let full_row = format!("{}{}", marker, row);
253                (i, full_row, fg_color)
254            })
255            .collect();
256
257        for (i, full_row, fg_color) in rows {
258            self.write_str(
259                x + 1,
260                content_y + i as u16,
261                &full_row[..full_row.len().min(max_len)],
262                fg_color,
263            );
264        }
265    }
266
267    /// Render the details panel
268    fn render_details(&mut self, x: u16, y: u16, w: u16, h: u16) {
269        self.draw_box(x, y, w, h, " Details ");
270        let max_len = (w.saturating_sub(2)) as usize;
271        let content_y = y + 1;
272
273        // Extract data first to avoid borrow conflict
274        let crate_data = self.report.crates.get(self.selected).map(|c| {
275            let name_line = format!("Name: {}", c.name);
276            let version_line = format!("Version: {}", c.local_version);
277            let issue_lines: Vec<String> = c
278                .issues
279                .iter()
280                .take(h.saturating_sub(5) as usize)
281                .map(|issue| format!("  * {}", issue.message))
282                .collect();
283            (name_line, version_line, issue_lines)
284        });
285
286        if let Some((name_line, version_line, issue_lines)) = crate_data {
287            // Name
288            self.write_str(
289                x + 1,
290                content_y,
291                &name_line[..name_line.len().min(max_len)],
292                Color::WHITE,
293            );
294
295            // Version
296            self.write_str(
297                x + 1,
298                content_y + 1,
299                &version_line[..version_line.len().min(max_len)],
300                Color::WHITE,
301            );
302
303            // Issues
304            if !issue_lines.is_empty() {
305                self.write_str(x + 1, content_y + 2, "Issues:", Color::RED);
306                for (i, issue_line) in issue_lines.iter().enumerate() {
307                    self.write_str(
308                        x + 1,
309                        content_y + 3 + i as u16,
310                        &issue_line[..issue_line.len().min(max_len)],
311                        Color::WHITE,
312                    );
313                }
314            } else {
315                self.write_str(x + 1, content_y + 2, "No issues", Color::GREEN);
316            }
317        } else {
318            self.write_str(x + 1, y + 1, "No crate selected", Color::WHITE);
319        }
320    }
321
322    /// Render help bar
323    fn render_help(&mut self, x: u16, y: u16, w: u16, h: u16) {
324        self.draw_box(x, y, w, h, "");
325
326        let help = "^/k Up  v/j Down  d Toggle details  q/Esc Quit";
327        let max_len = (w.saturating_sub(2)) as usize;
328        self.write_str(x + 1, y + 1, &help[..help.len().min(max_len)], CYAN);
329    }
330
331    /// Draw a box with border and title
332    fn draw_box(&mut self, x: u16, y: u16, w: u16, h: u16, title: &str) {
333        if w < 2 || h < 2 {
334            return;
335        }
336
337        // Top border
338        self.set_char(x, y, '┌', Color::WHITE);
339        for i in 1..w - 1 {
340            self.set_char(x + i, y, '─', Color::WHITE);
341        }
342        self.set_char(x + w - 1, y, '┐', Color::WHITE);
343
344        // Title
345        if !title.is_empty() && w > title.len() as u16 + 2 {
346            let title_x = x + 2;
347            self.write_str(title_x, y, title, CYAN);
348        }
349
350        // Sides
351        for i in 1..h - 1 {
352            self.set_char(x, y + i, '│', Color::WHITE);
353            self.set_char(x + w - 1, y + i, '│', Color::WHITE);
354        }
355
356        // Bottom border
357        self.set_char(x, y + h - 1, '└', Color::WHITE);
358        for i in 1..w - 1 {
359            self.set_char(x + i, y + h - 1, '─', Color::WHITE);
360        }
361        self.set_char(x + w - 1, y + h - 1, '┘', Color::WHITE);
362    }
363}
364
365/// Run the TUI dashboard with the given report
366#[cfg(feature = "presentar-terminal")]
367pub fn run_dashboard(report: StackHealthReport) -> Result<()> {
368    let mut dashboard = Dashboard::new(report);
369    dashboard.run()
370}
371
372#[cfg(all(test, feature = "presentar-terminal"))]
373mod tests {
374    use super::*;
375    use crate::stack::types::{CrateIssue, HealthSummary, IssueSeverity, IssueType};
376    use std::path::PathBuf;
377
378    fn create_test_report() -> StackHealthReport {
379        let mut crates = vec![
380            CrateInfo::new("trueno", semver::Version::new(1, 2, 0), PathBuf::new()),
381            CrateInfo::new("aprender", semver::Version::new(0, 14, 1), PathBuf::new()),
382        ];
383
384        crates[0].status = CrateStatus::Healthy;
385        crates[0].crates_io_version = Some(semver::Version::new(1, 2, 0));
386
387        crates[1].status = CrateStatus::Warning;
388        crates[1].crates_io_version = Some(semver::Version::new(0, 14, 1));
389        crates[1].issues.push(CrateIssue::new(
390            IssueSeverity::Warning,
391            IssueType::VersionBehind,
392            "Test warning",
393        ));
394
395        StackHealthReport {
396            crates,
397            conflicts: vec![],
398            summary: HealthSummary {
399                total_crates: 2,
400                healthy_count: 1,
401                warning_count: 1,
402                error_count: 0,
403                path_dependency_count: 0,
404                conflict_count: 0,
405            },
406            timestamp: chrono::Utc::now(),
407        }
408    }
409
410    #[test]
411    fn test_dashboard_creation() {
412        let report = create_test_report();
413        let dashboard = Dashboard::new(report);
414
415        assert_eq!(dashboard.selected, 0);
416        assert!(dashboard.show_details);
417        assert_eq!(dashboard.report.crates.len(), 2);
418    }
419
420    #[test]
421    fn test_dashboard_navigation() {
422        let report = create_test_report();
423        let mut dashboard = Dashboard::new(report);
424
425        assert_eq!(dashboard.selected, 0);
426        dashboard.selected = 1;
427        assert_eq!(dashboard.selected, 1);
428        dashboard.selected = 0;
429        assert_eq!(dashboard.selected, 0);
430    }
431
432    #[test]
433    fn test_dashboard_toggle_details() {
434        let report = create_test_report();
435        let mut dashboard = Dashboard::new(report);
436
437        assert!(dashboard.show_details);
438        dashboard.show_details = !dashboard.show_details;
439        assert!(!dashboard.show_details);
440    }
441
442    #[test]
443    fn test_dashboard_with_empty_report() {
444        let report = StackHealthReport {
445            crates: vec![],
446            conflicts: vec![],
447            summary: HealthSummary {
448                total_crates: 0,
449                healthy_count: 0,
450                warning_count: 0,
451                error_count: 0,
452                path_dependency_count: 0,
453                conflict_count: 0,
454            },
455            timestamp: chrono::Utc::now(),
456        };
457
458        let dashboard = Dashboard::new(report);
459        assert_eq!(dashboard.report.crates.len(), 0);
460    }
461
462    #[test]
463    fn test_dashboard_with_errors() {
464        let mut crates =
465            vec![CrateInfo::new("broken", semver::Version::new(0, 1, 0), PathBuf::new())];
466        crates[0].status = CrateStatus::Error;
467        crates[0].issues.push(CrateIssue::new(
468            IssueSeverity::Error,
469            IssueType::PathDependency,
470            "Path dependency error",
471        ));
472
473        let report = StackHealthReport {
474            crates,
475            conflicts: vec![],
476            summary: HealthSummary {
477                total_crates: 1,
478                healthy_count: 0,
479                warning_count: 0,
480                error_count: 1,
481                path_dependency_count: 1,
482                conflict_count: 0,
483            },
484            timestamp: chrono::Utc::now(),
485        };
486
487        let dashboard = Dashboard::new(report);
488        assert_eq!(dashboard.report.summary.error_count, 1);
489    }
490
491    #[test]
492    fn test_dashboard_selected_boundary() {
493        let report = create_test_report();
494        let mut dashboard = Dashboard::new(report);
495
496        dashboard.selected = 1;
497        assert_eq!(dashboard.selected, 1);
498        dashboard.selected = 100;
499        assert_eq!(dashboard.selected, 100);
500    }
501
502    #[test]
503    fn test_dashboard_report_summary_types() {
504        let report = StackHealthReport {
505            crates: vec![],
506            conflicts: vec![],
507            summary: HealthSummary {
508                total_crates: 0,
509                healthy_count: 0,
510                warning_count: 0,
511                error_count: 0,
512                path_dependency_count: 0,
513                conflict_count: 0,
514            },
515            timestamp: chrono::Utc::now(),
516        };
517
518        let dashboard = Dashboard::new(report);
519        assert_eq!(dashboard.report.summary.total_crates, 0);
520        assert!(dashboard.show_details);
521    }
522
523    #[test]
524    fn test_dashboard_with_unknown_status() {
525        let mut crates =
526            vec![CrateInfo::new("unknown", semver::Version::new(0, 1, 0), PathBuf::new())];
527        crates[0].status = CrateStatus::Unknown;
528
529        let report = StackHealthReport {
530            crates,
531            conflicts: vec![],
532            summary: HealthSummary::default(),
533            timestamp: chrono::Utc::now(),
534        };
535
536        let dashboard = Dashboard::new(report);
537        assert_eq!(dashboard.report.crates[0].status, CrateStatus::Unknown);
538    }
539
540    #[test]
541    fn test_dashboard_multiple_crates() {
542        let mut crates = vec![
543            CrateInfo::new("a", semver::Version::new(1, 0, 0), PathBuf::new()),
544            CrateInfo::new("b", semver::Version::new(2, 0, 0), PathBuf::new()),
545            CrateInfo::new("c", semver::Version::new(3, 0, 0), PathBuf::new()),
546        ];
547        for c in &mut crates {
548            c.status = CrateStatus::Healthy;
549        }
550
551        let report = StackHealthReport {
552            crates,
553            conflicts: vec![],
554            summary: HealthSummary { total_crates: 3, healthy_count: 3, ..Default::default() },
555            timestamp: chrono::Utc::now(),
556        };
557
558        let mut dashboard = Dashboard::new(report);
559
560        assert_eq!(dashboard.selected, 0);
561        dashboard.selected = 1;
562        assert_eq!(dashboard.selected, 1);
563        dashboard.selected = 2;
564        assert_eq!(dashboard.selected, 2);
565    }
566
567    #[test]
568    fn test_dashboard_crate_with_multiple_issues() {
569        let mut crates =
570            vec![CrateInfo::new("problematic", semver::Version::new(0, 1, 0), PathBuf::new())];
571        crates[0].status = CrateStatus::Warning;
572        crates[0].issues.push(CrateIssue::new(
573            IssueSeverity::Warning,
574            IssueType::VersionBehind,
575            "Version behind",
576        ));
577        crates[0].issues.push(CrateIssue::new(
578            IssueSeverity::Warning,
579            IssueType::VersionBehind,
580            "Another warning",
581        ));
582
583        let report = StackHealthReport {
584            crates,
585            conflicts: vec![],
586            summary: HealthSummary { total_crates: 1, warning_count: 1, ..Default::default() },
587            timestamp: chrono::Utc::now(),
588        };
589
590        let dashboard = Dashboard::new(report);
591        assert_eq!(dashboard.report.crates[0].issues.len(), 2);
592    }
593
594    mod render_tests {
595        use super::*;
596
597        #[test]
598        fn test_render_full_with_details() {
599            let report = create_test_report();
600            let mut dashboard = Dashboard::new(report);
601            dashboard.render();
602            assert!(dashboard.width > 0);
603            assert!(dashboard.height > 0);
604        }
605
606        #[test]
607        fn test_render_full_without_details() {
608            let report = create_test_report();
609            let mut dashboard = Dashboard::new(report);
610            dashboard.show_details = false;
611            dashboard.render();
612            assert!(dashboard.width > 0);
613        }
614
615        #[test]
616        fn test_render_with_all_status_types() {
617            let mut crates = vec![
618                CrateInfo::new("healthy", semver::Version::new(1, 0, 0), PathBuf::new()),
619                CrateInfo::new("warning", semver::Version::new(1, 0, 0), PathBuf::new()),
620                CrateInfo::new("error", semver::Version::new(1, 0, 0), PathBuf::new()),
621                CrateInfo::new("unknown", semver::Version::new(1, 0, 0), PathBuf::new()),
622            ];
623            crates[0].status = CrateStatus::Healthy;
624            crates[1].status = CrateStatus::Warning;
625            crates[2].status = CrateStatus::Error;
626            crates[3].status = CrateStatus::Unknown;
627
628            let report = StackHealthReport {
629                crates,
630                conflicts: vec![],
631                summary: HealthSummary {
632                    total_crates: 4,
633                    healthy_count: 1,
634                    warning_count: 1,
635                    error_count: 1,
636                    ..Default::default()
637                },
638                timestamp: chrono::Utc::now(),
639            };
640
641            let mut dashboard = Dashboard::new(report);
642            dashboard.render();
643            assert!(dashboard.width > 0);
644        }
645
646        #[test]
647        fn test_render_table_with_selected() {
648            let mut crates = vec![
649                CrateInfo::new("first", semver::Version::new(1, 0, 0), PathBuf::new()),
650                CrateInfo::new("second", semver::Version::new(2, 0, 0), PathBuf::new()),
651            ];
652            crates[0].status = CrateStatus::Healthy;
653            crates[1].status = CrateStatus::Healthy;
654
655            let report = StackHealthReport {
656                crates,
657                conflicts: vec![],
658                summary: HealthSummary::default(),
659                timestamp: chrono::Utc::now(),
660            };
661
662            let mut dashboard = Dashboard::new(report);
663            dashboard.selected = 1;
664            dashboard.render();
665            assert!(dashboard.width > 0);
666        }
667
668        #[test]
669        fn test_render_empty_report() {
670            let report = StackHealthReport {
671                crates: vec![],
672                conflicts: vec![],
673                summary: HealthSummary::default(),
674                timestamp: chrono::Utc::now(),
675            };
676
677            let mut dashboard = Dashboard::new(report);
678            dashboard.render();
679            assert!(dashboard.width > 0);
680        }
681
682        #[test]
683        fn test_render_details_with_issues() {
684            let mut crates =
685                vec![CrateInfo::new("broken", semver::Version::new(0, 1, 0), PathBuf::new())];
686            crates[0].issues.push(CrateIssue::new(
687                IssueSeverity::Error,
688                IssueType::PathDependency,
689                "Test error message",
690            ));
691
692            let report = StackHealthReport {
693                crates,
694                conflicts: vec![],
695                summary: HealthSummary::default(),
696                timestamp: chrono::Utc::now(),
697            };
698
699            let mut dashboard = Dashboard::new(report);
700            dashboard.render();
701            assert!(dashboard.height > 0);
702        }
703
704        // ===================================================================
705        // Coverage expansion: write_str, set_char, draw_box edge cases
706        // ===================================================================
707
708        #[test]
709        fn test_write_str_basic() {
710            let report = create_test_report();
711            let mut dashboard = Dashboard::new(report);
712            // Write a short string within bounds
713            dashboard.write_str(0, 0, "Hello", Color::WHITE);
714            // Should not panic, string is within buffer
715            assert!(dashboard.width > 5);
716        }
717
718        #[test]
719        fn test_write_str_overflow_width() {
720            let report = create_test_report();
721            let mut dashboard = Dashboard::new(report);
722            // Write string that exceeds buffer width -- should truncate, not panic
723            let long = "A".repeat(dashboard.width as usize + 20);
724            dashboard.write_str(0, 0, &long, Color::WHITE);
725        }
726
727        #[test]
728        fn test_write_str_at_edge() {
729            let report = create_test_report();
730            let mut dashboard = Dashboard::new(report);
731            let w = dashboard.width;
732            // Write starting near the right edge
733            dashboard.write_str(w.saturating_sub(2), 0, "ABCD", Color::WHITE);
734        }
735
736        #[test]
737        fn test_set_char_within_bounds() {
738            let report = create_test_report();
739            let mut dashboard = Dashboard::new(report);
740            dashboard.set_char(0, 0, 'X', Color::RED);
741            dashboard.set_char(5, 5, 'Y', Color::GREEN);
742        }
743
744        #[test]
745        fn test_set_char_out_of_bounds() {
746            let report = create_test_report();
747            let mut dashboard = Dashboard::new(report);
748            let w = dashboard.width;
749            let h = dashboard.height;
750            // Out of bounds -- should silently skip
751            dashboard.set_char(w, 0, 'Z', Color::WHITE);
752            dashboard.set_char(0, h, 'Z', Color::WHITE);
753            dashboard.set_char(w + 10, h + 10, 'Z', Color::WHITE);
754        }
755
756        #[test]
757        fn test_draw_box_minimal_size() {
758            let report = create_test_report();
759            let mut dashboard = Dashboard::new(report);
760            // 2x2 is the minimum that draw_box handles
761            dashboard.draw_box(0, 0, 2, 2, "");
762        }
763
764        #[test]
765        fn test_draw_box_too_small() {
766            let report = create_test_report();
767            let mut dashboard = Dashboard::new(report);
768            // 1x1 or 0x0 should early-return without crash
769            dashboard.draw_box(0, 0, 1, 1, "");
770            dashboard.draw_box(0, 0, 0, 0, "");
771            dashboard.draw_box(0, 0, 1, 5, "");
772            dashboard.draw_box(0, 0, 5, 1, "");
773        }
774
775        #[test]
776        fn test_draw_box_with_title() {
777            let report = create_test_report();
778            let mut dashboard = Dashboard::new(report);
779            dashboard.draw_box(0, 0, 40, 10, " Title ");
780        }
781
782        #[test]
783        fn test_draw_box_title_too_wide() {
784            let report = create_test_report();
785            let mut dashboard = Dashboard::new(report);
786            // Title wider than box -- should skip title rendering
787            dashboard.draw_box(0, 0, 5, 5, "Very long title text");
788        }
789
790        #[test]
791        fn test_render_title_errors_only() {
792            let report = StackHealthReport {
793                crates: vec![],
794                conflicts: vec![],
795                summary: HealthSummary {
796                    total_crates: 1,
797                    healthy_count: 0,
798                    warning_count: 0,
799                    error_count: 3,
800                    path_dependency_count: 0,
801                    conflict_count: 0,
802                },
803                timestamp: chrono::Utc::now(),
804            };
805            let mut dashboard = Dashboard::new(report);
806            dashboard.render_title(0, 0, dashboard.width, 3);
807        }
808
809        #[test]
810        fn test_render_title_warnings_only() {
811            let report = StackHealthReport {
812                crates: vec![],
813                conflicts: vec![],
814                summary: HealthSummary {
815                    total_crates: 2,
816                    healthy_count: 1,
817                    warning_count: 2,
818                    error_count: 0,
819                    path_dependency_count: 0,
820                    conflict_count: 0,
821                },
822                timestamp: chrono::Utc::now(),
823            };
824            let mut dashboard = Dashboard::new(report);
825            dashboard.render_title(0, 0, dashboard.width, 3);
826        }
827
828        #[test]
829        fn test_render_title_all_healthy() {
830            let report = StackHealthReport {
831                crates: vec![],
832                conflicts: vec![],
833                summary: HealthSummary {
834                    total_crates: 5,
835                    healthy_count: 5,
836                    warning_count: 0,
837                    error_count: 0,
838                    path_dependency_count: 0,
839                    conflict_count: 0,
840                },
841                timestamp: chrono::Utc::now(),
842            };
843            let mut dashboard = Dashboard::new(report);
844            dashboard.render_title(0, 0, dashboard.width, 3);
845        }
846
847        #[test]
848        fn test_render_table_no_crates_io_version() {
849            let mut crates =
850                vec![CrateInfo::new("local_only", semver::Version::new(0, 1, 0), PathBuf::new())];
851            crates[0].status = CrateStatus::Healthy;
852            // crates_io_version is None by default
853
854            let report = StackHealthReport {
855                crates,
856                conflicts: vec![],
857                summary: HealthSummary::default(),
858                timestamp: chrono::Utc::now(),
859            };
860
861            let mut dashboard = Dashboard::new(report);
862            let w = dashboard.width;
863            dashboard.render_table(0, 3, w, 15);
864        }
865
866        #[test]
867        fn test_render_table_with_issues_count() {
868            let mut crates =
869                vec![CrateInfo::new("buggy", semver::Version::new(1, 0, 0), PathBuf::new())];
870            crates[0].status = CrateStatus::Error;
871            crates[0].issues.push(CrateIssue::new(
872                IssueSeverity::Error,
873                IssueType::PathDependency,
874                "issue 1",
875            ));
876            crates[0].issues.push(CrateIssue::new(
877                IssueSeverity::Warning,
878                IssueType::VersionBehind,
879                "issue 2",
880            ));
881
882            let report = StackHealthReport {
883                crates,
884                conflicts: vec![],
885                summary: HealthSummary::default(),
886                timestamp: chrono::Utc::now(),
887            };
888
889            let mut dashboard = Dashboard::new(report);
890            let w = dashboard.width;
891            dashboard.render_table(0, 3, w, 15);
892        }
893
894        #[test]
895        fn test_render_details_no_issues() {
896            let mut crates =
897                vec![CrateInfo::new("clean", semver::Version::new(2, 0, 0), PathBuf::new())];
898            crates[0].status = CrateStatus::Healthy;
899
900            let report = StackHealthReport {
901                crates,
902                conflicts: vec![],
903                summary: HealthSummary::default(),
904                timestamp: chrono::Utc::now(),
905            };
906
907            let mut dashboard = Dashboard::new(report);
908            let w = dashboard.width;
909            dashboard.render_details(0, 10, w, 8);
910        }
911
912        #[test]
913        fn test_render_details_no_crate_selected() {
914            // Empty crates list, selected=0 means no crate exists at that index
915            let report = StackHealthReport {
916                crates: vec![],
917                conflicts: vec![],
918                summary: HealthSummary::default(),
919                timestamp: chrono::Utc::now(),
920            };
921
922            let mut dashboard = Dashboard::new(report);
923            let w = dashboard.width;
924            dashboard.render_details(0, 10, w, 8);
925        }
926
927        #[test]
928        fn test_render_details_selected_out_of_range() {
929            let mut crates =
930                vec![CrateInfo::new("only_one", semver::Version::new(1, 0, 0), PathBuf::new())];
931            crates[0].status = CrateStatus::Healthy;
932
933            let report = StackHealthReport {
934                crates,
935                conflicts: vec![],
936                summary: HealthSummary::default(),
937                timestamp: chrono::Utc::now(),
938            };
939
940            let mut dashboard = Dashboard::new(report);
941            dashboard.selected = 99; // Out of range
942            let w = dashboard.width;
943            dashboard.render_details(0, 10, w, 8);
944        }
945
946        #[test]
947        fn test_render_details_multiple_issues_truncated() {
948            let mut crates =
949                vec![CrateInfo::new("many_issues", semver::Version::new(0, 1, 0), PathBuf::new())];
950            crates[0].status = CrateStatus::Error;
951            for i in 0..10 {
952                crates[0].issues.push(CrateIssue::new(
953                    IssueSeverity::Error,
954                    IssueType::PathDependency,
955                    format!("Error number {}", i),
956                ));
957            }
958
959            let report = StackHealthReport {
960                crates,
961                conflicts: vec![],
962                summary: HealthSummary::default(),
963                timestamp: chrono::Utc::now(),
964            };
965
966            let mut dashboard = Dashboard::new(report);
967            let w = dashboard.width;
968            // Render details with limited height so issues get truncated
969            dashboard.render_details(0, 10, w, 6);
970        }
971
972        #[test]
973        fn test_render_help_standalone() {
974            let report = create_test_report();
975            let mut dashboard = Dashboard::new(report);
976            let w = dashboard.width;
977            let h = dashboard.height;
978            dashboard.render_help(0, h.saturating_sub(3), w, 3);
979        }
980
981        #[test]
982        fn test_render_with_small_buffer() {
983            let report = create_test_report();
984            let mut dashboard = Dashboard::new(report);
985            // Force a very small buffer
986            dashboard.width = 20;
987            dashboard.height = 10;
988            dashboard.buffer = CellBuffer::new(20, 10);
989            dashboard.render();
990        }
991
992        #[test]
993        fn test_render_long_crate_name_truncation() {
994            let mut crates = vec![CrateInfo::new(
995                "a_very_long_crate_name_that_exceeds_column",
996                semver::Version::new(1, 0, 0),
997                PathBuf::new(),
998            )];
999            crates[0].status = CrateStatus::Healthy;
1000            crates[0].crates_io_version = Some(semver::Version::new(1, 0, 0));
1001
1002            let report = StackHealthReport {
1003                crates,
1004                conflicts: vec![],
1005                summary: HealthSummary::default(),
1006                timestamp: chrono::Utc::now(),
1007            };
1008
1009            let mut dashboard = Dashboard::new(report);
1010            dashboard.render();
1011        }
1012    }
1013}