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 {
#[arg(long, global = true, value_name = "FMT")]
pub format: Option<String>,
#[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, ®istry);
}
let (chunks, input, output) = split_pipeline(cli.rest)?;
let fmt = io::output_format(&output, cli.format.as_deref())?;
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)
}
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(())
}
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| {
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)));
}
}