cargo-reclaim 0.2.2

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

use cargo_reclaim::{PersistedPlan, PersistedPlanEntry, PlanEditRequest};

use super::{CliError, EditPlanCommand, OutputFormat};

pub(super) fn prompt_for_interactive_edit(
    document: &PersistedPlan,
) -> Result<Option<PlanEditRequest>, CliError> {
    let menu = InteractiveMenu::from_plan(document);
    let mut stderr = io::stderr();
    write_interactive_menu(&mut stderr, document, &menu)?;
    stderr.flush()?;

    let mut line = String::new();
    io::stdin().read_line(&mut line)?;
    let trimmed = line.trim();
    if is_cancel(trimmed) {
        return Ok(None);
    }

    let request = parse_interactive_selection(trimmed, document, &menu)?;
    Ok(Some(request))
}

pub(super) fn write_interactive_cancel_report(
    output: &mut impl Write,
    command: &EditPlanCommand,
) -> Result<(), CliError> {
    if command.output_format == OutputFormat::Json {
        let document = serde_json::json!({
            "command": "edit-plan",
            "plan_path": command.plan_path.display().to_string(),
            "status": "cancelled",
            "modified": false,
            "message": "interactive selection was not saved",
        });
        serde_json::to_writer(&mut *output, &document)?;
        writeln!(output)?;
        return Ok(());
    }

    writeln!(output, "cargo-reclaim edit-plan")?;
    writeln!(output, "interactive selection was not saved")?;
    writeln!(output, "plan unchanged")?;
    Ok(())
}

pub(super) fn is_confirmed(value: &str) -> bool {
    value.eq_ignore_ascii_case("y") || value.eq_ignore_ascii_case("yes")
}

struct InteractiveMenu {
    project_groups: Vec<ProjectGroup>,
    class_groups: Vec<String>,
}

struct ProjectGroup {
    label: String,
    manifest: String,
    indices: Vec<usize>,
}

impl InteractiveMenu {
    fn from_plan(document: &PersistedPlan) -> Self {
        let mut projects: BTreeMap<String, Vec<usize>> = BTreeMap::new();
        let mut classes = BTreeSet::new();
        for (index, entry) in document.body.plan.entries.iter().enumerate() {
            if entry.action == "delete"
                && let Some(manifest) = project_manifest_for_entry(entry)
            {
                projects.entry(manifest).or_default().push(index + 1);
            }
            if entry.artifact_class != "whole_target" && entry.artifact_class != "unknown" {
                classes.insert(entry.artifact_class.clone());
            }
        }

        Self {
            project_groups: projects
                .into_iter()
                .enumerate()
                .map(|(index, (manifest, indices))| ProjectGroup {
                    label: format!("p{}", index + 1),
                    manifest,
                    indices,
                })
                .collect(),
            class_groups: classes.into_iter().collect(),
        }
    }
}

fn project_manifest_for_entry(entry: &PersistedPlanEntry) -> Option<String> {
    entry
        .evidence
        .project_manifest
        .clone()
        .or_else(|| adjacent_project_manifest(&entry.snapshot.path))
}

fn adjacent_project_manifest(snapshot_path: &str) -> Option<String> {
    let mut current = Some(Path::new(snapshot_path));
    while let Some(path) = current {
        let manifest = path.join("Cargo.toml");
        if manifest.is_file() {
            return Some(manifest.display().to_string());
        }
        current = path.parent();
    }
    None
}

