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    // The bar's background is a fixed dark navy, so the foreground must be fixed
403    // too: on a light-theme terminal the default foreground is near-black and
404    // vanishes into it.
405    .style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
406
407    // Rows sitting on one of the fixed dark backgrounds (selection green, highlight
408    // blue) need explicitly light text; everywhere else the terminal's own default
409    // foreground is the only colour guaranteed readable on both light and dark themes.
410    let highlighted_row = app.table_state.selected();
411
412    let rows: Vec<Row> = app
413        .repos
414        .iter()
415        .enumerate()
416        .map(|(i, repo)| {
417            let is_selected = app.selected[i];
418            let color = reason_color(&repo.reason);
419
420            let sel_cell = if is_prune_mode {
421                if matches!(repo.reason, SkipReason::Candidate) {
422                    if is_selected {
423                        Cell::from("[x]").style(
424                            Style::default()
425                                .fg(Color::Green)
426                                .add_modifier(Modifier::BOLD),
427                        )
428                    } else {
429                        Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
430                    }
431                } else {
432                    Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
433                }
434            } else {
435                Cell::from(format!("{}", i + 1)).style(Style::default().fg(Color::DarkGray))
436            };
437
438            let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
439            let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
440
441            let reason_str = repo.reason.to_string();
442            let adapters_str = if repo.adapters.is_empty() {
443                "—".to_string()
444            } else {
445                repo.adapters.join(", ")
446            };
447            let bloat_str = if repo.reclaimable_bytes > 0 {
448                format_bytes(repo.reclaimable_bytes)
449            } else {
450                "—".to_string()
451            };
452            let activity_str = repo
453                .last_activity
454                .map(|d| d.format("%Y-%m-%d").to_string())
455                .unwrap_or_else(|| "—".to_string());
456            let pruned_str = repo
457                .entry
458                .last_pruned_at
459                .map(|d| d.format("%Y-%m-%d").to_string())
460                .unwrap_or_else(|| "Never".to_string());
461
462            let row_style = if is_selected {
463                Style::default().bg(Color::Rgb(20, 50, 30))
464            } else {
465                Style::default()
466            };
467
468            let on_dark_bg = is_selected || highlighted_row == Some(i);
469            let path_style = if on_dark_bg {
470                Style::default().fg(Color::White)
471            } else {
472                Style::default()
473            };
474            let date_color = if on_dark_bg {
475                Color::Gray
476            } else {
477                Color::DarkGray
478            };
479
480            Row::new(vec![
481                sel_cell,
482                Cell::from(path_str).style(path_style),
483                Cell::from(reason_str).style(Style::default().fg(color)),
484                Cell::from(adapters_str).style(Style::default().fg(Color::Magenta)),
485                Cell::from(bloat_str).style(Style::default().fg(Color::Cyan)),
486                Cell::from(activity_str).style(Style::default().fg(date_color)),
487                Cell::from(pruned_str).style(Style::default().fg(date_color)),
488            ])
489            .style(row_style)
490        })
491        .collect();
492
493    let table = Table::new(
494        rows,
495        [
496            Constraint::Length(4),  // sel/#
497            Constraint::Min(24),    // path
498            Constraint::Length(22), // reason
499            Constraint::Length(16), // adapters
500            Constraint::Length(11), // bloat
501            Constraint::Length(13), // last activity
502            Constraint::Length(13), // last pruned
503        ],
504    )
505    .header(col_headers)
506    .block(
507        Block::default()
508            .title(" Registered Repositories ")
509            .borders(Borders::ALL)
510            .border_style(Style::default().fg(Color::DarkGray)),
511    )
512    .row_highlight_style(
513        Style::default()
514            .bg(Color::Rgb(30, 40, 70))
515            .add_modifier(Modifier::BOLD),
516    )
517    .highlight_symbol("▶ ");
518
519    frame.render_stateful_widget(table, outer[1], &mut app.table_state.clone());
520
521    // ── Footer ────────────────────────────────────────────────────────────────
522    let mut footer_lines = if is_prune_mode {
523        vec![
524            Line::from(vec![
525                Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
526                Span::styled(
527                    format!(
528                        "{} of {} candidates  ({})",
529                        app.selected_count(),
530                        app.candidate_count(),
531                        format_bytes(app.selected_bytes())
532                    ),
533                    Style::default()
534                        .fg(Color::Yellow)
535                        .add_modifier(Modifier::BOLD),
536                ),
537            ]),
538            Line::from(vec![
539                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
540                Span::raw(" Navigate  "),
541                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
542                Span::raw(" Jump  "),
543                Span::styled("[Space]", Style::default().fg(Color::Cyan)),
544                Span::raw(" Toggle  "),
545                Span::styled("[a]", Style::default().fg(Color::Cyan)),
546                Span::raw(" Toggle All  "),
547                Span::styled(
548                    "[Enter]",
549                    Style::default()
550                        .fg(Color::Green)
551                        .add_modifier(Modifier::BOLD),
552                ),
553                Span::raw(" Prune Selected  "),
554                Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
555                Span::raw(" Back to Browse  "),
556                Span::styled("[q]", Style::default().fg(Color::Red)),
557                Span::raw(" Quit"),
558            ]),
559            Line::from(vec![]),
560        ]
561    } else {
562        vec![
563            Line::from(vec![
564                Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
565                Span::styled("■ Candidate", Style::default().fg(Color::Green)),
566                Span::raw("  "),
567                Span::styled("■ Active", Style::default().fg(Color::Cyan)),
568                Span::raw("  "),
569                Span::styled("■ No Bloat", Style::default().fg(Color::Blue)),
570                Span::raw("  "),
571                Span::styled("■ Ignored", Style::default().fg(Color::DarkGray)),
572                Span::raw("  "),
573                Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
574            ]),
575            Line::from(vec![
576                Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
577                Span::raw(" Navigate  "),
578                Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
579                Span::raw(" Jump  "),
580                Span::styled(
581                    "[p]",
582                    Style::default()
583                        .fg(Color::Yellow)
584                        .add_modifier(Modifier::BOLD),
585                ),
586                Span::raw(" Prune-Select Mode  "),
587                Span::styled(
588                    "[i]",
589                    Style::default()
590                        .fg(Color::Magenta)
591                        .add_modifier(Modifier::BOLD),
592                ),
593                Span::raw(" Toggle Ignore  "),
594                Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Red)),
595                Span::raw(" Quit"),
596            ]),
597            Line::from(vec![
598                Span::styled(
599                    "[i] ",
600                    Style::default()
601                        .fg(Color::Magenta)
602                        .add_modifier(Modifier::BOLD),
603                ),
604                Span::styled(
605                    "toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
606                    Style::default().fg(Color::DarkGray),
607                ),
608            ]),
609        ]
610    };
611
612    // The credit, on both footers, in the dimmest colour the theme has. It is one
613    // constant and one push — a fork that does not want it deletes these two lines and
614    // nothing else in the binary cares.
615    footer_lines.push(Line::from(Span::styled(
616        constants::ATTRIBUTION_LINE,
617        Style::default().fg(Color::DarkGray),
618    )));
619
620    let footer =
621        Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
622            Style::default().fg(if is_prune_mode {
623                Color::Yellow
624            } else {
625                Color::Green
626            }),
627        ));
628    frame.render_widget(footer, outer[2]);
629}
630
631// ── Plain-text fallback ──────────────────────────────────────────────────────
632
633/// Plain text fallback rendering for non-TUI environments.
634pub fn render_status_plain(repos: &[RepoStatusEntry]) {
635    use crate::output;
636
637    output::print_header("dev-prune status");
638    println!(
639        "\n  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
640        "#", "Repository", "Status / Reason", "Adapters", "Bloat", "Last Activity", "Last Pruned"
641    );
642    println!("  {}", "─".repeat(118));
643
644    let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
645    for (i, repo) in repos.iter().enumerate() {
646        let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
647        let reason = repo.reason.to_string();
648        let adapters = if repo.adapters.is_empty() {
649            "—".to_string()
650        } else {
651            repo.adapters.join("+")
652        };
653        let bloat = if repo.reclaimable_bytes > 0 {
654            format_bytes(repo.reclaimable_bytes)
655        } else {
656            "—".to_string()
657        };
658        let activity = repo
659            .last_activity
660            .map(|d| d.format("%Y-%m-%d").to_string())
661            .unwrap_or_else(|| "—".to_string());
662        let pruned = repo
663            .entry
664            .last_pruned_at
665            .map(|d| d.format("%Y-%m-%d").to_string())
666            .unwrap_or_else(|| "Never".to_string());
667
668        println!(
669            "  {:>3}  {:<35}  {:<22}  {:<12}  {:<12}  {:<13}  {:<13}",
670            i + 1,
671            path_str,
672            reason,
673            adapters,
674            bloat,
675            activity,
676            pruned
677        );
678    }
679    println!("  {}", "─".repeat(118));
680
681    let total: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
682    let candidates = repos
683        .iter()
684        .filter(|r| matches!(r.reason, SkipReason::Candidate))
685        .count();
686    output::print_info(&format!(
687        "Total: {} repos  |  {} candidates  |  {} reclaimable",
688        repos.len(),
689        candidates,
690        format_bytes(total)
691    ));
692
693    // Reclaimable is what a prune actually frees, which for pnpm and bun is less than
694    // the folder's apparent size — the rest is hardlinked into the manager's store.
695    // Said once here rather than per row so the table stays scannable.
696    let shared: u64 = repos
697        .iter()
698        .flat_map(|r| &r.bloat_dirs)
699        .map(|b| b.shared_bytes)
700        .sum();
701    if shared > 0 {
702        output::print_info(&format!(
703            "Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
704             node_modules does not free those bytes, the store keeps them.",
705            format_bytes(shared)
706        ));
707    }
708}
709
710// ── Tests ─────────────────────────────────────────────────────────────────────
711
712#[cfg(test)]
713mod tests {
714    use ratatui::style::Color;
715
716    use crate::engine::SkipReason;
717
718    use super::reason_color;
719
720    #[test]
721    fn test_row_style_logic() {
722        assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
723        assert_eq!(reason_color(&SkipReason::Active), Color::Cyan);
724        assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); // merged Disabled+Ignored
725        assert_eq!(reason_color(&SkipReason::NoBloat), Color::Blue);
726        assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
727    }
728}