univert 0.4.0

Universal file converter (library and CLI)
Documentation
use clap::Parser;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;

use crate::{Options, conversion_map, convert};

/// Output formats ordered by rough popularity — everything not listed falls
/// to the end in alphabetic order. Used to present `--list` in a way that
/// surfaces the formats most users care about first.
const POPULARITY: &[&str] = &[
  // Images people ask for most often.
  "png", "jpeg", "webp", "gif", "svg", "pdf", "ico",
  // Text / data / config.
  "html", "md", "txt", "json", "yaml", "json5", "csv", "tsv", "xml", "toml",
  "ini", // Audio / music.
  "mp3", "wav", "flac", "aac", "ogg", "midi", // 3D / CAD.
  "stl", "obj", // Less common image / docs.
  "tif", "bmp", "tga", "ps", "eps", // Scripts.
  "js", "css",
];

fn popularity_rank(fmt: &str) -> (usize, String) {
  match POPULARITY.iter().position(|p| *p == fmt) {
    Some(i) => (i, String::new()),
    None => (usize::MAX, fmt.to_string()),
  }
}

fn print_list() {
  let mut by_source: BTreeMap<&'static str, Vec<&'static str>> =
    BTreeMap::new();
  for (from, to) in conversion_map::list_all() {
    by_source.entry(from).or_default().push(to);
  }

  let width = by_source.keys().map(|k| k.len()).max().unwrap_or(0);

  for (from, mut targets) in by_source {
    targets.sort_by_key(|t| popularity_rank(t));
    targets.dedup();
    println!("{from:<width$} -> {}", targets.join(", "));
  }
}

/// Universal file converter.
#[derive(Parser, Debug)]
#[command(
  name = "univert",
  version,
  about = "Universal file converter",
  override_usage = "univert <input-file> <output-file> [OPTIONS]"
)]
pub struct Args {
  /// Overwrite existing file
  #[arg(short, long)]
  pub force: bool,

  /// List all available conversions
  #[arg(short, long)]
  pub list: bool,

  /// Split source file into several target files (where applicable)
  #[arg(short, long)]
  pub split: bool,

  /// Input file
  pub input: Option<PathBuf>,

  /// Output file
  pub output: Option<PathBuf>,
}

pub fn run() -> ExitCode {
  // Restore default SIGPIPE behavior so piping into `head` etc. terminates
  // cleanly instead of panicking from a broken-pipe write.
  #[cfg(unix)]
  {
    use std::os::raw::c_int;
    unsafe extern "C" {
      fn signal(signum: c_int, handler: usize) -> usize;
    }
    const SIGPIPE: c_int = 13;
    const SIG_DFL: usize = 0;
    unsafe {
      signal(SIGPIPE, SIG_DFL);
    }
  }

  let args = Args::parse();

  if args.list {
    print_list();
    return ExitCode::SUCCESS;
  }

  let (Some(input), Some(output)) = (args.input.as_ref(), args.output.as_ref())
  else {
    eprintln!("usage: univert <input-file> <output-file> [OPTIONS]");
    return ExitCode::from(2);
  };

  let options = Options {
    force: args.force,
    split: args.split,
  };

  match convert(input, output, &options) {
    Ok(()) => {
      println!("Converted {} to {}", input.display(), output.display());
      ExitCode::SUCCESS
    }
    Err(err) => {
      eprintln!("{err}");
      ExitCode::FAILURE
    }
  }
}