fn write_interactive_menu(
    output: &mut impl Write,
    document: &PersistedPlan,
    menu: &InteractiveMenu,
) -> Result<(), CliError> {
    writeln!(output, "cargo-reclaim edit-plan interactive")?;
    writeln!(
        output,
        "Select entries to delete; apply --plan remains the destructive step."
    )?;
    writeln!(
        output,
        "Tokens: entry number, project group pN, class c:<class>, or none/cancel."
    )?;
    writeln!(output)?;
    writeln!(output, "Entries:")?;
    for (index, entry) in document.body.plan.entries.iter().enumerate() {
        writeln!(
            output,
            "{}\t{}\t{}\t{}\t{}",
            index + 1,
            display_text(&entry.artifact_class),
            display_text(&entry.action),
            entry.snapshot.size_bytes,
            display_text(&entry.snapshot.path)
        )?;
    }

    if !menu.project_groups.is_empty() {
        writeln!(output)?;
        writeln!(output, "Project groups:")?;
        for group in &menu.project_groups {
            writeln!(
                output,
                "{}\t{}\tentries {}",
                group.label,
                display_text(&group.manifest),
                join_indices(&group.indices)
            )?;
        }
    }

    if !menu.class_groups.is_empty() {
        writeln!(output)?;
        writeln!(output, "Class groups:")?;
        for class in &menu.class_groups {
            writeln!(output, "c:{}\t{}", display_text(class), display_text(class))?;
        }
    }

    if document
        .body
        .plan
        .entries
        .iter()
        .any(|entry| entry.artifact_class == "whole_target")
    {
        writeln!(
            output,
            "whole_target entries must be selected by entry number."
        )?;
    }

    writeln!(output)?;
    writeln!(output, "Selection:")?;
    Ok(())
}

fn parse_interactive_selection(
    input: &str,
    document: &PersistedPlan,
    menu: &InteractiveMenu,
) -> Result<PlanEditRequest, CliError> {
    let tokens = input
        .split(|character: char| character.is_whitespace() || character == ',')
        .filter(|token| !token.is_empty())
        .collect::<Vec<_>>();
    if tokens.is_empty() {
        return Err(CliError::Usage(
            "interactive selection requires at least one token or `none`".to_string(),
        ));
    }
    if tokens.iter().any(|token| is_cancel(token)) {
        return Err(CliError::Usage(
            "`none`/`cancel` cannot be combined with other selections".to_string(),
        ));
    }

    let mut select_indices = Vec::new();
    let mut select_classes = Vec::new();
    for token in tokens {
        if let Some(class) = token.strip_prefix("c:") {
            if class.is_empty() {
                return Err(CliError::Usage(
                    "class selection requires c:<class>".to_string(),
                ));
            }
            if class == "whole_target" {
                return Err(CliError::Usage(
                    "whole_target must be selected by entry number".to_string(),
                ));
            }
            select_classes.push(class.to_string());
        } else if let Some(group_index) = token.strip_prefix('p') {
            let group_index = parse_group_index(group_index)?;
            let group = menu
                .project_groups
                .get(group_index - 1)
                .ok_or_else(|| CliError::Usage(format!("unknown project group `{token}`")))?;
            select_indices.extend(group.indices.iter().copied());
        } else {
            let index = parse_interactive_index(token)?;
            if index > document.body.plan.entries.len() {
                return Err(CliError::Usage(format!(
                    "entry index {index} is out of range for this plan"
                )));
            }
            select_indices.push(index);
        }
    }

    PlanEditRequest::new_with_class_selectors(
        Vec::new(),
        Vec::new(),
        select_indices,
        Vec::new(),
        select_classes,
        Vec::new(),
    )
    .map_err(CliError::from)
}

fn parse_interactive_index(value: &str) -> Result<usize, CliError> {
    let index = value.parse::<usize>().map_err(|_| {
        CliError::Usage("interactive selection requires a positive 1-based entry index".to_string())
    })?;
    if index == 0 {
        return Err(CliError::Usage(
            "interactive selection requires a positive 1-based entry index".to_string(),
        ));
    }
    Ok(index)
}

fn parse_group_index(value: &str) -> Result<usize, CliError> {
    let index = value
        .parse::<usize>()
        .map_err(|_| CliError::Usage("project group selection requires p<index>".to_string()))?;
    if index == 0 {
        return Err(CliError::Usage(
            "project group selection requires p<index>".to_string(),
        ));
    }
    Ok(index)
}

fn is_cancel(value: &str) -> bool {
    value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("cancel")
}

fn join_indices(indices: &[usize]) -> String {
    indices
        .iter()
        .map(usize::to_string)
        .collect::<Vec<_>>()
        .join(",")
}

fn display_text(value: &str) -> String {
    value.escape_default().to_string()
}