crapify 0.3.0

Deep-fry your images, and other crimes against pixels.
use std::collections::HashMap;
use std::path::PathBuf;

use clap::{Command, CommandFactory, Parser};

use crate::crapifier::{CrapifierEntry, registry};
use crate::error::CrapifyError;
use crate::io;

#[derive(Parser)]
#[command(
    name = "crapify",
    version,
    about = "Deep-fry your images, and other crimes against pixels.",
    // Clap's eager --help / -h would otherwise be processed before
    // `trailing_var_arg` can absorb it, masking per-stage help like
    // `crapify deep-fry --help`. We dispatch help manually in `dispatch_help`.
    disable_help_flag = true,
)]
pub struct Cli {
    /// Output format override (e.g. `png`, `jpg`, `webp`). When omitted, the
    /// format is inferred from the output path's extension.
    #[arg(long, global = true, value_name = "FMT")]
    pub format: Option<String>,

    /// Pipeline body. See the bottom of `--help` for grammar and registered stages.
    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    pub rest: Vec<String>,
}

pub fn split_pipeline(
    mut rest: Vec<String>,
) -> Result<(Vec<Vec<String>>, PathBuf, PathBuf), CrapifyError> {
    if rest.len() < 3 {
        return Err(CrapifyError::NoStages);
    }
    let output = PathBuf::from(rest.pop().unwrap());
    let input = PathBuf::from(rest.pop().unwrap());

    let chunks: Vec<Vec<String>> = rest.split(|s| s == "+").map(<[String]>::to_vec).collect();

    for (idx, chunk) in chunks.iter().enumerate() {
        if chunk.is_empty() {
            return Err(CrapifyError::EmptyStage(idx));
        }
    }

    Ok((chunks, input, output))
}

pub fn run(cli: Cli) -> Result<(), CrapifyError> {
    let registry = registry();

    if cli.rest.iter().any(|s| s == "--help" || s == "-h") {
        return dispatch_help(&cli.rest, &registry);
    }

    let (chunks, input, output) = split_pipeline(cli.rest)?;
    let fmt = io::output_format(&output, cli.format.as_deref())?;

    // Resolve every stage name and parse every stage's args *before* decoding the
    // input. Decoding can be slow on multi-megapixel images; user-facing errors
    // (typo'd stage name, bad flag) should fail fast.
    let mut resolved: Vec<(&CrapifierEntry, clap::ArgMatches)> = Vec::with_capacity(chunks.len());
    for chunk in &chunks {
        let entry = *registry
            .get(chunk[0].as_str())
            .ok_or_else(|| CrapifyError::UnknownStage(chunk[0].clone()))?;
        let cmd = (entry.augment_command)(Command::new(entry.name).no_binary_name(true));
        let matches = cmd
            .try_get_matches_from(&chunk[1..])
            .map_err(stage_clap_err)?;
        resolved.push((entry, matches));
    }

    let mut img = io::decode_input(&input)?;
    for (entry, matches) in resolved {
        img = (entry.run)(img, &matches)?;
    }

    io::encode_output(&img, &output, fmt)
}

// Find the stage chunk that actually asked for --help / -h and dispatch
// through its clap Command — clap returns DisplayHelp, stage_clap_err calls
// e.exit() to print and exit 0. If no recognized stage owns the help token
// (e.g. bare `crapify --help`, `crapify --format png --help`), fall back to
// top-level help, enriched with the live list of registered stages.
fn dispatch_help(
    rest: &[String],
    registry: &HashMap<&'static str, &'static CrapifierEntry>,
) -> Result<(), CrapifyError> {
    for chunk in rest.split(|s| s == "+") {
        if chunk.is_empty() {
            continue;
        }
        let Some(entry) = registry.get(chunk[0].as_str()) else {
            continue;
        };
        if !chunk[1..].iter().any(|s| s == "--help" || s == "-h") {
            continue;
        }
        let cmd = (entry.augment_command)(Command::new(entry.name).no_binary_name(true));
        cmd.try_get_matches_from(&chunk[1..])
            .map_err(stage_clap_err)?;
    }
    print_top_level_help(registry);
    Ok(())
}

