hashavatar 0.2.0

Deterministic procedural avatars in Rust with SHA-512 identities and WebP, PNG, and SVG export
Documentation
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;

use hashavatar::{
    AvatarBackground, AvatarKind, AvatarOptions, AvatarOutputFormat, AvatarSpec,
    encode_avatar_for_id, export_avatar_svg_for_id,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = CliArgs::parse(std::env::args_os().skip(1).collect())?;
    let spec = AvatarSpec::new(args.size, args.size, 0);

    if let Some(input) = args.input.as_ref() {
        let out_dir = args
            .out_dir
            .clone()
            .unwrap_or_else(|| PathBuf::from("exports"));
        fs::create_dir_all(&out_dir)?;

        for identity in read_batch_identities(input)? {
            let output = out_dir.join(output_file_name(&identity, args.kind, args.format));
            export_one(
                &identity,
                spec,
                &output,
                args.kind,
                args.background,
                args.format,
            )?;
            println!("wrote {}", output.display());
        }
        return Ok(());
    }

    let identity = args
        .id
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .ok_or("missing --id or --input")?;
    let output = args
        .output
        .clone()
        .unwrap_or_else(|| PathBuf::from(output_file_name(identity, args.kind, args.format)));
    export_one(
        identity,
        spec,
        &output,
        args.kind,
        args.background,
        args.format,
    )?;
    println!("wrote {}", output.display());
    Ok(())
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CliFormat {
    WebP,
    Png,
    Svg,
}

impl CliFormat {
    fn extension(self) -> &'static str {
        match self {
            Self::WebP => "webp",
            Self::Png => "png",
            Self::Svg => "svg",
        }
    }
}

impl FromStr for CliFormat {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim().to_ascii_lowercase().as_str() {
            "webp" => Ok(Self::WebP),
            "png" => Ok(Self::Png),
            "svg" => Ok(Self::Svg),
            _ => Err("unsupported format"),
        }
    }
}

#[derive(Debug)]
struct CliArgs {
    id: Option<String>,
    input: Option<PathBuf>,
    output: Option<PathBuf>,
    out_dir: Option<PathBuf>,
    kind: AvatarKind,
    background: AvatarBackground,
    format: CliFormat,
    size: u32,
}

impl CliArgs {
    fn parse(args: Vec<OsString>) -> Result<Self, Box<dyn std::error::Error>> {
        let mut parsed = Self {
            id: None,
            input: None,
            output: None,
            out_dir: None,
            kind: AvatarKind::Cat,
            background: AvatarBackground::Themed,
            format: CliFormat::WebP,
            size: 256,
        };

        let mut iter = args.into_iter();
        while let Some(flag) = iter.next() {
            let value = flag.to_string_lossy();
            match value.as_ref() {
                "--id" => parsed.id = Some(next_string(&mut iter, "--id")?),
                "--input" => parsed.input = Some(PathBuf::from(next_string(&mut iter, "--input")?)),
                "--output" => {
                    parsed.output = Some(PathBuf::from(next_string(&mut iter, "--output")?))
                }
                "--out-dir" => {
                    parsed.out_dir = Some(PathBuf::from(next_string(&mut iter, "--out-dir")?))
                }
                "--kind" => {
                    parsed.kind = AvatarKind::from_str(&next_string(&mut iter, "--kind")?)
                        .map_err(str::to_string)?
                }
                "--background" => {
                    parsed.background =
                        AvatarBackground::from_str(&next_string(&mut iter, "--background")?)
                            .map_err(str::to_string)?
                }
                "--format" => {
                    parsed.format = CliFormat::from_str(&next_string(&mut iter, "--format")?)
                        .map_err(str::to_string)?
                }
                "--size" => {
                    parsed.size = next_string(&mut iter, "--size")?.parse::<u32>()?;
                }
                "--help" | "-h" => {
                    print_help();
                    std::process::exit(0);
                }
                other => return Err(format!("unknown argument: {other}").into()),
            }
        }

        Ok(parsed)
    }
}

fn next_string(
    iter: &mut impl Iterator<Item = OsString>,
    flag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    iter.next()
        .map(|value| value.to_string_lossy().to_string())
        .ok_or_else(|| format!("missing value for {flag}").into())
}

fn read_batch_identities(path: &Path) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let contents = fs::read_to_string(path)?;
    Ok(contents
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect())
}

fn export_one(
    identity: &str,
    spec: AvatarSpec,
    output: &Path,
    kind: AvatarKind,
    background: AvatarBackground,
    format: CliFormat,
) -> Result<(), Box<dyn std::error::Error>> {
    let options = AvatarOptions::new(kind, background);
    match format {
        CliFormat::Svg => export_avatar_svg_for_id(spec, identity, options, output)?,
        CliFormat::WebP => {
            let bytes = encode_avatar_for_id(spec, identity, AvatarOutputFormat::WebP, options)?;
            fs::write(output, bytes)?;
        }
        CliFormat::Png => {
            let bytes = encode_avatar_for_id(spec, identity, AvatarOutputFormat::Png, options)?;
            fs::write(output, bytes)?;
        }
    }
    Ok(())
}

fn output_file_name(identity: &str, kind: AvatarKind, format: CliFormat) -> String {
    let safe_identity = identity
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' => ch,
            _ => '-',
        })
        .collect::<String>()
        .trim_matches('-')
        .to_ascii_lowercase();
    format!(
        "{}-{}.{}",
        if safe_identity.is_empty() {
            "avatar".to_string()
        } else {
            safe_identity
        },
        kind.as_str(),
        format.extension()
    )
}

fn print_help() {
    println!(
        "hashavatar-cli\n\
         \n\
         Single export:\n\
           cargo run --bin hashavatar-cli -- --id alice@example.com --kind robot --background white --format svg --output alice.svg\n\
         \n\
         Batch export:\n\
           cargo run --bin hashavatar-cli -- --input ids.txt --out-dir exports --kind fox --format webp\n\
         \n\
         Flags:\n\
           --id <value>\n\
           --input <path>\n\
           --output <path>\n\
           --out-dir <path>\n\
           --kind <cat|dog|robot|fox|alien>\n\
           --background <themed|white>\n\
           --format <webp|png|svg>\n\
           --size <pixels>\n"
    );
}