Skip to main content

dev_prune/tui/
status_view.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Terminal UI for the `dev-prune status` command.
5//
6// Renders a full interactive, scrollable table of all registered repositories
7// showing: status, reason skipped, last activity, last pruned, bloat size,
8// and adapters. Users can also select candidates and trigger a prune pass
9// directly from this view.
10
11use std::io;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
17use ratatui::prelude::*;
18use ratatui::widgets::*;
19
20use crate::constants;
21use crate::engine::{RepoStatusEntry, SkipReason};
22use crate::output::format_bytes;
23use crate::tui::Tui;
24
25/// Mode the status view is in.
26enum ViewMode {
27    /// Browsing the table — 'p' enters PruneSelect mode.
28    Browse,
29    /// User is selecting candidates to prune.
30    PruneSelect,
31}
32
33struct StatusApp<'a> {
34    repos: &'a [RepoStatusEntry],
35    table_state: TableState,
36    /// Which rows are checked for prune (indexed to `repos`).
37    selected: Vec<bool>,
38    mode: ViewMode,
39    /// If `Some`, the user confirmed and we return these indices.
40    confirmed_indices: Option<Vec<usize>>,
41    /// Set to true when the user toggles ignore config in .devprune.json or presence of ignore.devprune.json so caller can reload.
42    pub should_reload: bool,
43}
44
45impl<'a> StatusApp<'a> {
46    fn new(repos: &'a [RepoStatusEntry]) -> Self {
47        let selected = vec![false; repos.len()];
48        let mut table_state = TableState::default();
49        table_state.select(Some(0));
50        Self {
51            repos,
52            table_state,
53            selected,
54            mode: ViewMode::Browse,
55            confirmed_indices: None,
56            should_reload: false,
57        }
58    }
59
60    fn move_up(&mut self) {
61        let i = match self.table_state.selected() {
62            Some(i) => {
63                if i == 0 {
64                    self.repos.len().saturating_sub(1)
65                } else {
66                    i - 1
67                }
68            }
69            None => 0,
70        };
71        self.table_state.select(Some(i));
72    }
73
74    fn move_down(&mut self) {
75        let i = match self.table_state.selected() {
76            Some(i) => {
77                if i >= self.repos.len().saturating_sub(1) {
78                    0
79                } else {
80                    i + 1
81                }
82            }
83            None => 0,
84        };
85        self.table_state.select(Some(i));
86    }
87
88    fn toggle_current(&mut self) {
89        if let Some(i) = self.table_state.selected() {
90            // Only allow toggling candidate repos for pruning
91            if matches!(self.repos[i].reason, SkipReason::Candidate) {
92                self.selected[i] = !self.selected[i];
93            }
94        }
95    }
96
97    fn toggle_all_candidates(&mut self) {
98        let any_candidate_selected = self
99            .repos
100            .iter()
101            .enumerate()
102            .any(|(i, r)| matches!(r.reason, SkipReason::Candidate) && self.selected[i]);
103
104        for (i, repo) in self.repos.iter().enumerate() {
105            if matches!(repo.reason, SkipReason::Candidate) {
106                self.selected[i] = !any_candidate_selected;
107            }
108        }
109    }
110
111    fn confirm_prune(&mut self) {
112        let indices: Vec<usize> = self
113            .selected
114            .iter()
115            .enumerate()
116            .filter(|&(_, s)| *s)
117            .map(|(i, _)| i)
118            .collect();
119        self.confirmed_indices = Some(indices);
120    }
121
122    fn selected_bytes(&self) -> u64 {
123        self.repos
124            .iter()
125            .enumerate()
126            .filter(|(i, _)| self.selected[*i])
127            .map(|(_, r)| r.reclaimable_bytes)
128            .sum()
129    }
130
131    fn selected_count(&self) -> usize {
132        self.selected.iter().filter(|&&s| s).count()
133    }
134
135    fn candidate_count(&self) -> usize {
136        self.repos
137            .iter()
138            .filter(|r| matches!(r.reason, SkipReason::Candidate))
139            .count()
140    }
141}
142
143/// Render the full interactive status view.
144///
145/// Returns `Some` with the selected repositories' paths if the user confirmed a
146/// prune, or `None` if they just quit. Paths, not indices: every `i` toggle
147/// reloads the list, and an ignored repository entering or leaving it renumbers
148/// everything — indices handed to the caller would address the list it loaded
149/// before any of that happened.
150///
151/// Re-runs automatically when the user toggles ignore config in `devprune.json` so the
152/// status reflects the change immediately.
153pub fn render_status_tui(
154    repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
155) -> Result<Option<Vec<PathBuf>>> {
156    loop {
157        let repos = repos_loader();
158
159        if repos.is_empty() {
160            return Ok(None);
161        }
162
163        let mut app = StatusApp::new(&repos);
164        {
165            // Scoped so the terminal is restored before anything below prints, and on
166            // every exit path from the loop — return, error, or panic.
167            let mut tui = Tui::new()?;
168            tui.drain_stale_input(Duration::from_millis(100));
169            run_status_loop(&mut tui.terminal, &mut app)?;
170        }
171
172        if app.should_reload {
173            // User toggled ignore — reload and re-render
174            continue;
175        }
176
177        // Resolved against `repos` — the list this iteration actually displayed —
178        // while it is still in scope, so a reload can never desynchronise them.
179        return Ok(app
180            .confirmed_indices
181            .map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
182    }
183}
184
185fn run_status_loop(
186    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
187    app: &mut StatusApp,
188) -> Result<()> {
189    loop {
190        terminal.draw(|frame| render_ui(frame, app))?;
191
192        if event::poll(Duration::from_millis(100))? {
193            if let Event::Key(key) = event::read()? {
194                if key.kind == KeyEventKind::Release {
195                    continue;
196                }
197                // Raw mode delivers Ctrl-C as a key event rather than a signal, so
198                // without this the one key everybody reaches for to escape does nothing.
199                if key.modifiers.contains(KeyModifiers::CONTROL)
200                    && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
201                {
202                    return Ok(());
203                }
204                match key.code {
205                    KeyCode::Up | KeyCode::Char('k') => app.move_up(),
206                    KeyCode::Down | KeyCode::Char('j') => app.move_down(),
207                    KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
208                    KeyCode::End | KeyCode::Char('G') => {
209                        app.table_state
210                            .select(Some(app.repos.len().saturating_sub(1)));
211                    }
212                    KeyCode::PageUp => {
213                        let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
214                        app.table_state.select(Some(i));
215                    }
216                    KeyCode::PageDown => {
217                        let i = (app.table_state.selected().unwrap_or(0) + 10)
218                            .min(app.repos.len().saturating_sub(1));
219                        app.table_state.select(Some(i));
220                    }
221                    KeyCode::Char(' ') => {
222                        if matches!(app.mode, ViewMode::PruneSelect) {
223                            app.toggle_current();
224                        }
225                    }
226                    KeyCode::Char('a') | KeyCode::Char('A') => {
227                        if matches!(app.mode, ViewMode::PruneSelect) {
228                            app.toggle_all_candidates();
229                        }
230                    }
231                    KeyCode::Char('p') | KeyCode::Char('P') => {
232                        app.mode = ViewMode::PruneSelect;
233                        // Auto-select all candidates
234                        for (i, repo) in app.repos.iter().enumerate() {
235                            if matches!(repo.reason, SkipReason::Candidate) {
236                                app.selected[i] = true;
237                            }
238                        }
239                    }
240                    KeyCode::Char('i') | KeyCode::Char('I') => {
241                        // Toggle ignore in .devprune.json on the current repo
242                        if let Some(idx) = app.table_state.selected() {
243                            let repo = &app.repos[idx];
244                            // Refuses a config that does not parse. Starting from the
245                            // defaults would have written a fresh file over the broken
246                            // one, discarding every other override it held — and the
247                            // dashboard already shows such a repo as `config_error`.
248                            let mut per_repo =
249                                crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
250                                    .map_err(|e| anyhow::anyhow!(e))
251                                    .with_context(|| {
252                                        format!(
253                                            "Could not toggle ignore for {}",
254                                            crate::output::clean_path(&repo.path)
255                                        )
256                                    })?
257                                    .unwrap_or_default();
258                            per_repo.ignore = !per_repo.ignore;
259                            // Both writes are propagated rather than swallowed. A silent
260                            // failure here redraws the table unchanged, which reads as a
261                            // dead key; worse, a repository the user just un-ignored would
262                            // still be pruned on the next pass.
263                            per_repo.save_to_repo(&repo.path).with_context(|| {
264                                format!(
265                                    "Could not write the config for {}",
266                                    crate::output::clean_path(&repo.path)
267                                )
268                            })?;
269
270                            // The legacy marker file still counts as "ignored", so it has
271                            // to go for the toggle to mean anything.
272                            let legacy_ignore =
273                                repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
274                            if legacy_ignore.exists() {
275                                std::fs::remove_file(&legacy_ignore).with_context(|| {
276                                    format!(
277                                        "Could not remove {}",
278                                        crate::output::clean_path(&legacy_ignore)
279                                    )
280                                })?;
281                            }
282
283                            // Signal caller to reload status
284                            app.should_reload = true;
285                            return Ok(());
286                        }
287                    }
288                    KeyCode::Enter => {
289                        if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
290                            app.confirm_prune();
291                            return Ok(());
292                        }
293                    }
294                    KeyCode::Esc => {
295                        if matches!(app.mode, ViewMode::PruneSelect) {
296                            // Exit prune-select mode, go back to browse
297                            app.mode = ViewMode::Browse;
298                            for s in &mut app.selected {
299                                *s = false;
300                            }
301                        } else {
302                            return Ok(());
303                        }
304                    }
305                    KeyCode::Char('q') => return Ok(()),
306                    _ => {}
307                }
308            }
309        }
310    }
311}
312
313fn reason_color(reason: &SkipReason) -> Color {
314    match reason {
315        SkipReason::Candidate => Color::Green,
316        SkipReason::Active => Color::Cyan,
317        SkipReason::Ignored => Color::DarkGray,
318        SkipReason::NoBloat => Color::Blue,
319        SkipReason::PathMissing => Color::Red,
320        // Actionable rather than broken: the repo is fine, its config file is not.
321        SkipReason::ConfigError(_) => Color::Yellow,
322    }
323}
324
325fn render_ui(frame: &mut Frame, app: &StatusApp) {
326    let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
327
328    let outer = Layout::default()
329        .direction(Direction::Vertical)
330        .constraints([
331            Constraint::Length(3), // header
332            Constraint::Min(5),    // table
333            // Four content lines plus the border: the keybindings, the mode-specific
334            // line, and the credit line that closes both footers.
335            Constraint::Length(6), // footer
336        ])
337        .split(frame.area());
338
339    // ── Header ───────────────────────────────────────────────────────────────
340    let mode_label = if is_prune_mode {
341        Span::styled(
342            " PRUNE-SELECT MODE ",
343            Style::default()
344                .bg(Color::Yellow)
345                .fg(Color::Black)
346                .add_modifier(Modifier::BOLD),
347        )
348    } else {
349        Span::styled(
350            " BROWSE MODE ",
351            Style::default()
352                .bg(Color::Cyan)
353                .fg(Color::Black)
354                .add_modifier(Modifier::BOLD),
355        )
356    };
357
358    let header_line = Line::from(vec![
359        Span::styled(
360            " dev-prune ",
361            Style::default()
362                .fg(Color::Black)
363                .bg(Color::Green)
364                .add_modifier(Modifier::BOLD),
365        ),
366        Span::raw(" "),
367        mode_label,
368        Span::styled(
369            format!(
370                "  {} repos  |  {} candidates  |  {} reclaimable",
371                app.repos.len(),
372                app.candidate_count(),
373                format_bytes(app.repos.iter().map(|r| r.reclaimable_bytes).sum::<u64>())
374            ),
375            Style::default().fg(Color::DarkGray),
376        ),
377    ]);
378
379    let header_widget =
380        Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
381            Style::default().fg(if is_prune_mode {
382                Color::Yellow
383            } else {
384                Color::Cyan
385            }),
386        ));
387    frame.render_widget(header_widget, outer[0]);
388
389    // ── Table ─────────────────────────────────────────────────────────────────
390    let col_headers = Row::new(vec![
391        Cell::from(if is_prune_mode { "Sel" } else { "#" })
392            .style(Style::default().add_modifier(Modifier::BOLD)),
393        Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
394        Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
395        Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
396        Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
397        Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
398        Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
399    ])
400    .height(1)
401    .bottom_margin(1)
402    .style(Style::default().bg(Color::Rgb(20, 25, 40)));
403
404    let rows: Vec<Row> = app
405        .repos
406        .iter()
407        .enumerate()
408        .map(|(i, repo)| {
409            let is_selected = app.selected[i];
410            let color = reason_color(&repo.reason);
411
412            let sel_cell = if is_prune_mode {
413                if matches!(repo.reason, SkipReason::Candidate) {
414                    if is_selected {
415                        Cell::from("[x]").style(
416                            Style::default()
417                                .fg(Color::Green)
418                                .add_modifier(Modifier::BOLD),
419                        )
420                    } else {
421                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
422                    }
423                } else {
424                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
425                }
426            } else {
427                Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
428            };
429
430            let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
431            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
432
433            let reason_str = repo.reason.to_string();
434            let adapters_str = if repo.adapters.is_empty() {
435                "—".to_string()
436            } else {
437                repo.adapters.join(", ")
438            };
439            let bloat_str = if repo.reclaimable_bytes > 0 {
440                format_bytes(repo.reclaimable_bytes)
441            } else {
442                "—".to_string()
443            };
444            let activity_str = repo
445                .last_activity
446                .map(|d| d.format("%Y-%m-%d").to_string())
447                .unwrap_or_else(|| "—".to_string());
448            let pruned_str = repo
449                .entry
450                .last_pruned_at
451                .map(|d| d.format("%Y-%m-%d").to_string())
452                .unwrap_or_else(|| "Never".to_string());
453
454            let row_style = if is_selected {
455                Style::default().bg(Color::Rgb(20, 50, 30))
456            } else {
457                Style::default()
458            };
459
460            Row::new(vec![
461                sel_cell,
462                Cell::from(path_str).style(Style::default().fg(Color::White)),
463                Cell::from(reason_str).style(Style::default().fg(color)),
464                Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
465                Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
466                Cell::from(activity_str).style(Style::default().fg(Color::Gray)),
467                Cell::from(pruned_str).style(Style::default().fg(Color::Gray)),
468            ])
469            .style(row_style)
470        })
471        .collect();
472
473    let table = Table::new(
474        rows,
475        [
476            Constraint::Length(4),  // sel/#
477            Constraint::Min(24),    // path
478            Constraint::Length(22), // reason
479            Constraint::Length(16), // adapters
480            Constraint::Length(11), // bloat
481            Constraint::Length(13), // last activity
482            Constraint::Length(13), // last pruned
483        ],
484    )
485    .header(col_headers)
486    .block(
487        Block::default()
488            .title(" Registered Repositories ")
489            .borders(Borders::ALL)
490            .border_style(Style::default().fg(Color::Gray)),
491    )
492    .row_highlight_style(
493        Style::default()
494            .bg(Color::Rgb(30, 40, 70))
495            .add_modifier(Modifier::BOLD),
496    )
497    .highlight_symbol("▶ ");
498
499    frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
500
501    // ── Footer ────────────────────────────────────────────────────────────────
502    let mut footer_lines = if is_prune_mode {
503        vec![
504            Line::from(vec![
505                Span::styled("Selected: ", Style::default().fg(Color::Gray)),
506                Span::styled(
507                    format!(
508                        "{} of {} candidates  ({})",
509                        app.selected_count(),
510                        app.candidate_count(),
511                        format_bytes(app.selected_bytes())
512                    ),
513                    Style::default()
514                        .fg(Color::Yellow)
515                        .add_modifier(Modifier::BOLD),
516                ),
517            ]),
518            Line::from(vec![
519                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
520                Span::raw(" Navigate  "),
521                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
522                Span::raw(" Jump  "),
523                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
524                Span::raw(" Toggle  "),
525                Span::styled("[a]", Style::default().fg(Color::Cyan)),
526                Span::raw(" Toggle All  "),
527                Span::styled(
528                    "[Enter]",
529                    Style::default()
530                        .fg(Color::Green)
531                        .add_modifier(Modifier::BOLD),
532                ),
533                Span::raw(" Prune Selected  "),
534                Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
535                Span::raw(" Back to Browse  "),
536                Span::styled("[q]", Style::default().fg(Color::Red)),
537                Span::raw(" Quit"),
538            ]),
539            Line::from(vec![]),
540        ]
541    } else {
542        vec![
543            Line::from(vec![
544                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
545                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
546                Span::raw("  "),
547                Span::styled("■ Active", Style::default().fg(Color::Cyan)),
548                Span::raw("  "),
549                Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
550                Span::raw("  "),
551                Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
552                Span::raw("  "),
553                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
554            ]),
555            Line::from(vec![
556                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
557                Span::raw(" Navigate  "),
558                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
559                Span::raw(" Jump  "),
560                Span::styled(
561                    "[p]",
562                    Style::default()
563                        .fg(Color::Yellow)
564                        .add_modifier(Modifier::BOLD),
565                ),
566                Span::raw(" Prune-Select Mode  "),
567                Span::styled(
568                    "[i]",
569                    Style::default()
570                        .fg(Color::Magenta)
571                        .add_modifier(Modifier::BOLD),
572                ),
573                Span::raw(" Toggle Ignore  "),
574                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
575                Span::raw(" Quit"),
576            ]),
577            Line::from(vec![
578                Span::styled(
579                    "[i] ",
580                    Style::default()
581                        .fg(Color::Magenta)
582                        .add_modifier(Modifier::BOLD),
583                ),
584                Span::styled(
585                    "toggles `ignore` in `.devprune.json` and updates `.gitignore` — refreshes instantly.",
586                    Style::default().fg(Color::DarkGray),
587                ),
588            ]),
589        ]
590    };
591
592    // The credit, on both footers, in the dimmest colour the theme has. It is one
593    // constant and one push — a fork that does not want it deletes these two lines and
594    // nothing else in the binary cares.
595    footer_lines.push(Line::from(Span::styled(
596        constants::ATTRIBUTION_LINE,
597        Style::default().fg(Color::DarkGray),
598    )));
599
600    let footer =
601        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
602            Style::default().fg(if is_prune_mode {
603                Color::Yellow
604            } else {
605                Color::Green
606            }),
607        ));
608    frame.render_widget(footer, outer[2]);
609}
610
611// ── Plain-text fallback ──────────────────────────────────────────────────────
612
613/// Plain text fallback rendering for non-TUI environments.
614pub fn render_status_plain(repos: &[RepoStatusEntry]) {
615    use crate::output;
616
617    output::print_header("dev-prune status");
618    println!(
619        "\n  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
620        "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
621    );
622    println!("  {}", "─".repeat(118));
623
624    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
625    for (i, repo) in repos.iter().enumerate() {
626        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
627        let reason = repo.reason.to_string();
628        let adapters = if repo.adapters.is_empty() {
629            "—".to_string()
630        } else {
631            repo.adapters.join("+")
632        };
633        let bloat = if repo.reclaimable_bytes > 0 {
634            format_bytes(repo.reclaimable_bytes)
635        } else {
636            "—".to_string()
637        };
638        let activity = repo
639            .last_activity
640            .map(|d| d.format("%Y-%m-%d").to_string())
641            .unwrap_or_else(|| "—".to_string());
642        let pruned = repo
643            .entry
644            .last_pruned_at
645            .map(|d| d.format("%Y-%m-%d").to_string())
646            .unwrap_or_else(|| "Never".to_string());
647
648        println!(
649            "  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
650            i + 1,
651            path_str,
652            reason,
653            adapters,
654            bloat,
655            activity,
656            pruned
657        );
658    }
659    println!("  {}", "─".repeat(118));
660
661    let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
662    let candidates = repos
663        .iter()
664        .filter(|r| matches!(r.reason, SkipReason::Candidate))
665        .count();
666    output::print_info(&format!(
667        "Total: {} repos  |  {} candidates  |  {} reclaimable",
668        repos.len(),
669        candidates,
670        format_bytes(total)
671    ));
672
673    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
674    // the folder's apparent size — the rest is hardlinked into the manager's store.
675    // Said once here rather than per row so the table stays scannable.
676    let shared: u64 = repos
677        .iter()
678        .flat_map(|r| &r.bloat_dirs)
679        .map(|b| b.shared_bytes)
680        .sum();
681    if shared > 0 {
682        output::print_info(&format!(
683            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
684             node_modules does not free those bytes, the store keeps them.",
685            format_bytes(shared)
686        ));
687    }
688}
689
690// ── Tests ─────────────────────────────────────────────────────────────────────
691
692#[cfg(test)]
693mod tests {
694    use ratatui::style::Color;
695
696    use crate::engine::SkipReason;
697
698    use super::reason_color;
699
700    #[test]
701    fn test_row_style_logic() {
702        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
703        assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
704        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
705        assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
706        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
707    }
708}