cargo-reclaim 0.2.1

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

use cargo_reclaim::ScannerOptions;

use super::super::cleanup_assistant::{
    CleanupAssistantAction, CleanupAssistantMode, CleanupAssistantPage,
    CleanupAssistantStartOptions,
};
use super::super::cleanup_terminal::run_cleanup_terminal_assistant;
use super::super::target_report::{TargetsDiscovery, build_targets_report, normalize_for_dedupe};
use super::super::{CliError, OutputFormat};
use super::CleanupCommand;

#[derive(Debug, Clone)]
pub(super) struct ResolvedCleanupCommand {
    pub(super) command: CleanupCommand,
    pub(super) cancelled: bool,
}

pub(super) fn resolve_cleanup_assistant(
    command: &CleanupCommand,
) -> Result<ResolvedCleanupCommand, CliError> {
    let decision = cleanup_interaction_decision(command, cleanup_assistant_tty_available());
    let CleanupInteractionDecision::Assistant(request) = decision else {
        if matches!(decision, CleanupInteractionDecision::UsageError) {
            return Err(no_cleanup_selector_error());
        }
        return Ok(ResolvedCleanupCommand {
            command: command.clone(),
            cancelled: false,
        });
    };

    let report = build_cleanup_assistant_targets_report(command, request.target_selection)?;
    if report.targets.is_empty() {
        return Err(CliError::Usage(
            "cleanup found no target directories to select".to_string(),
        ));
    }
    let start_options = cleanup_assistant_start_options(command, &report, request)?;
    let selection = run_cleanup_terminal_assistant(&report, start_options)?;
    if selection.action == CleanupAssistantAction::Cancel {
        return Ok(ResolvedCleanupCommand {
            command: command.clone(),
            cancelled: true,
        });
    }

    let mut resolved = command.clone();
    resolved.prompt_selector = false;
    if command.all && !selection.target_selection_modified {
        resolved.selected_targets.clear();
    } else {
        resolved.selected_targets = selection.targets;
        resolved.all = false;
    }
    resolved.delete_target = selection.mode == CleanupAssistantMode::DeleteTarget;
    resolved.execute = selection.action == CleanupAssistantAction::Execute;
    resolved.validate_only = selection.action == CleanupAssistantAction::ValidateOnly;
    resolved.interactive_selection_modified = selection.target_selection_modified;
    Ok(ResolvedCleanupCommand {
        command: resolved,
        cancelled: false,
    })
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CleanupInteractionDecision {
    NonInteractive,
    UsageError,
    Assistant(CleanupAssistantRequest),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CleanupAssistantRequest {
    target_selection: CleanupAssistantTargetSelection,
    first_page: CleanupAssistantPage,
    minimum_page: CleanupAssistantPage,
    forced_mode: Option<CleanupAssistantMode>,
    forced_action: Option<CleanupAssistantAction>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CleanupAssistantTargetSelection {
    User,
    Explicit,
    All,
}

fn cleanup_interaction_decision(
    command: &CleanupCommand,
    tty_available: bool,
) -> CleanupInteractionDecision {
    let has_selector = command.all || !command.selected_targets.is_empty();
    if command.output_format != OutputFormat::Terminal || !tty_available {
        return if has_selector {
            CleanupInteractionDecision::NonInteractive
        } else {
            CleanupInteractionDecision::UsageError
        };
    }

    let forced_action = cleanup_forced_action(command);
    if has_selector && forced_action.is_some() {
        return CleanupInteractionDecision::NonInteractive;
    }

    let forced_mode = command
        .delete_target
        .then_some(CleanupAssistantMode::DeleteTarget);
    let target_selection = if command.all {
        CleanupAssistantTargetSelection::All
    } else if command.selected_targets.is_empty() {
        CleanupAssistantTargetSelection::User
    } else {
        CleanupAssistantTargetSelection::Explicit
    };
    let (first_page, minimum_page) = if has_selector && command.delete_target {
        (CleanupAssistantPage::Action, CleanupAssistantPage::Action)
    } else if has_selector {
        (CleanupAssistantPage::Mode, CleanupAssistantPage::Mode)
    } else {
        (CleanupAssistantPage::Targets, CleanupAssistantPage::Targets)
    };

    CleanupInteractionDecision::Assistant(CleanupAssistantRequest {
        target_selection,
        first_page,
        minimum_page,
        forced_mode,
        forced_action,
    })
}

fn cleanup_forced_action(command: &CleanupCommand) -> Option<CleanupAssistantAction> {
    if command.execute {
        Some(CleanupAssistantAction::Execute)
    } else if command.validate_only {
        Some(CleanupAssistantAction::ValidateOnly)
    } else {
        None
    }
}

fn build_cleanup_assistant_targets_report(
    command: &CleanupCommand,
    target_selection: CleanupAssistantTargetSelection,
) -> Result<super::super::target_report::TargetsReport, CliError> {
    let (roots, scanner_options) = match target_selection {
        CleanupAssistantTargetSelection::Explicit => {
            let mut roots = command.roots.clone();
            roots.extend(command.selected_targets.iter().cloned());
            (roots, explicit_target_scanner_options(command))
        }
        CleanupAssistantTargetSelection::User | CleanupAssistantTargetSelection::All => {
            (command.roots.clone(), command.scanner_options.clone())
        }
    };
    build_targets_report(&TargetsDiscovery::new(
        roots,
        scanner_options,
        command.inventory_options.clone(),
        command.config_path.clone(),
        command.config_version,
    ))
}

fn cleanup_assistant_start_options(
    command: &CleanupCommand,
    report: &super::super::target_report::TargetsReport,
    request: CleanupAssistantRequest,
) -> Result<CleanupAssistantStartOptions, CliError> {
    let selected = match request.target_selection {
        CleanupAssistantTargetSelection::User => vec![false; report.targets.len()],
        CleanupAssistantTargetSelection::All => vec![true; report.targets.len()],
        CleanupAssistantTargetSelection::Explicit => explicit_target_selection(command, report)?,
    };
    Ok(CleanupAssistantStartOptions {
        selected,
        first_page: request.first_page,
        minimum_page: request.minimum_page,
        forced_mode: request.forced_mode,
        forced_action: request.forced_action,
    })
}

fn explicit_target_selection(
    command: &CleanupCommand,
    report: &super::super::target_report::TargetsReport,
) -> Result<Vec<bool>, CliError> {
    let selected_targets = command
        .selected_targets
        .iter()
        .map(|path| normalize_for_dedupe(path))
        .collect::<HashSet<_>>();
    let selected = report
        .targets
        .iter()
        .map(|target| selected_targets.contains(&normalize_for_dedupe(&target.path)))
        .collect::<Vec<_>>();
    let matched_count = selected.iter().filter(|selected| **selected).count();
    if matched_count == selected_targets.len() {
        Ok(selected)
    } else {
        Err(CliError::Usage(
            "selected target was not discovered; pass a root that contains it or the target path itself".to_string(),
        ))
    }
}

fn cleanup_assistant_tty_available() -> bool {
    io::stdin().is_terminal() && io::stdout().is_terminal() && io::stderr().is_terminal()
}

fn no_cleanup_selector_error() -> CliError {
    CliError::Usage(
        "cleanup requires a selector: pass --all or --target <path>, or run no-selector cleanup from an interactive terminal".to_string(),
    )
}

fn explicit_target_scanner_options(command: &CleanupCommand) -> ScannerOptions {
    let mut scanner_options = command.scanner_options.clone();
    scanner_options.allow_name_only_targets = true;
    scanner_options
}

#[cfg(test)]
mod tests {
    use super::*;
    use cargo_reclaim::{InventoryOptions, PlannerOptions, PolicyKind, WholeTargetMode};
    use std::path::PathBuf;

    fn command_fixture() -> CleanupCommand {
        let scanner_options = ScannerOptions::default();
        CleanupCommand {
            roots: vec![PathBuf::from(".")],
            selected_targets: Vec::new(),
            all: false,
            delete_target: false,
            execute: false,
            validate_only: false,
            prompt_selector: true,
            interactive_selection_modified: false,
            output_format: OutputFormat::Terminal,
            policy: PolicyKind::Balanced,
            inventory_options: InventoryOptions {
                follow_symlinks: scanner_options.follow_symlinks,
                skipped_paths: scanner_options.skipped_paths.clone(),
                deep_target_scan: false,
                deep_directory_measurement: true,
            },
            scanner_options,
            planner_options: PlannerOptions {
                whole_target_mode: WholeTargetMode::Off,
                ..PlannerOptions::default()
            },
            config_path: None,
            config_version: None,
        }
    }

    #[test]
    fn explicit_target_tty_starts_assistant_at_mode_page() {
        let mut command = command_fixture();
        command.prompt_selector = false;
        command.selected_targets.push(PathBuf::from("target"));

        let decision = cleanup_interaction_decision(&command, true);

        assert_eq!(
            decision,
            CleanupInteractionDecision::Assistant(CleanupAssistantRequest {
                target_selection: CleanupAssistantTargetSelection::Explicit,
                first_page: CleanupAssistantPage::Mode,
                minimum_page: CleanupAssistantPage::Mode,
                forced_mode: None,
                forced_action: None,
            })
        );
    }

    #[test]
    fn explicit_target_delete_tty_starts_assistant_at_action_page() {
        let mut command = command_fixture();
        command.prompt_selector = false;
        command.selected_targets.push(PathBuf::from("target"));
        command.delete_target = true;

        let decision = cleanup_interaction_decision(&command, true);

        assert_eq!(
            decision,
            CleanupInteractionDecision::Assistant(CleanupAssistantRequest {
                target_selection: CleanupAssistantTargetSelection::Explicit,
                first_page: CleanupAssistantPage::Action,
                minimum_page: CleanupAssistantPage::Action,
                forced_mode: Some(CleanupAssistantMode::DeleteTarget),
                forced_action: None,
            })
        );
    }

    #[test]
    fn explicit_target_with_action_flags_is_noninteractive() {
        let updates: [fn(&mut CleanupCommand); 2] = [
            |command: &mut CleanupCommand| command.execute = true,
            |command: &mut CleanupCommand| command.validate_only = true,
        ];
        for update in updates {
            let mut command = command_fixture();
            command.prompt_selector = false;
            command.selected_targets.push(PathBuf::from("target"));
            update(&mut command);

            assert_eq!(
                cleanup_interaction_decision(&command, true),
                CleanupInteractionDecision::NonInteractive
            );
        }
    }

    #[test]
    fn all_tty_mirrors_explicit_target_start_pages() {
        let mut command = command_fixture();
        command.prompt_selector = false;
        command.all = true;

        assert_eq!(
            cleanup_interaction_decision(&command, true),
            CleanupInteractionDecision::Assistant(CleanupAssistantRequest {
                target_selection: CleanupAssistantTargetSelection::All,
                first_page: CleanupAssistantPage::Mode,
                minimum_page: CleanupAssistantPage::Mode,
                forced_mode: None,
                forced_action: None,
            })
        );

        command.delete_target = true;
        assert_eq!(
            cleanup_interaction_decision(&command, true),
            CleanupInteractionDecision::Assistant(CleanupAssistantRequest {
                target_selection: CleanupAssistantTargetSelection::All,
                first_page: CleanupAssistantPage::Action,
                minimum_page: CleanupAssistantPage::Action,
                forced_mode: Some(CleanupAssistantMode::DeleteTarget),
                forced_action: None,
            })
        );
    }

    #[test]
    fn all_with_action_flags_is_noninteractive() {
        let updates: [fn(&mut CleanupCommand); 2] = [
            |command: &mut CleanupCommand| command.execute = true,
            |command: &mut CleanupCommand| command.validate_only = true,
        ];
        for update in updates {
            let mut command = command_fixture();
            command.prompt_selector = false;
            command.all = true;
            update(&mut command);

            assert_eq!(
                cleanup_interaction_decision(&command, true),
                CleanupInteractionDecision::NonInteractive
            );
        }
    }

    #[test]
    fn no_selector_non_tty_or_json_is_usage_error() {
        let command = command_fixture();
        assert_eq!(
            cleanup_interaction_decision(&command, false),
            CleanupInteractionDecision::UsageError
        );

        let mut json_command = command_fixture();
        json_command.output_format = OutputFormat::Json;
        assert_eq!(
            cleanup_interaction_decision(&json_command, true),
            CleanupInteractionDecision::UsageError
        );
    }
}