cargo-reclaim 0.2.0

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::io::{self, Write};

use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};

use super::CliError;
use super::cleanup_assistant::{
    CleanupAssistantAction, CleanupAssistantMode, CleanupAssistantPage, CleanupAssistantSelection,
    CleanupAssistantStartOptions, CleanupAssistantState,
};
use super::target_report::{TargetsReport, evidence_label, human_bytes};

struct TerminalGuard;

impl TerminalGuard {
    fn enter(stderr: &mut io::Stderr) -> Result<Self, CliError> {
        enable_raw_mode()?;
        execute!(stderr, EnterAlternateScreen, Hide)?;
        Ok(Self)
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let mut stderr = io::stderr();
        let _ = execute!(stderr, Show, LeaveAlternateScreen);
    }
}

pub(super) fn run_cleanup_terminal_assistant(
    report: &TargetsReport,
    start_options: CleanupAssistantStartOptions,
) -> Result<CleanupAssistantSelection, CliError> {
    let mut state = CleanupAssistantState::with_start_options(start_options)?;
    let mut stderr = io::stderr();
    let _guard = TerminalGuard::enter(&mut stderr)?;
    let mut events = CrosstermEventReader;

    run_cleanup_terminal_assistant_loop(&mut stderr, &mut events, report, &mut state)
}

fn run_cleanup_terminal_assistant_loop(
    writer: &mut impl Write,
    events: &mut impl TerminalEventReader,
    report: &TargetsReport,
    state: &mut CleanupAssistantState,
) -> Result<CleanupAssistantSelection, CliError> {
    loop {
        draw(writer, report, state)?;
        if state.page() == CleanupAssistantPage::Done {
            return Ok(state.selection(report));
        }

        let Some(event) = events.read_event()? else {
            return Err(CliError::Usage(
                "cleanup assistant input ended before selection completed".to_string(),
            ));
        };
        let Event::Key(key) = event else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }
        match key.code {
            KeyCode::Char('q') | KeyCode::Esc => state.cancel(),
            KeyCode::Up | KeyCode::Char('k') => state.move_up(),
            KeyCode::Down | KeyCode::Char('j') => state.move_down(),
            KeyCode::Char('a') => state.select_all_targets(),
            KeyCode::Char('n') => state.select_no_targets(),
            KeyCode::Char(' ') => state.choose_current(),
            KeyCode::Enter => {
                if state.page() != CleanupAssistantPage::Targets {
                    state.choose_current();
                }
                if let Err(error) = state.next_page() {
                    draw_error(writer, &error)?;
                    wait_for_keypress(events)?;
                }
            }
            KeyCode::Backspace | KeyCode::Left => state.previous_page(),
            _ => {}
        }
    }
}

trait TerminalEventReader {
    fn read_event(&mut self) -> Result<Option<Event>, CliError>;
}

struct CrosstermEventReader;

impl TerminalEventReader for CrosstermEventReader {
    fn read_event(&mut self) -> Result<Option<Event>, CliError> {
        Ok(Some(event::read()?))
    }
}

fn draw(
    writer: &mut impl Write,
    report: &TargetsReport,
    state: &CleanupAssistantState,
) -> Result<(), CliError> {
    execute!(writer, MoveTo(0, 0), Clear(ClearType::All))?;
    writeln!(writer, "cargo-reclaim cleanup assistant")?;
    writeln!(writer)?;
    match state.page() {
        CleanupAssistantPage::Targets => draw_targets(writer, report, state)?,
        CleanupAssistantPage::Mode => draw_mode(writer, state)?,
        CleanupAssistantPage::Action => draw_action(writer, state)?,
        CleanupAssistantPage::Done => {}
    }
    writeln!(writer)?;
    writeln!(
        writer,
        "Enter: continue/select  Space: toggle  Up/Down: move  Backspace: back  Esc/q: cancel"
    )?;
    if state.page() == CleanupAssistantPage::Targets {
        writeln!(writer, "a: select all  n: select none")?;
    }
    writer.flush()?;
    Ok(())
}

fn draw_targets(
    writer: &mut impl Write,
    report: &TargetsReport,
    state: &CleanupAssistantState,
) -> Result<(), CliError> {
    writeln!(writer, "Select target directories")?;
    writeln!(
        writer,
        "Targets: {} ({})",
        report.targets.len(),
        human_bytes(report.total_size_bytes)
    )?;
    writeln!(writer)?;
    for (index, target) in report.targets.iter().enumerate() {
        let cursor = if index == state.cursor() { ">" } else { " " };
        let selected = if state.selected()[index] {
            "[x]"
        } else {
            "[ ]"
        };
        writeln!(
            writer,
            "{cursor} {selected} {:>10} {:<18} {}",
            human_bytes(target.size_bytes),
            evidence_label(&target.evidence),
            target.path.display()
        )?;
    }
    Ok(())
}

fn draw_mode(writer: &mut impl Write, state: &CleanupAssistantState) -> Result<(), CliError> {
    writeln!(writer, "Choose cleanup mode")?;
    writeln!(writer)?;
    let modes = [
        (
            CleanupAssistantMode::SmartTrim,
            "Smart trim selected targets",
            "Planner-selected stale artifacts only",
        ),
        (
            CleanupAssistantMode::DeleteTarget,
            "Delete selected target directories",
            "Whole-target deletion",
        ),
    ];
    for (index, (mode, label, detail)) in modes.iter().enumerate() {
        let cursor = if index == state.cursor() { ">" } else { " " };
        let selected = if *mode == state.mode() {
            "(default)"
        } else {
            ""
        };
        writeln!(writer, "{cursor} {label:<34} {detail} {selected}")?;
    }
    Ok(())
}

fn draw_action(writer: &mut impl Write, state: &CleanupAssistantState) -> Result<(), CliError> {
    writeln!(writer, "Choose apply decision")?;
    writeln!(writer)?;
    let actions = [
        (CleanupAssistantAction::ValidateOnly, "Validate only"),
        (CleanupAssistantAction::Execute, "Execute"),
        (CleanupAssistantAction::Cancel, "Cancel"),
    ];
    for (index, (action, label)) in actions.iter().enumerate() {
        let cursor = if index == state.cursor() { ">" } else { " " };
        let selected = if *action == state.action() {
            "(default)"
        } else {
            ""
        };
        writeln!(writer, "{cursor} {label} {selected}")?;
    }
    Ok(())
}

fn draw_error(writer: &mut impl Write, error: &CliError) -> Result<(), CliError> {
    writeln!(writer)?;
    writeln!(writer, "{error}")?;
    writeln!(writer, "Press any key to continue.")?;
    writer.flush()?;
    Ok(())
}

fn wait_for_keypress(events: &mut impl TerminalEventReader) -> Result<(), CliError> {
    loop {
        let Some(event) = events.read_event()? else {
            return Err(CliError::Usage(
                "cleanup assistant input ended while waiting for keypress".to_string(),
            ));
        };
        if matches!(
            event,
            Event::Key(key) if key.kind == KeyEventKind::Press
        ) {
            return Ok(());
        }
    }
}

#[cfg(test)]
mod tests;