character-wizard-cli 0.5.0

Native level-1 SRD character creation CLI
//! Native character-wizard command-line entry point.

use std::{
    env, fs,
    io::{self, Write as _},
    path::{Path, PathBuf},
    process::ExitCode,
};

use crate::character_wizard_domain::Character;
use clap::{Args, Parser, Subcommand};

pub mod creation;
pub mod domain;
pub mod pdf_renderer;
pub mod srd_data;

pub use self::creation as character_wizard_creation;
pub use self::domain as character_wizard_domain;
pub use self::pdf_renderer as character_wizard_pdf_renderer;
pub use self::srd_data as character_wizard_srd_data;

mod template;

use template::resolve_template;

#[derive(Parser)]
#[command(about = "Create D&D characters using SRD 5.2.1.", version)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Create(CreateArgs),
    Validate { character_json: PathBuf },
    Show { character_json: PathBuf },
}

#[derive(Args)]
struct CreateArgs {
    #[arg(long)]
    template: Option<PathBuf>,
    #[arg(long)]
    from_json: Option<PathBuf>,
    #[arg(long, default_value = "character.json")]
    json: PathBuf,
    #[arg(short, long, default_value = "character-sheet-filled.pdf")]
    output: PathBuf,
    #[arg(long, default_value = "character-draft.json")]
    draft: PathBuf,
    #[arg(long)]
    force: bool,
}

fn main() -> ExitCode {
    let cli = Cli::parse_from(env::args_os());
    match run(cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err((code, message)) => {
            println!("Error: {message}");
            ExitCode::from(code)
        }
    }
}

type CliResult = Result<(), (u8, String)>;

fn run(cli: Cli) -> CliResult {
    match cli.command {
        Command::Create(options) => create(options),
        Command::Validate { character_json } => validate(&character_json),
        Command::Show { character_json } => show(&character_json),
    }
}

fn validate(path: &Path) -> CliResult {
    let character = load_character(path)?;
    println!("{} is valid.", character.name);
    Ok(())
}

fn show(path: &Path) -> CliResult {
    let character = load_character(path)?;
    println!("{}", character.name);
    println!(
        "Identity      Level {} {} {}",
        character.level, character.species, character.character_class
    );
    println!("Background    {}", character.background);
    println!("Alignment     {}", character.alignment);
    println!(
        "Combat        HP {} · AC {} · Speed {} ft.",
        character.hit_points(),
        character.armor_class(),
        character.speed()
    );
    println!(
        "Skills        {}",
        character
            .skills()
            .into_iter()
            .collect::<Vec<_>>()
            .join(", ")
    );
    println!("Languages     {}", languages(&character).join(", "));
    Ok(())
}

fn create(options: CreateArgs) -> CliResult {
    let template = resolve_template(options.template.as_deref()).map_err(|error| (1, error))?;
    let json_output = options.json;
    let pdf_output = options.output;
    confirm_overwrite(&[&json_output, &pdf_output], options.force)?;

    let mut completed_draft = None;
    let character = if let Some(source) = options.from_json {
        load_character(&source)?
    } else {
        let draft = options.draft;
        println!(
            "Progress is checkpointed in {}; Ctrl-C keeps the latest completed stage.",
            draft.display()
        );
        match character_wizard_creation::run_interactive(&draft) {
            Ok(character) => {
                completed_draft = Some(draft);
                character
            }
            Err(character_wizard_creation::WizardError::SaveAndExit) => {
                println!("Creation saved in {}.", draft.display());
                return Ok(());
            }
            Err(error) => return Err((1, error.to_string())),
        }
    };
    create_parent(&json_output)?;
    create_parent(&pdf_output)?;
    fs::write(
        &json_output,
        character.to_json().map_err(|error| (1, error))?,
    )
    .map_err(|error| {
        (
            1,
            format!("unable to write {}: {error}", json_output.display()),
        )
    })?;
    if let Err(error) =
        character_wizard_pdf_renderer::render_character(&character, &template, &pdf_output)
    {
        let _ = fs::remove_file(&json_output);
        return Err((1, error));
    }
    println!("{} is ready!", character.name);
    println!("PDF: {}", pdf_output.display());
    println!("JSON: {}", json_output.display());
    if let Some(draft) = completed_draft {
        let _ = fs::remove_file(draft);
    }
    Ok(())
}

fn load_character(path: &Path) -> Result<Character, (u8, String)> {
    if !path.is_file() {
        return Err((
            1,
            format!(
                "character JSON does not exist or is not a file: {}",
                path.display()
            ),
        ));
    }
    let source = fs::read_to_string(path)
        .map_err(|error| (1, format!("unable to read {}: {error}", path.display())))?;
    Character::from_json(&source).map_err(|error| {
        (
            1,
            format!("invalid character JSON {}: {error}", path.display()),
        )
    })
}

fn confirm_overwrite(paths: &[&Path], force: bool) -> CliResult {
    let existing: Vec<String> = paths
        .iter()
        .filter(|path| path.exists())
        .map(|path| path.display().to_string())
        .collect();
    if existing.is_empty() || force {
        return Ok(());
    }
    print!(
        "Overwrite existing output(s): {}? [y/N] ",
        existing.join(", ")
    );
    io::stdout()
        .flush()
        .map_err(|error| (1, error.to_string()))?;
    let mut answer = String::new();
    io::stdin()
        .read_line(&mut answer)
        .map_err(|error| (1, error.to_string()))?;
    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
        Ok(())
    } else {
        Err((1, "Aborted".to_owned()))
    }
}

fn create_parent(path: &Path) -> CliResult {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent)
            .map_err(|error| (1, format!("unable to create {}: {error}", parent.display())))?;
    }
    Ok(())
}

fn languages(character: &Character) -> Vec<String> {
    let mut values = vec!["Common".to_owned()];
    values.extend(character.selected_languages.iter().cloned());
    values.extend(character.class_choices.additional_language.iter().cloned());
    values
}

#[cfg(test)]
mod tests {
    use clap::Parser as _;

    use super::{Cli, Command};

    #[test]
    fn clap_accepts_the_version_flag() {
        let error = match Cli::try_parse_from(["character-wizard", "--version"]) {
            Ok(_) => panic!("--version should display the package version"),
            Err(error) => error,
        };
        assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
        assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
    }

    #[test]
    fn create_does_not_require_a_template_argument() {
        let cli = Cli::try_parse_from(["character-wizard", "create"]).expect("parse create");
        let Command::Create(options) = cli.command else {
            panic!("expected create command");
        };
        assert!(options.template.is_none());
    }
}