use std::io;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::prelude::*;
use ratatui::widgets::*;
use crate::constants;
use crate::engine::{RepoStatusEntry, SkipReason};
use crate::output::format_bytes;
use crate::tui::Tui;
enum ViewMode {
Browse,
PruneSelect,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SortKey {
Default,
Size,
Activity,
Name,
}
impl SortKey {
fn next(self) -> Self {
match self {
SortKey::Default => SortKey::Size,
SortKey::Size => SortKey::Activity,
SortKey::Activity => SortKey::Name,
SortKey::Name => SortKey::Default,
}
}
fn label(self) -> &'static str {
match self {
SortKey::Default => "relevance",
SortKey::Size => "size ↓",
SortKey::Activity => "idle longest ↓",
SortKey::Name => "name ↑",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Filter {
All,
Candidates,
WithBloat,
Problems,
}
impl Filter {
fn next(self) -> Self {
match self {
Filter::All => Filter::Candidates,
Filter::Candidates => Filter::WithBloat,
Filter::WithBloat => Filter::Problems,
Filter::Problems => Filter::All,
}
}
fn label(self) -> &'static str {
match self {
Filter::All => "all",
Filter::Candidates => "candidates",
Filter::WithBloat => "has bloat",
Filter::Problems => "problems",
}
}
fn accepts(self, repo: &RepoStatusEntry) -> bool {
match self {
Filter::All => true,
Filter::Candidates => matches!(repo.reason, SkipReason::Candidate),
Filter::WithBloat => repo.reclaimable_bytes > 0,
Filter::Problems => matches!(
repo.reason,
SkipReason::PathMissing | SkipReason::ConfigError(_)
),
}
}
}
struct StatusApp<'a> {
repos: &'a [RepoStatusEntry],
view: Vec<usize>,
table_state: TableState,
selected: Vec<bool>,
mode: ViewMode,
sort: SortKey,
filter: Filter,
search: String,
searching: bool,
confirmed_indices: Option<Vec<usize>>,
pub should_reload: bool,
}
impl<'a> StatusApp<'a> {
fn new(repos: &'a [RepoStatusEntry]) -> Self {
let selected = vec![false; repos.len()];
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut app = Self {
repos,
view: Vec::new(),
table_state,
selected,
mode: ViewMode::Browse,
sort: SortKey::Default,
filter: Filter::All,
search: String::new(),
searching: false,
confirmed_indices: None,
should_reload: false,
};
app.rebuild_view();
app
}
fn rebuild_view(&mut self) {
let anchor = self.cursor_repo();
let needle = self.search.to_lowercase();
let mut view: Vec<usize> = (0..self.repos.len())
.filter(|&i| {
let repo = &self.repos[i];
if !self.filter.accepts(repo) {
return false;
}
if needle.is_empty() {
return true;
}
repo.path.to_string_lossy().to_lowercase().contains(&needle)
|| repo
.adapters
.iter()
.any(|a| a.to_lowercase().contains(&needle))
})
.collect();
match self.sort {
SortKey::Default => {}
SortKey::Size => view.sort_by(|&a, &b| {
self.repos[b]
.reclaimable_bytes
.cmp(&self.repos[a].reclaimable_bytes)
.then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
}),
SortKey::Activity => view.sort_by(|&a, &b| {
let key = |i: usize| {
self.repos[i]
.last_activity
.map(|t| (0, t))
.unwrap_or((1, chrono::DateTime::<chrono::Utc>::MIN_UTC))
};
key(a)
.cmp(&key(b))
.then_with(|| self.repos[a].path.cmp(&self.repos[b].path))
}),
SortKey::Name => view.sort_by(|&a, &b| self.repos[a].path.cmp(&self.repos[b].path)),
}
self.view = view;
let row = anchor
.and_then(|repo_idx| self.view.iter().position(|&i| i == repo_idx))
.unwrap_or(0);
self.table_state
.select((!self.view.is_empty()).then(|| row.min(self.view.len() - 1)));
}
fn cursor_repo(&self) -> Option<usize> {
self.view.get(self.table_state.selected()?).copied()
}
fn move_up(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 {
self.view.len().saturating_sub(1)
} else {
i - 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
fn move_down(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i >= self.view.len().saturating_sub(1) {
0
} else {
i + 1
}
}
None => 0,
};
self.table_state.select(Some(i));
}
fn toggle_current(&mut self) {
if let Some(i) = self.cursor_repo() {
if matches!(self.repos[i].reason, SkipReason::Candidate) {
self.selected[i] = !self.selected[i];
}
}
}
fn toggle_all_candidates(&mut self) {
let visible: Vec<usize> = self
.view
.iter()
.copied()
.filter(|&i| matches!(self.repos[i].reason, SkipReason::Candidate))
.collect();
let any_selected = visible.iter().any(|&i| self.selected[i]);
for i in visible {
self.selected[i] = !any_selected;
}
}
fn confirm_prune(&mut self) {
let indices: Vec<usize> = self
.selected
.iter()
.enumerate()
.filter(|&(_, s)| *s)
.map(|(i, _)| i)
.collect();
self.confirmed_indices = Some(indices);
}
fn selected_bytes(&self) -> u64 {
self.repos
.iter()
.enumerate()
.filter(|(i, _)| self.selected[*i])
.map(|(_, r)| r.reclaimable_bytes)
.sum()
}
fn selected_count(&self) -> usize {
self.selected.iter().filter(|&&s| s).count()
}
fn candidate_count(&self) -> usize {
self.repos
.iter()
.filter(|r| matches!(r.reason, SkipReason::Candidate))
.count()
}
}
fn reclaimable_split(repos: &[RepoStatusEntry]) -> (u64, u64) {
let ready = repos
.iter()
.filter(|r| matches!(r.reason, SkipReason::Candidate))
.map(|r| r.reclaimable_bytes)
.sum();
let total = repos.iter().map(|r| r.reclaimable_bytes).sum();
(ready, total)
}
pub fn render_status_tui(
repos_loader: &dyn Fn() -> Vec<RepoStatusEntry>,
) -> Result<Option<Vec<PathBuf>>> {
loop {
let repos = repos_loader();
if repos.is_empty() {
return Ok(None);
}
let mut app = StatusApp::new(&repos);
{
let mut tui = Tui::new()?;
tui.drain_stale_input(Duration::from_millis(100));
run_status_loop(&mut tui.terminal, &mut app)?;
}
if app.should_reload {
continue;
}
return Ok(app
.confirmed_indices
.map(|indices| indices.into_iter().map(|i| repos[i].path.clone()).collect()));
}
}
fn run_status_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut StatusApp,
) -> Result<()> {
loop {
terminal.draw(|frame| render_ui(frame, app))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
{
if key.kind == KeyEventKind::Release {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
{
return Ok(());
}
if app.searching {
match key.code {
KeyCode::Char(c) => {
app.search.push(c);
app.rebuild_view();
}
KeyCode::Backspace => {
app.search.pop();
app.rebuild_view();
}
KeyCode::Enter => app.searching = false,
KeyCode::Esc => {
app.searching = false;
app.search.clear();
app.rebuild_view();
}
_ => {}
}
continue;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => app.move_up(),
KeyCode::Down | KeyCode::Char('j') => app.move_down(),
KeyCode::Home | KeyCode::Char('g') => app.table_state.select(Some(0)),
KeyCode::End | KeyCode::Char('G') => {
app.table_state
.select(Some(app.view.len().saturating_sub(1)));
}
KeyCode::PageUp => {
let i = app.table_state.selected().unwrap_or(0).saturating_sub(10);
app.table_state.select(Some(i));
}
KeyCode::PageDown => {
let i = (app.table_state.selected().unwrap_or(0) + 10)
.min(app.view.len().saturating_sub(1));
app.table_state.select(Some(i));
}
KeyCode::Char('s') | KeyCode::Char('S') => {
app.sort = app.sort.next();
app.rebuild_view();
}
KeyCode::Char('f') | KeyCode::Char('F') => {
app.filter = app.filter.next();
app.rebuild_view();
}
KeyCode::Char('/') => app.searching = true,
KeyCode::Char(' ') => {
if matches!(app.mode, ViewMode::PruneSelect) {
app.toggle_current();
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
if matches!(app.mode, ViewMode::PruneSelect) {
app.toggle_all_candidates();
}
}
KeyCode::Char('p') | KeyCode::Char('P') => {
app.mode = ViewMode::PruneSelect;
for i in app.view.clone() {
if matches!(app.repos[i].reason, SkipReason::Candidate) {
app.selected[i] = true;
}
}
}
KeyCode::Char('i') | KeyCode::Char('I') => {
if let Some(idx) = app.cursor_repo() {
let repo = &app.repos[idx];
if matches!(repo.reason, SkipReason::PathMissing) {
continue;
}
let mut per_repo =
crate::config::PerRepoConfig::load_with_diagnostics(&repo.path)
.map_err(|e| anyhow::anyhow!(e))
.with_context(|| {
format!(
"Could not toggle ignore for {}",
crate::output::clean_path(&repo.path)
)
})?
.unwrap_or_default();
per_repo.ignore = !per_repo.ignore;
per_repo.save_to_repo(&repo.path).with_context(|| {
format!(
"Could not write the config for {}",
crate::output::clean_path(&repo.path)
)
})?;
let legacy_ignore = repo.path.join(crate::constants::DEVPRUNE_IGNORE_FILE);
if legacy_ignore.exists() {
std::fs::remove_file(&legacy_ignore).with_context(|| {
format!(
"Could not remove {}",
crate::output::clean_path(&legacy_ignore)
)
})?;
}
app.should_reload = true;
return Ok(());
}
}
KeyCode::Enter => {
if matches!(app.mode, ViewMode::PruneSelect) && app.selected_count() > 0 {
app.confirm_prune();
return Ok(());
}
}
KeyCode::Esc => {
if matches!(app.mode, ViewMode::PruneSelect) {
app.mode = ViewMode::Browse;
app.selected.fill(false);
} else {
return Ok(());
}
}
KeyCode::Char('q') => return Ok(()),
_ => {}
}
}
}
}
fn reason_color(reason: &SkipReason) -> Color {
match reason {
SkipReason::Candidate => Color::Green,
SkipReason::Active => Color::Reset,
SkipReason::Ignored | SkipReason::NoBloat => Color::DarkGray,
SkipReason::PathMissing => Color::Red,
SkipReason::ConfigError(_) => Color::Yellow,
}
}
fn render_ui(frame: &mut Frame, app: &mut StatusApp) {
let is_prune_mode = matches!(app.mode, ViewMode::PruneSelect);
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(7), ])
.split(frame.area());
let mode_label = if is_prune_mode {
Span::styled(
" PRUNE-SELECT MODE ",
Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(
" BROWSE MODE ",
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
};
let (ready, total) = reclaimable_split(app.repos);
let header_line = Line::from(vec![
Span::styled(
" dev-prune ",
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
mode_label,
Span::styled(
format!(
" {} | {} candidates | ",
if app.view.len() == app.repos.len() {
format!("{} repos", app.repos.len())
} else {
format!("showing {} of {} repos", app.view.len(), app.repos.len())
},
app.candidate_count(),
),
Style::default().fg(Color::DarkGray),
),
Span::styled(
format!("{} ready now", format_bytes(ready)),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" | {} reclaimable in all", format_bytes(total)),
Style::default().fg(Color::DarkGray),
),
]);
let header_widget =
Paragraph::new(header_line).block(Block::default().borders(Borders::ALL).border_style(
Style::default().fg(if is_prune_mode {
Color::Yellow
} else {
Color::Cyan
}),
));
frame.render_widget(header_widget, outer[0]);
let col_headers = Row::new(vec![
Cell::from(if is_prune_mode { "Sel" } else { "#" })
.style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Repository").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Status / Reason").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Adapters").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Bloat").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Last Activity").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Last Pruned").style(Style::default().add_modifier(Modifier::BOLD)),
])
.height(1)
.bottom_margin(1)
.style(Style::default().bg(Color::Rgb(20, 25, 40)).fg(Color::White));
let highlighted_row = app.table_state.selected();
let all_paths: Vec<_> = app.repos.iter().map(|r| r.path.clone()).collect();
let rows: Vec<Row> = app
.view
.iter()
.enumerate()
.map(|(row, &i)| {
let repo = &app.repos[i];
let is_selected = app.selected[i];
let color = reason_color(&repo.reason);
let sel_cell = if is_prune_mode {
if matches!(repo.reason, SkipReason::Candidate) {
if is_selected {
Cell::from("[x]").style(
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
Cell::from("[ ]").style(Style::default().fg(Color::DarkGray))
}
} else {
Cell::from(" — ").style(Style::default().fg(Color::DarkGray))
}
} else {
Cell::from(format!("{}", row + 1)).style(Style::default().fg(Color::DarkGray))
};
let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
let reason_str = repo.reason.to_string();
let adapters_str = if repo.adapters.is_empty() {
"—".to_string()
} else {
repo.adapters.join(", ")
};
let bloat_str = if repo.reclaimable_bytes > 0 {
format_bytes(repo.reclaimable_bytes)
} else {
"—".to_string()
};
let bloat_color = if repo.reclaimable_bytes > 0 {
Color::Green
} else {
Color::DarkGray
};
let activity_str = repo
.last_activity
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "—".to_string());
let pruned_str = repo
.entry
.last_pruned_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "Never".to_string());
let row_style = if is_selected {
Style::default().bg(Color::Rgb(20, 50, 30))
} else {
Style::default()
};
let on_dark_bg = is_selected || highlighted_row == Some(i);
let path_style = if on_dark_bg {
Style::default().fg(Color::White)
} else {
Style::default()
};
let date_color = if on_dark_bg {
Color::Gray
} else {
Color::DarkGray
};
Row::new(vec![
sel_cell,
Cell::from(path_str).style(path_style),
Cell::from(reason_str).style(Style::default().fg(color)),
Cell::from(adapters_str),
Cell::from(bloat_str).style(Style::default().fg(bloat_color)),
Cell::from(activity_str).style(Style::default().fg(date_color)),
Cell::from(pruned_str).style(Style::default().fg(date_color)),
])
.style(row_style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4), Constraint::Min(24), Constraint::Length(22), Constraint::Length(16), Constraint::Length(11), Constraint::Length(13), Constraint::Length(13), ],
)
.header(col_headers)
.block(
Block::default()
.title(" Registered Repositories ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.row_highlight_style(
Style::default()
.bg(Color::Rgb(30, 40, 70))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
frame.render_stateful_widget(table, outer[1], &mut app.table_state);
let mut footer_lines = if is_prune_mode {
vec![
Line::from(vec![
Span::styled("Selected: ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(
"{} of {} candidates ",
app.selected_count(),
app.candidate_count()
),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", format_bytes(app.selected_bytes())),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate "),
Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" Jump "),
Span::styled("[Space]", Style::default().fg(Color::Cyan)),
Span::raw(" Toggle "),
Span::styled("[a]", Style::default().fg(Color::Cyan)),
Span::raw(" Toggle All "),
Span::styled(
"[Enter]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Prune Selected "),
Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
Span::raw(" Back to Browse "),
Span::styled("[q]", Style::default().fg(Color::Cyan)),
Span::raw(" Quit"),
]),
Line::from(vec![]),
]
} else {
vec![
Line::from(vec![
Span::styled("Legend: ", Style::default().fg(Color::DarkGray)),
Span::styled("■ Candidate", Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled("■ Active", Style::default().fg(Color::Reset)),
Span::raw(" "),
Span::styled("■ Ignored / No Bloat", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("■ Path Missing", Style::default().fg(Color::Red)),
]),
Line::from(vec![
Span::styled("[↑/↓/j/k]", Style::default().fg(Color::Cyan)),
Span::raw(" Navigate "),
Span::styled("[PgUp/PgDn/g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" Jump "),
Span::styled(
"[p]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Prune-Select Mode "),
Span::styled(
"[i]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Toggle Ignore "),
Span::styled("[q/Esc/Ctrl-C]", Style::default().fg(Color::Cyan)),
Span::raw(" Quit"),
]),
Line::from(vec![
Span::styled(
"[i] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"toggles `ignore` in `.devprune.json` (kept out of `git status` via `.git/info/exclude`) — refreshes instantly.",
Style::default().fg(Color::DarkGray),
),
]),
]
};
let mut state_line = vec![
Span::styled(
"[s]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Sort: "),
Span::styled(app.sort.label(), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(
"[f]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Filter: "),
Span::styled(app.filter.label(), Style::default().fg(Color::Green)),
Span::raw(" "),
Span::styled(
"[/]",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Search: "),
];
if app.searching {
state_line.push(Span::styled(
format!("{}█", app.search),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
state_line.push(Span::styled(
" (Enter to keep, Esc to clear)",
Style::default().fg(Color::DarkGray),
));
} else if app.search.is_empty() {
state_line.push(Span::styled("—", Style::default().fg(Color::DarkGray)));
} else {
state_line.push(Span::styled(
app.search.clone(),
Style::default().fg(Color::Yellow),
));
}
if app.view.is_empty() {
state_line.push(Span::styled(
" no repositories match",
Style::default().fg(Color::Red),
));
}
footer_lines.push(Line::from(state_line));
footer_lines.push(Line::from(Span::styled(
constants::ATTRIBUTION_LINE,
Style::default().fg(Color::DarkGray),
)));
let footer =
Paragraph::new(footer_lines).block(Block::default().borders(Borders::ALL).border_style(
Style::default().fg(if is_prune_mode {
Color::Yellow
} else {
Color::Green
}),
));
frame.render_widget(footer, outer[2]);
}
pub fn render_status_plain(repos: &[RepoStatusEntry]) {
use crate::output;
use colored::Colorize;
output::print_header("dev-prune status");
println!(
"\n {:>3} {} {:<22} {:<12} {:<12} {:<13} {:<13}",
"#",
output::pad_display("Repository", 35),
"Status / Reason",
"Adapters",
"Bloat",
"Last Activity",
"Last Pruned"
);
println!(" {}", "─".repeat(122));
let all_paths: Vec<_> = repos.iter().map(|r| r.path.clone()).collect();
for (i, repo) in repos.iter().enumerate() {
let path_str = crate::engine::compute_display_name(&repo.path, &all_paths);
let reason = repo.reason.to_string();
let adapters = if repo.adapters.is_empty() {
"—".to_string()
} else {
repo.adapters.join("+")
};
let bloat = if repo.reclaimable_bytes > 0 {
format_bytes(repo.reclaimable_bytes)
} else {
"—".to_string()
};
let activity = repo
.last_activity
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "—".to_string());
let pruned = repo
.entry
.last_pruned_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "Never".to_string());
let reason_cell = format!("{reason:<22}");
let reason_cell = match &repo.reason {
SkipReason::Candidate => Colorize::green(reason_cell.as_str()).to_string(),
SkipReason::Active => reason_cell,
SkipReason::Ignored | SkipReason::NoBloat => {
Colorize::dimmed(reason_cell.as_str()).to_string()
}
SkipReason::PathMissing => Colorize::red(reason_cell.as_str()).to_string(),
SkipReason::ConfigError(_) => Colorize::yellow(reason_cell.as_str()).to_string(),
};
let bloat_cell = if repo.reclaimable_bytes > 0 {
Colorize::green(format!("{bloat:<12}").as_str()).to_string()
} else {
format!("{bloat:<12}")
};
println!(
" {:>3} {} {} {:<12} {} {:<13} {:<13}",
i + 1,
output::pad_display(&path_str, 35),
reason_cell,
adapters,
bloat_cell,
activity,
pruned
);
}
println!(" {}", "─".repeat(122));
let (ready, total) = reclaimable_split(repos);
let candidates = repos
.iter()
.filter(|r| matches!(r.reason, SkipReason::Candidate))
.count();
output::print_info(&format!(
"Total: {} repos | {} candidates | {} ready now | {} reclaimable in all",
repos.len(),
candidates,
output::format_bytes_styled(ready),
output::format_bytes(total)
));
let shared: u64 = repos
.iter()
.flat_map(|r| &r.bloat_dirs)
.map(|b| b.shared_bytes)
.sum();
if shared > 0 {
output::print_info(&format!(
"Excluded: {} hardlinked into package-manager stores (pnpm/bun) — deleting \
node_modules does not free those bytes, the store keeps them.",
format_bytes(shared)
));
}
}
#[cfg(test)]
mod tests {
use ratatui::style::Color;
use crate::engine::SkipReason;
use super::*;
use crate::engine::RepoStatusEntry;
#[test]
fn test_row_style_logic() {
assert_eq!(reason_color(&SkipReason::Candidate), Color::Green);
assert_eq!(reason_color(&SkipReason::Active), Color::Reset);
assert_eq!(reason_color(&SkipReason::Ignored), Color::DarkGray); assert_eq!(reason_color(&SkipReason::NoBloat), Color::DarkGray);
assert_eq!(reason_color(&SkipReason::PathMissing), Color::Red);
assert_eq!(
reason_color(&SkipReason::ConfigError(String::new())),
Color::Yellow
);
}
fn entry(path: &str, reason: SkipReason, bytes: u64, days_idle: i64) -> RepoStatusEntry {
RepoStatusEntry {
path: std::path::PathBuf::from(path),
entry: crate::config::RepoEntry::new(),
reason,
adapters: vec!["uv".to_string()],
bloat_dirs: Vec::new(),
reclaimable_bytes: bytes,
reclaimable_by_adapter: Vec::new(),
last_activity: Some(chrono::Utc::now() - chrono::Duration::days(days_idle)),
idle_days: 30,
}
}
fn sample() -> Vec<RepoStatusEntry> {
vec![
entry("/code/alpha", SkipReason::Candidate, 500, 90),
entry("/code/beta", SkipReason::Active, 9_000, 1),
entry("/code/gamma", SkipReason::PathMissing, 0, 400),
entry("/code/delta", SkipReason::Candidate, 2_000, 40),
]
}
#[test]
fn the_default_view_is_every_row_in_the_order_it_arrived() {
let repos = sample();
let app = StatusApp::new(&repos);
assert_eq!(app.view, vec![0, 1, 2, 3]);
}
#[test]
fn sorting_by_size_puts_the_biggest_reclaim_first() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.sort = SortKey::Size;
app.rebuild_view();
assert_eq!(app.view, vec![1, 3, 0, 2]);
}
#[test]
fn sorting_by_activity_puts_the_longest_untouched_first() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.sort = SortKey::Activity;
app.rebuild_view();
assert_eq!(app.view, vec![2, 0, 3, 1]);
}
#[test]
fn filters_narrow_the_view_without_disturbing_the_selection() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.mode = ViewMode::PruneSelect;
app.selected[3] = true;
app.filter = Filter::Candidates;
app.rebuild_view();
assert_eq!(app.view, vec![0, 3]);
app.filter = Filter::Problems;
app.rebuild_view();
assert_eq!(app.view, vec![2]);
assert!(app.selected[3]);
assert_eq!(app.selected_count(), 1);
}
#[test]
fn a_search_matches_the_path_and_the_adapters() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.search = "ELT".to_string();
app.rebuild_view();
assert_eq!(app.view, vec![3], "the match is case-insensitive");
app.search = "uv".to_string();
app.rebuild_view();
assert_eq!(app.view, vec![0, 1, 2, 3], "every sample repo reports uv");
app.search = "nothing-matches-this".to_string();
app.rebuild_view();
assert!(app.view.is_empty());
assert_eq!(app.cursor_repo(), None);
}
#[test]
fn the_cursor_follows_its_repository_through_a_filter() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.table_state.select(Some(3)); assert_eq!(app.cursor_repo(), Some(3));
app.filter = Filter::Candidates;
app.rebuild_view();
assert_eq!(app.cursor_repo(), Some(3));
}
#[test]
fn toggle_all_is_scoped_to_what_is_on_screen() {
let repos = sample();
let mut app = StatusApp::new(&repos);
app.search = "alpha".to_string();
app.rebuild_view();
app.toggle_all_candidates();
assert!(app.selected[0]);
assert!(
!app.selected[3],
"delta was filtered out and must not be armed"
);
}
#[test]
fn the_header_separates_what_is_prunable_now_from_everything() {
let repos = sample();
let (ready, total) = reclaimable_split(&repos);
assert_eq!(ready, 2_500, "alpha + delta, the two candidates");
assert_eq!(total, 11_500, "every registered repository");
}
#[test]
fn a_filter_never_changes_the_header_totals() {
let repos = sample();
let mut app = StatusApp::new(&repos);
let before = reclaimable_split(app.repos);
app.filter = Filter::Problems;
app.rebuild_view();
assert_eq!(app.view.len(), 1);
assert_eq!(reclaimable_split(app.repos), before);
}
}