// Print the Cli's help with a footer that documents the pipeline grammar
// and lists every registered crapifier. The footer goes through
// `after_long_help` (rather than `long_about`) so the conventional render
// order is preserved: about → Usage → Arguments → Options → footer.
fn print_top_level_help(registry: &HashMap<&'static str, &'static CrapifierEntry>) {
    let stages_section = render_stages_section(registry);
    let grammar = "Pipeline grammar:\n  \
                   crapify [globals] <stage> [args] + <stage> [args] ... <input> <output>\n\n\
                   Use `crapify <stage> --help` for stage-specific options.";
    let footer = if stages_section.is_empty() {
        grammar.to_string()
    } else {
        format!("{grammar}\n\nSTAGES:\n{}", stages_section.trim_end())
    };
    Cli::command()
        .after_long_help(footer)
        .print_long_help()
        .ok();
    println!();
}

fn render_stages_section(registry: &HashMap<&'static str, &'static CrapifierEntry>) -> String {
    let mut rows: Vec<(String, String)> = registry
        .values()
        .map(|entry| {
            // Recover each stage's `about` by running its augment_command on a
            // throwaway Command and reading it back — the about lives on the
            // Command, not on the CrapifierEntry directly.
            let cmd = (entry.augment_command)(Command::new(entry.name).no_binary_name(true));
            let about = cmd.get_about().map(|s| s.to_string()).unwrap_or_default();
            (entry.name.to_string(), about)
        })
        .collect();
    rows.sort_by(|a, b| a.0.cmp(&b.0));
    let name_col = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
    let mut s = String::new();
    for (name, about) in rows {
        s.push_str(&format!("  {name:<name_col$}  {about}\n"));
    }
    s
}

fn stage_clap_err(e: clap::Error) -> CrapifyError {
    use clap::error::ErrorKind::*;
    match e.kind() {
        DisplayHelp | DisplayVersion | DisplayHelpOnMissingArgumentOrSubcommand => {
            e.exit();
        }
        _ => CrapifyError::Clap(e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn empty_rest_no_stages() {
        let err = split_pipeline(v(&[])).unwrap_err();
        assert!(matches!(err, CrapifyError::NoStages));
    }

    #[test]
    fn just_io_no_stages() {
        let err = split_pipeline(v(&["in.png", "out.png"])).unwrap_err();
        assert!(matches!(err, CrapifyError::NoStages));
    }

    #[test]
    fn single_stage() {
        let (chunks, input, output) =
            split_pipeline(v(&["deep-fry", "in.png", "out.png"])).unwrap();
        assert_eq!(input, PathBuf::from("in.png"));
        assert_eq!(output, PathBuf::from("out.png"));
        assert_eq!(chunks, vec![v(&["deep-fry"])]);
    }

    #[test]
    fn single_stage_with_args() {
        let (chunks, _, _) =
            split_pipeline(v(&["deep-fry", "--quality", "50", "in.png", "out.png"])).unwrap();
        assert_eq!(chunks, vec![v(&["deep-fry", "--quality", "50"])]);
    }

    #[test]
    fn multi_stage() {
        let (chunks, _, _) =
            split_pipeline(v(&["deep-fry", "+", "xerox", "in.png", "out.png"])).unwrap();
        assert_eq!(chunks, vec![v(&["deep-fry"]), v(&["xerox"])]);
    }

    #[test]
    fn leading_plus_empty_first_stage() {
        let err = split_pipeline(v(&["+", "deep-fry", "in.png", "out.png"])).unwrap_err();
        assert!(matches!(err, CrapifyError::EmptyStage(0)));
    }

    #[test]
    fn consecutive_plus_empty_middle_stage() {
        let err =
            split_pipeline(v(&["deep-fry", "+", "+", "xerox", "in.png", "out.png"])).unwrap_err();
        assert!(matches!(err, CrapifyError::EmptyStage(1)));
    }

    #[test]
    fn trailing_plus_before_io_empty_last_stage() {
        let err = split_pipeline(v(&["deep-fry", "+", "in.png", "out.png"])).unwrap_err();
        assert!(matches!(err, CrapifyError::EmptyStage(1)));
    }
}