logmv 0.7.0

Logged atomic file move and trash with an append-only JSON-Lines audit trail
Documentation
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::Parser;
use logmv::{Op, run};

#[derive(Parser)]
#[command(author, version, about = "Logged atomic file move/trash")]
struct Cli {
    /// JSON-Lines log file to append to (required first positional)
    log: String,

    /// Trash mode: move PATH to the trash directory
    #[arg(long)]
    trash: Option<String>,

    /// Create the destination's missing parent directories (like mkdir -p). Must
    /// precede SRC/PATH (e.g. logmv LOG --mkdir SRC DST).
    #[arg(long)]
    mkdir: bool,

    /// After a successful move/trash, remove the source's now-empty parent
    /// directories, cascading up (like rmdir -p). Must precede SRC/PATH.
    #[arg(long)]
    rmdir: bool,

    /// For move: SRC DST [K V]...; for trash: [K V]... Metadata tokens must not
    /// start with `--`; flags precede SRC/PATH.
    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    rest: Vec<String>,
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match dispatch(cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("logmv: {e}");
            ExitCode::FAILURE
        }
    }
}

fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
    let log = Path::new(&cli.log);

    if let Some(trash_path) = &cli.trash {
        let pairs = pair_up(&cli.rest)?;
        let home_raw = std::env::var("HOME")?;
        let home = PathBuf::from(&home_raw);
        if !home.is_absolute() {
            return Err(format!("HOME must be an absolute path, got: {home_raw:?}").into());
        }
        let trash_dir = home.join(".Trash");
        run(
            Op::Trash {
                path: PathBuf::from(trash_path),
                trash_dir,
            },
            log,
            &pairs,
            cli.mkdir,
            cli.rmdir,
        )?;
    } else {
        // Move: SRC DST [K V]...; too few positionals is a usage error.
        if cli.rest.len() < 2 {
            return Err("usage: logmv <LOG> <SRC> <DST> [K V]...".into());
        }
        let src = PathBuf::from(&cli.rest[0]);
        let dst = PathBuf::from(&cli.rest[1]);
        let pairs = pair_up(&cli.rest[2..])?;
        run(Op::Move { src, dst }, log, &pairs, cli.mkdir, cli.rmdir)?;
    }

    Ok(())
}

/// Split a flat trailing slab into `[K V]` pairs. A token starting with `--`
/// (a flag that leaked past SRC/PATH) or an odd length (a dangling key with no
/// value) is a usage error, checked before any move/log (AC12).
fn pair_up(slab: &[String]) -> Result<Vec<(&str, &str)>, Box<dyn std::error::Error>> {
    if let Some(tok) = slab.iter().find(|t| t.starts_with("--")) {
        return Err(format!(
            "trailing metadata must not start with `--`: got {tok:?}; flags must precede SRC/PATH"
        )
        .into());
    }
    if !slab.chunks_exact(2).remainder().is_empty() {
        return Err("trailing metadata must be [K V] pairs: got an odd number of arguments".into());
    }
    Ok(slab
        .chunks_exact(2)
        .map(|c| (c[0].as_str(), c[1].as_str()))
        .collect())
}