stash-cli 0.8.0

A local store for pipeline output and ad hoc file snapshots
Documentation
use clap::{ArgAction, Args};
use std::collections::{BTreeMap, HashSet};
use std::io::{self, Write};

use crate::store;
use crate::store::Meta;

#[derive(Args, Debug, Clone)]
pub(crate) struct RmArgs {
    #[arg(help = "Entry references to remove")]
    refs: Vec<String>,

    #[arg(long, help = "Remove entries older than the referenced entry")]
    before: Option<String>,

    #[arg(long, help = "Remove entries newer than the referenced entry")]
    after: Option<String>,

    #[arg(short = 'a', long = "attr", value_name = "name|name=value", action = ArgAction::Append, help = "Remove entries where an attribute is set, or equals a value (repeatable)")]
    attr: Vec<String>,

    #[arg(short = 'f', long = "force", help = "Do not prompt for confirmation")]
    force: bool,
}

#[derive(Clone, Debug)]
struct RmAttrFilter {
    key: String,
    value: Option<String>,
}

fn parse_rm_attr_filters(values: &[String]) -> io::Result<Vec<RmAttrFilter>> {
    let mut filters = Vec::new();
    for value in values {
        if value.trim().is_empty() || value.contains(',') {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "--attr accepts name or name=value and is repeatable",
            ));
        }
        if let Some((key, attr_value)) = value.split_once('=') {
            filters.push(RmAttrFilter {
                key: key.to_string(),
                value: Some(attr_value.to_string()),
            });
        } else {
            filters.push(RmAttrFilter {
                key: value.to_string(),
                value: None,
            });
        }
    }
    Ok(filters)
}

fn matches_rm_attr_filters(attrs: &BTreeMap<String, String>, filters: &[RmAttrFilter]) -> bool {
    filters.iter().all(|filter| match &filter.value {
        Some(value) => attrs.get(&filter.key) == Some(value),
        None => attrs.contains_key(&filter.key),
    })
}

fn confirm_rm_before(reference: &str, count: usize) -> io::Result<bool> {
    if count == 1 {
        eprint!("Remove 1 entry older than {}? [y/N] ", reference);
    } else {
        eprint!("Remove {} entries older than {}? [y/N] ", count, reference);
    }
    io::stderr().flush()?;
    let mut reply = String::new();
    io::stdin().read_line(&mut reply)?;
    let reply = reply.trim().to_ascii_lowercase();
    Ok(reply == "y" || reply == "yes")
}

fn confirm_rm_after(reference: &str, count: usize) -> io::Result<bool> {
    if count == 1 {
        eprint!("Remove 1 entry newer than {}? [y/N] ", reference);
    } else {
        eprint!("Remove {} entries newer than {}? [y/N] ", count, reference);
    }
    io::stderr().flush()?;
    let mut reply = String::new();
    io::stdin().read_line(&mut reply)?;
    let reply = reply.trim().to_ascii_lowercase();
    Ok(reply == "y" || reply == "yes")
}

fn confirm_rm_entries(reason: &str, entries: &[Meta]) -> io::Result<bool> {
    eprintln!(
        "Remove {} entr{} {}:",
        entries.len(),
        if entries.len() == 1 { "y" } else { "ies" },
        reason
    );
    for entry in entries {
        if let Some(name) = entry.attrs.get("filename") {
            eprintln!("  {}  {}  {}", entry.short_id(), entry.ts, name);
        } else {
            eprintln!("  {}  {}", entry.short_id(), entry.ts);
        }
    }
    eprint!("Continue? [y/N] ");
    io::stderr().flush()?;
    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let answer = input.trim().to_ascii_lowercase();
    Ok(answer == "y" || answer == "yes")
}

pub(super) fn rm_command(args: RmArgs) -> io::Result<()> {
    if args.before.is_some() && args.after.is_some() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "rm accepts at most one of --before or --after",
        ));
    }

    if !args.attr.is_empty() {
        if !args.refs.is_empty() || args.before.is_some() || args.after.is_some() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "rm accepts either <ref>..., --before, --after, or --attr",
            ));
        }
        let filters = parse_rm_attr_filters(&args.attr)?;
        let matches: Vec<Meta> = store::list()?
            .into_iter()
            .filter(|meta| matches_rm_attr_filters(&meta.attrs, &filters))
            .collect();
        if matches.is_empty() {
            return Ok(());
        }
        if !args.force && !confirm_rm_entries("matching attributes", &matches)? {
            return Ok(());
        }
        for meta in matches {
            store::remove(&meta.id)?;
        }
        return Ok(());
    }

    if let Some(before_ref) = args.before.as_deref() {
        if !args.refs.is_empty() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "rm accepts either <ref>..., --before, or --after",
            ));
        }
        let id = store::resolve(before_ref)?;
        let ids = store::older_than_ids(&id)?;
        if ids.is_empty() {
            return Ok(());
        }
        if !args.force && !confirm_rm_before(before_ref, ids.len())? {
            return Ok(());
        }
        for id in ids {
            store::remove(&id)?;
        }
        return Ok(());
    }

    if let Some(after_ref) = args.after.as_deref() {
        if !args.refs.is_empty() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "rm accepts either <ref>..., --before, or --after",
            ));
        }
        let id = store::resolve(after_ref)?;
        let ids = store::newer_than_ids(&id)?;
        if ids.is_empty() {
            return Ok(());
        }
        if !args.force && !confirm_rm_after(after_ref, ids.len())? {
            return Ok(());
        }
        for id in ids {
            store::remove(&id)?;
        }
        return Ok(());
    }

    if args.refs.is_empty() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "rm requires at least one ref",
        ));
    }

    let mut seen = HashSet::new();
    let mut ids: Vec<String> = Vec::new();
    for reference in &args.refs {
        let id = store::resolve(reference)?;
        if seen.insert(id.clone()) {
            ids.push(id);
        }
    }
    if ids.len() == 1 {
        return store::remove(&ids[0]);
    }

    let mut entries = Vec::new();
    for id in &ids {
        entries.push(store::get_meta(id)?);
    }
    if !args.force && !confirm_rm_entries("matching refs", &entries)? {
        return Ok(());
    }
    for id in ids {
        store::remove(&id)?;
    }
    Ok(())
}