Skip to main content

character_wizard_cli/
cli.rs

1//! Native character-wizard command-line adapter.
2
3use std::{
4    env, fs,
5    io::{self, Write as _},
6    path::{Path, PathBuf},
7    process::ExitCode,
8};
9
10use crate::character_wizard_domain::{Character, ResolvedCharacter};
11use clap::{Args, Parser, Subcommand};
12
13use crate::{
14    app_error::{AppError, ErrorKind},
15    character_wizard_creation, character_wizard_pdf_renderer, character_wizard_srd_data, data_pack,
16    rules::RulesContext,
17    share,
18    storage::{self, CharacterRepository},
19    template::resolve_template,
20};
21
22#[derive(Parser)]
23#[command(about = "Create D&D characters using SRD 5.2.1.", version)]
24struct Cli {
25    #[arg(
26        long,
27        global = true,
28        value_name = "DIRECTORY",
29        help = "Validated external campaign data pack directory"
30    )]
31    data: Option<PathBuf>,
32    #[command(subcommand)]
33    command: Command,
34}
35
36#[derive(Subcommand)]
37enum Command {
38    Create(CreateArgs),
39    Random(RandomArgs),
40    Edit(EditArgs),
41    Render(RenderArgs),
42    List(ListArgs),
43    Validate { character_json: PathBuf },
44    Show(ShowArgs),
45    Export(ExportArgs),
46    Import(ImportArgs),
47}
48
49#[derive(Args)]
50struct CreateArgs {
51    #[arg(long)]
52    template: Option<PathBuf>,
53    #[arg(long)]
54    quick: bool,
55    #[arg(
56        long,
57        value_name = "PATH",
58        help = "Path for the character JSON (defaults to <character-name>.json)"
59    )]
60    json: Option<PathBuf>,
61    #[arg(
62        short,
63        long,
64        value_name = "PATH",
65        help = "Path for the filled character sheet (defaults to <character-name>.pdf)"
66    )]
67    pdf: Option<PathBuf>,
68    #[arg(long, default_value = "character-draft.json")]
69    draft: PathBuf,
70    #[arg(long)]
71    force: bool,
72}
73
74#[derive(Args)]
75struct RandomArgs {
76    #[arg(long = "class", value_name = "CLASS")]
77    class_name: Option<String>,
78    #[arg(long, value_name = "BACKGROUND")]
79    background: Option<String>,
80    #[arg(long, value_name = "SPECIES")]
81    species: Option<String>,
82    #[arg(long)]
83    template: Option<PathBuf>,
84    #[arg(
85        long,
86        value_name = "PATH",
87        help = "Path for the character JSON (defaults to <character-name>.json)"
88    )]
89    json: Option<PathBuf>,
90    #[arg(
91        short,
92        long,
93        value_name = "PATH",
94        help = "Path for the filled character sheet (defaults to <character-name>.pdf)"
95    )]
96    pdf: Option<PathBuf>,
97    #[arg(long)]
98    force: bool,
99}
100
101#[derive(Args)]
102struct EditArgs {
103    #[command(flatten)]
104    character: CharacterRefArgs,
105    #[arg(long, help = "Render the edited character to this PDF path")]
106    pdf: Option<PathBuf>,
107    #[arg(long, requires = "pdf")]
108    template: Option<PathBuf>,
109    #[arg(long)]
110    force: bool,
111}
112
113#[derive(Args)]
114struct RenderArgs {
115    #[command(flatten)]
116    character: CharacterRefArgs,
117    #[arg(long)]
118    template: Option<PathBuf>,
119    #[arg(
120        short,
121        long,
122        value_name = "PATH",
123        help = "Path for the filled character sheet (defaults to <character-name>.pdf)"
124    )]
125    pdf: Option<PathBuf>,
126    #[arg(long)]
127    force: bool,
128}
129
130#[derive(Args)]
131struct ShowArgs {
132    #[command(flatten)]
133    character: CharacterRefArgs,
134}
135
136#[derive(Args)]
137struct ExportArgs {
138    #[command(flatten)]
139    character: CharacterRefArgs,
140}
141
142#[derive(Args)]
143struct ImportArgs {
144    #[arg(value_name = "CODE")]
145    code: String,
146    #[arg(
147        long,
148        value_name = "PATH",
149        help = "JSON destination (defaults to the character collection)"
150    )]
151    output: Option<PathBuf>,
152    #[arg(
153        long,
154        value_name = "PATH",
155        conflicts_with = "output",
156        help = "Character collection directory (defaults to the current directory)"
157    )]
158    directory: Option<PathBuf>,
159    #[arg(long)]
160    force: bool,
161}
162
163#[derive(Args)]
164struct CharacterRefArgs {
165    #[arg(value_name = "CHARACTER")]
166    character: PathBuf,
167    #[arg(
168        long,
169        value_name = "PATH",
170        help = "Character collection directory (defaults to the current directory)"
171    )]
172    directory: Option<PathBuf>,
173}
174
175#[derive(Args)]
176struct ListArgs {
177    #[arg(
178        long,
179        value_name = "PATH",
180        help = "Character collection directory (defaults to the current directory)"
181    )]
182    directory: Option<PathBuf>,
183}
184
185#[must_use]
186pub fn main() -> ExitCode {
187    let cli = Cli::parse_from(env::args_os());
188    match run(cli) {
189        Ok(()) => ExitCode::SUCCESS,
190        Err(error) => {
191            eprintln!("Error: {error}");
192            ExitCode::from(error.exit_code())
193        }
194    }
195}
196
197type CliResult = Result<(), AppError>;
198
199fn run(cli: Cli) -> CliResult {
200    let pack = cli
201        .data
202        .as_deref()
203        .map(data_pack::load)
204        .transpose()
205        .map_err(|error| (1, error))?;
206    let rules = match pack.as_ref() {
207        Some(pack) => pack.rules(),
208        None => RulesContext::srd(),
209    };
210    if let Some(reference) = rules.reference()
211        && !matches!(&cli.command, Command::Export(_))
212    {
213        println!(
214            "Using data pack: {} ({})",
215            rules.name().unwrap_or(&reference.id),
216            reference.id
217        );
218    }
219    match cli.command {
220        Command::Create(options) => create(options, rules),
221        Command::Random(options) => random(options, rules),
222        Command::Edit(options) => edit(options, rules),
223        Command::Render(options) => render(options, rules),
224        Command::List(options) => list(&options, rules),
225        Command::Validate { character_json } => validate(&character_json, rules),
226        Command::Show(options) => show(&resolve_character_path(&options.character), rules),
227        Command::Export(options) => export_character(&options, rules),
228        Command::Import(options) => import_character(options, rules),
229    }
230}
231
232fn export_character(options: &ExportArgs, rules: RulesContext<'_>) -> CliResult {
233    let character = load_character(&resolve_character_path(&options.character), rules)?;
234    println!("{}", share::encode(&character).map_err(|error| (1, error))?);
235    Ok(())
236}
237
238fn import_character(options: ImportArgs, rules: RulesContext<'_>) -> CliResult {
239    let character = resolve_rules(
240        share::decode(&options.code).map_err(|error| (1, error))?,
241        rules,
242    )?;
243    let output = options.output.unwrap_or_else(|| {
244        collection_directory(options.directory.as_deref())
245            .join(character_output_path(&character.name, "json"))
246    });
247    if output.exists() && !options.force {
248        return Err(AppError::new(
249            ErrorKind::Input,
250            format!(
251                "import destination already exists: {}; pass --force to overwrite it",
252                output.display()
253            ),
254        ));
255    }
256    write_character(&output, &character)?;
257    println!("Imported {}.", character.name);
258    println!("JSON: {}", output.display());
259    Ok(())
260}
261
262fn random(options: RandomArgs, rules: RulesContext<'_>) -> CliResult {
263    let requested_class = options.class_name.as_deref();
264    let custom_class = requested_class.and_then(|value| rules.custom_class(value));
265    let character_class = if custom_class.is_some() {
266        requested_class.map(str::to_owned)
267    } else {
268        requested_class
269            .map(|value| {
270                canonical_srd_choice(value, &character_wizard_srd_data::CLASS_NAMES, "class")
271            })
272            .transpose()?
273    };
274    let requested_species = options.species.as_deref();
275    let requested_background = options.background.as_deref();
276    let custom_background = requested_background.and_then(|value| rules.custom_background(value));
277    let background = if custom_background.is_some() {
278        requested_background.map(str::to_owned)
279    } else {
280        requested_background
281            .map(|value| {
282                canonical_srd_choice(
283                    value,
284                    &character_wizard_srd_data::BACKGROUND_NAMES,
285                    "background",
286                )
287            })
288            .transpose()?
289    };
290    let custom_species = requested_species.and_then(|value| rules.custom_species(value));
291    let species = if custom_species.is_some() {
292        requested_species.map(str::to_owned)
293    } else {
294        requested_species
295            .map(|value| {
296                canonical_srd_choice(value, &character_wizard_srd_data::SPECIES_NAMES, "species")
297            })
298            .transpose()?
299    };
300    let template = resolve_template(options.template.as_deref()).map_err(|error| (1, error))?;
301    let character = resolve_rules(
302        character_wizard_creation::generate_random_character_with_rules(
303            character_class.as_deref(),
304            background.as_deref(),
305            species.as_deref(),
306            rules,
307        )
308        .map_err(|error| (1, error.to_string()))?,
309        rules,
310    )?;
311    let json_output = options
312        .json
313        .unwrap_or_else(|| character_output_path(&character.name, "json"));
314    let pdf_output = options
315        .pdf
316        .unwrap_or_else(|| character_output_path(&character.name, "pdf"));
317    confirm_overwrite(&[&json_output, &pdf_output], options.force)?;
318    create_parent(&pdf_output)?;
319    write_character(&json_output, &character)?;
320    if let Err(error) =
321        character_wizard_pdf_renderer::render_character(&character, &template, &pdf_output)
322    {
323        let _ = fs::remove_file(&json_output);
324        return Err(AppError::new(ErrorKind::Rendering, error));
325    }
326    println!("{} is ready!", character.name);
327    println!("PDF: {}", pdf_output.display());
328    println!("JSON: {}", json_output.display());
329    Ok(())
330}
331
332fn list(options: &ListArgs, rules: RulesContext<'_>) -> CliResult {
333    let directory = collection_directory(options.directory.as_deref());
334    let characters = collection_characters(&directory, rules)?;
335    if characters.is_empty() {
336        println!("No characters found in {}.", directory.display());
337        return Ok(());
338    }
339    println!("NAME\tCLASS\tLEVEL\tSPECIES");
340    for character in characters {
341        println!(
342            "{}\t{}\t{}\t{}",
343            character.name,
344            character.class_name(),
345            character.level,
346            character.species_name()
347        );
348    }
349    Ok(())
350}
351
352fn render(options: RenderArgs, rules: RulesContext<'_>) -> CliResult {
353    let character = load_character(&resolve_character_path(&options.character), rules)?;
354    let template = resolve_template(options.template.as_deref()).map_err(|error| (1, error))?;
355    let output = options
356        .pdf
357        .unwrap_or_else(|| character_output_path(&character.name, "pdf"));
358    confirm_overwrite(&[&output], options.force)?;
359    create_parent(&output)?;
360    character_wizard_pdf_renderer::render_character(&character, &template, &output)
361        .map_err(|error| (1, error))?;
362    println!("PDF: {}", output.display());
363    Ok(())
364}
365
366fn edit(options: EditArgs, rules: RulesContext<'_>) -> CliResult {
367    let character_path = resolve_character_path(&options.character);
368    let character = load_character(&character_path, rules)?;
369    let Some(mut edited) =
370        character_wizard_creation::run_edit_interactive_with_rules(&character, rules)
371            .map_err(|error| (1, error.to_string()))?
372    else {
373        println!("No changes saved.");
374        return Ok(());
375    };
376    edited.data_pack.clone_from(&character.data_pack);
377    let edited = resolve_rules(edited, rules)?;
378    let template = options
379        .pdf
380        .as_ref()
381        .map(|_| resolve_template(options.template.as_deref()))
382        .transpose()
383        .map_err(|error| (1, error))?;
384    let mut outputs = vec![character_path.as_path()];
385    if let Some(output) = options.pdf.as_deref() {
386        outputs.push(output);
387    }
388    confirm_overwrite(&outputs, options.force)?;
389    write_character(&character_path, &edited)?;
390    if let Some(output) = options.pdf {
391        create_parent(&output)?;
392        if let Err(error) = character_wizard_pdf_renderer::render_character(
393            &edited,
394            template
395                .as_ref()
396                .expect("template resolved when output is set"),
397            &output,
398        ) {
399            return Err(AppError::new(ErrorKind::Rendering, error));
400        }
401        println!("PDF: {}", output.display());
402    }
403    println!("{} updated.", edited.name);
404    println!("JSON: {}", character_path.display());
405    Ok(())
406}
407
408fn validate(path: &Path, rules: RulesContext<'_>) -> CliResult {
409    let character = load_character(path, rules)?;
410    println!("{} is valid.", character.name);
411    Ok(())
412}
413
414fn show(path: &Path, rules: RulesContext<'_>) -> CliResult {
415    let character = load_character(path, rules)?;
416    println!("{}", character.name);
417    println!(
418        "Identity      Level {} {} {}",
419        character.level,
420        character.species_name(),
421        character.class_name()
422    );
423    println!("Background    {}", character.background_name());
424    println!("Alignment     {}", character.alignment);
425    println!(
426        "Combat        HP {} · AC {} · Speed {} ft.",
427        character.hit_points(),
428        character.armor_class(),
429        character.speed()
430    );
431    println!(
432        "Skills        {}",
433        character
434            .skills()
435            .into_iter()
436            .collect::<Vec<_>>()
437            .join(", ")
438    );
439    println!("Languages     {}", languages(&character).join(", "));
440    println!(
441        "Equipment     {}",
442        character
443            .inventory()
444            .into_iter()
445            .map(|item| if item.quantity > 1 {
446                format!("{} x {}", item.quantity, item.name)
447            } else {
448                item.name
449            })
450            .collect::<Vec<_>>()
451            .join(", ")
452    );
453    println!("Gold          {} GP", character.coins().gold);
454    Ok(())
455}
456
457fn create(options: CreateArgs, rules: RulesContext<'_>) -> CliResult {
458    let template = resolve_template(options.template.as_deref()).map_err(|error| (1, error))?;
459
460    let mut completed_draft = None;
461    let mut character = if options.quick {
462        character_wizard_creation::run_quick_interactive_with_rules(rules)
463            .map_err(|error| (1, error.to_string()))?
464    } else {
465        let draft = options.draft;
466        println!(
467            "Progress is checkpointed in {}; Ctrl-C keeps the latest completed stage.",
468            draft.display()
469        );
470        match character_wizard_creation::run_interactive_with_rules(&draft, rules) {
471            Ok(character) => {
472                completed_draft = Some(draft);
473                character
474            }
475            Err(character_wizard_creation::WizardError::SaveAndExit) => {
476                println!("Creation saved in {}.", draft.display());
477                return Ok(());
478            }
479            Err(error) => return Err(AppError::new(ErrorKind::Input, error.to_string())),
480        }
481    };
482    character.data_pack = rules.reference().cloned();
483    let character = resolve_rules(character, rules)?;
484    let json_output = options
485        .json
486        .unwrap_or_else(|| character_output_path(&character.name, "json"));
487    let pdf_output = options
488        .pdf
489        .unwrap_or_else(|| character_output_path(&character.name, "pdf"));
490    confirm_overwrite(&[&json_output, &pdf_output], options.force)?;
491    create_parent(&pdf_output)?;
492    write_character(&json_output, &character)?;
493    if let Err(error) =
494        character_wizard_pdf_renderer::render_character(&character, &template, &pdf_output)
495    {
496        let _ = fs::remove_file(&json_output);
497        return Err(AppError::new(ErrorKind::Rendering, error));
498    }
499    println!("{} is ready!", character.name);
500    println!("PDF: {}", pdf_output.display());
501    println!("JSON: {}", json_output.display());
502    if let Some(draft) = completed_draft {
503        let _ = fs::remove_file(draft);
504    }
505    Ok(())
506}
507
508fn character_output_path(name: &str, extension: &str) -> PathBuf {
509    let stem = name
510        .trim()
511        .chars()
512        .map(|character| {
513            if character.is_alphanumeric() {
514                character.to_ascii_lowercase()
515            } else {
516                '-'
517            }
518        })
519        .collect::<String>();
520    let stem = stem.trim_matches('-');
521    let stem = if stem.is_empty() { "character" } else { stem };
522    PathBuf::from(format!("{stem}.{extension}"))
523}
524
525fn canonical_srd_choice(value: &str, choices: &[&str], label: &str) -> CliResultValue<String> {
526    choices
527        .iter()
528        .find(|choice| choice.eq_ignore_ascii_case(value))
529        .map(|choice| (*choice).to_owned())
530        .ok_or_else(|| {
531            AppError::new(
532                ErrorKind::Input,
533                format!(
534                    "unknown SRD {label}: {value} (choose one of: {})",
535                    choices.join(", ")
536                ),
537            )
538        })
539}
540
541type CliResultValue<T> = Result<T, AppError>;
542
543fn collection_directory(directory: Option<&Path>) -> PathBuf {
544    CharacterRepository::new(directory)
545        .directory()
546        .to_path_buf()
547}
548
549fn resolve_character_path(character: &CharacterRefArgs) -> PathBuf {
550    CharacterRepository::new(character.directory.as_deref()).resolve(&character.character)
551}
552
553fn collection_characters(
554    directory: &Path,
555    rules: RulesContext<'_>,
556) -> Result<Vec<ResolvedCharacter>, AppError> {
557    let paths = CharacterRepository::new(Some(directory))
558        .json_paths()
559        .map_err(|error| AppError::new(ErrorKind::Persistence, error))?;
560    let mut characters = Vec::new();
561    for path in paths {
562        let source = read_character_source(&path)?;
563        let Ok(character) = Character::from_json(&source) else {
564            continue;
565        };
566        characters.push(resolve_rules(character, rules)?);
567    }
568    Ok(characters)
569}
570
571fn load_character(path: &Path, rules: RulesContext<'_>) -> Result<ResolvedCharacter, AppError> {
572    let source = read_character_source(path)?;
573    let character = Character::from_json(&source).map_err(|error| {
574        AppError::new(
575            ErrorKind::Input,
576            format!("invalid character JSON {}: {error}", path.display()),
577        )
578    })?;
579    resolve_rules(character, rules)
580}
581
582fn read_character_source(path: &Path) -> Result<String, AppError> {
583    if !path.is_file() {
584        return Err(AppError::new(
585            ErrorKind::Input,
586            format!(
587                "character JSON does not exist or is not a file: {}",
588                path.display()
589            ),
590        ));
591    }
592    fs::read_to_string(path).map_err(|error| {
593        AppError::new(
594            ErrorKind::Persistence,
595            format!("unable to read {}: {error}", path.display()),
596        )
597    })
598}
599
600fn resolve_rules(
601    character: Character,
602    rules: RulesContext<'_>,
603) -> Result<ResolvedCharacter, AppError> {
604    rules.resolve(character).map_err(|error| {
605        AppError::new(
606            ErrorKind::Rules,
607            rules.reference().map_or(error.clone(), |reference| {
608                format!("{error} in data pack {}", reference.id)
609            }),
610        )
611    })
612}
613
614fn confirm_overwrite(paths: &[&Path], force: bool) -> CliResult {
615    let existing: Vec<String> = paths
616        .iter()
617        .filter(|path| path.exists())
618        .map(|path| path.display().to_string())
619        .collect();
620    if existing.is_empty() || force {
621        return Ok(());
622    }
623    print!(
624        "Overwrite existing output(s): {}? [y/N] ",
625        existing.join(", ")
626    );
627    io::stdout()
628        .flush()
629        .map_err(|error| AppError::new(ErrorKind::Persistence, error.to_string()))?;
630    let mut answer = String::new();
631    io::stdin()
632        .read_line(&mut answer)
633        .map_err(|error| AppError::new(ErrorKind::Persistence, error.to_string()))?;
634    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
635        Ok(())
636    } else {
637        Err(AppError::new(ErrorKind::Input, "Aborted"))
638    }
639}
640
641fn create_parent(path: &Path) -> CliResult {
642    storage::create_parent(path).map_err(|error| AppError::new(ErrorKind::Persistence, error))
643}
644
645fn write_character(path: &Path, character: &Character) -> CliResult {
646    let source = character.to_json().map_err(|error| (1, error))?;
647    storage::write_atomic(path, source.as_bytes())
648        .map_err(|error| AppError::new(ErrorKind::Persistence, error))
649}
650
651fn languages(character: &Character) -> Vec<String> {
652    let mut values = vec!["Common".to_owned()];
653    values.extend(character.selected_languages.iter().cloned());
654    values.extend(character.class_choices.additional_language.iter().cloned());
655    values
656}
657
658#[cfg(test)]
659mod tests {
660    use std::{
661        path::PathBuf,
662        sync::atomic::{AtomicUsize, Ordering},
663    };
664
665    use clap::Parser as _;
666
667    use super::{
668        CharacterRefArgs, Cli, Command, ImportArgs, RandomArgs, RenderArgs, RulesContext,
669        canonical_srd_choice, character_output_path, collection_characters, import_character,
670        load_character, random, render, resolve_character_path,
671    };
672
673    #[test]
674    fn clap_accepts_the_version_flag() {
675        let error = match Cli::try_parse_from(["character-wizard", "--version"]) {
676            Ok(_) => panic!("--version should display the package version"),
677            Err(error) => error,
678        };
679        assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
680        assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
681    }
682
683    #[test]
684    fn create_does_not_require_a_template_argument() {
685        let cli = Cli::try_parse_from(["character-wizard", "create"]).expect("parse create");
686        let Command::Create(options) = cli.command else {
687            panic!("expected create command");
688        };
689        assert!(options.template.is_none());
690        assert!(!options.quick);
691        assert!(options.json.is_none());
692        assert!(options.pdf.is_none());
693    }
694
695    #[test]
696    fn create_accepts_explicit_output_paths() {
697        let cli = Cli::try_parse_from([
698            "character-wizard",
699            "create",
700            "--json",
701            "records/legolas.json",
702            "--pdf",
703            "sheets/legolas.pdf",
704        ])
705        .expect("parse create");
706        let Command::Create(options) = cli.command else {
707            panic!("expected create command");
708        };
709        assert_eq!(options.json, Some(PathBuf::from("records/legolas.json")));
710        assert_eq!(options.pdf, Some(PathBuf::from("sheets/legolas.pdf")));
711        assert!(
712            Cli::try_parse_from([
713                "character-wizard",
714                "create",
715                "--output",
716                "sheets/legolas.pdf",
717            ])
718            .is_err()
719        );
720    }
721
722    #[test]
723    fn random_accepts_case_insensitive_constraints() {
724        let cli = Cli::try_parse_from([
725            "character-wizard",
726            "random",
727            "--class",
728            "wizard",
729            "--species",
730            "dwarf",
731        ])
732        .expect("parse random");
733        let Command::Random(options) = cli.command else {
734            panic!("expected random command");
735        };
736        assert_eq!(options.class_name.as_deref(), Some("wizard"));
737        assert_eq!(options.species.as_deref(), Some("dwarf"));
738        assert_eq!(
739            canonical_srd_choice(
740                options.class_name.as_deref().expect("class"),
741                &crate::character_wizard_srd_data::CLASS_NAMES,
742                "class"
743            ),
744            Ok("Wizard".to_owned())
745        );
746    }
747
748    #[test]
749    fn create_accepts_quick_and_rejects_the_removed_json_source() {
750        let cli = Cli::try_parse_from(["character-wizard", "create", "--quick"])
751            .expect("parse quick create");
752        let Command::Create(options) = cli.command else {
753            panic!("expected create command");
754        };
755        assert!(options.quick);
756        assert!(
757            Cli::try_parse_from(["character-wizard", "create", "--from-json", "legolas.json",])
758                .is_err()
759        );
760    }
761
762    #[test]
763    fn global_data_pack_option_is_available_after_a_command() {
764        let cli = Cli::try_parse_from(["character-wizard", "random", "--data", "my-campaign"])
765            .expect("parse data pack option");
766        assert_eq!(cli.data, Some(PathBuf::from("my-campaign")));
767        assert!(matches!(cli.command, Command::Random(_)));
768    }
769
770    #[test]
771    fn export_and_import_accept_collection_or_explicit_destinations() {
772        let cli = Cli::try_parse_from([
773            "character-wizard",
774            "export",
775            "legolas",
776            "--directory",
777            "party",
778        ])
779        .expect("parse export");
780        let Command::Export(options) = cli.command else {
781            panic!("expected export command");
782        };
783        assert_eq!(options.character.character, PathBuf::from("legolas"));
784        assert_eq!(options.character.directory, Some(PathBuf::from("party")));
785
786        let cli = Cli::try_parse_from([
787            "character-wizard",
788            "import",
789            "cw1:AAAA",
790            "--output",
791            "party/legolas.json",
792            "--force",
793        ])
794        .expect("parse import");
795        let Command::Import(options) = cli.command else {
796            panic!("expected import command");
797        };
798        assert_eq!(options.output, Some(PathBuf::from("party/legolas.json")));
799        assert!(options.force);
800    }
801
802    #[test]
803    fn import_writes_canonical_json_and_refuses_an_existing_destination() {
804        static NEXT: AtomicUsize = AtomicUsize::new(0);
805        let character = crate::character_wizard_domain::Character::from_json(include_str!(
806            "../fixtures/complete-character.json"
807        ))
808        .expect("character fixture");
809        let code = crate::share::encode(&character).expect("share code");
810        let directory = std::env::temp_dir().join(format!(
811            "character-wizard-import-test-{}-{}",
812            std::process::id(),
813            NEXT.fetch_add(1, Ordering::Relaxed)
814        ));
815        let output = directory.join("binary-smoke-test.json");
816        import_character(
817            ImportArgs {
818                code: code.clone(),
819                output: None,
820                directory: Some(directory.clone()),
821                force: false,
822            },
823            RulesContext::srd(),
824        )
825        .expect("import character");
826        let imported =
827            load_character(&output, RulesContext::srd()).expect("load imported character");
828        assert_eq!(imported, character);
829        let error = import_character(
830            ImportArgs {
831                code,
832                output: None,
833                directory: Some(directory.clone()),
834                force: false,
835            },
836            RulesContext::srd(),
837        )
838        .expect_err("refuse collision");
839        assert!(error.message().contains("--force"));
840        std::fs::remove_file(output).expect("remove imported fixture");
841        std::fs::remove_dir(directory).expect("remove import collection");
842    }
843
844    #[test]
845    fn edit_accepts_an_optional_pdf_output() {
846        let cli = Cli::try_parse_from([
847            "character-wizard",
848            "edit",
849            "records/legolas.json",
850            "--template",
851            "assets/character-sheet.pdf",
852            "--pdf",
853            "sheets/legolas.pdf",
854            "--force",
855        ])
856        .expect("parse edit");
857        let Command::Edit(options) = cli.command else {
858            panic!("expected edit command");
859        };
860        assert_eq!(
861            options.character.character,
862            PathBuf::from("records/legolas.json")
863        );
864        assert_eq!(
865            options.template,
866            Some(PathBuf::from("assets/character-sheet.pdf"))
867        );
868        assert_eq!(options.pdf, Some(PathBuf::from("sheets/legolas.pdf")));
869        assert!(options.force);
870    }
871
872    #[test]
873    fn render_accepts_explicit_paths() {
874        let cli = Cli::try_parse_from([
875            "character-wizard",
876            "render",
877            "records/legolas.json",
878            "--template",
879            "assets/character-sheet.pdf",
880            "--pdf",
881            "sheets/legolas.pdf",
882            "--force",
883        ])
884        .expect("parse render");
885        let Command::Render(options) = cli.command else {
886            panic!("expected render command");
887        };
888        assert_eq!(
889            options.character.character,
890            PathBuf::from("records/legolas.json")
891        );
892        assert_eq!(
893            options.template,
894            Some(PathBuf::from("assets/character-sheet.pdf"))
895        );
896        assert_eq!(options.pdf, Some(PathBuf::from("sheets/legolas.pdf")));
897        assert!(options.force);
898    }
899
900    #[test]
901    fn render_writes_a_pdf_for_a_valid_character() {
902        static NEXT: AtomicUsize = AtomicUsize::new(0);
903        let output = std::env::temp_dir().join(format!(
904            "character-wizard-render-test-{}-{}.pdf",
905            std::process::id(),
906            NEXT.fetch_add(1, Ordering::Relaxed)
907        ));
908        render(
909            RenderArgs {
910                character: CharacterRefArgs {
911                    character: PathBuf::from("fixtures/complete-character.json"),
912                    directory: None,
913                },
914                template: Some(PathBuf::from("assets/character-sheet.pdf")),
915                pdf: Some(output.clone()),
916                force: true,
917            },
918            RulesContext::srd(),
919        )
920        .expect("render fixture");
921        assert!(output.is_file());
922        std::fs::remove_file(output).expect("remove rendered PDF");
923    }
924
925    #[test]
926    fn random_pack_content_round_trips_and_renders_its_mechanics() {
927        static NEXT: AtomicUsize = AtomicUsize::new(0);
928        let directory = std::env::temp_dir().join(format!(
929            "character-wizard-pack-species-test-{}-{}",
930            std::process::id(),
931            NEXT.fetch_add(1, Ordering::Relaxed)
932        ));
933        std::fs::create_dir(&directory).expect("create pack");
934        std::fs::write(
935            directory.join("data-pack.json"),
936            r#"{"format_version":1,"id":"moon-pack","version":1,"name":"Moon Pack","files":{"species":"species.json","backgrounds":"backgrounds.json","equipment":"equipment.json"}}"#,
937        )
938        .expect("write manifest");
939        std::fs::write(
940            directory.join("species.json"),
941            r#"[{"id":"moonfolk","name":"Moonfolk","sizes":["Small"],"speed":35,"darkvision_range":60,"traits":["Moonlit Step"]}]"#,
942        )
943        .expect("write species");
944        std::fs::write(
945            directory.join("backgrounds.json"),
946            r#"[{"id":"lunar-scout","name":"Lunar Scout","abilities":["dexterity","wisdom","charisma"],"skills":["Perception","Survival"],"feat":"Alert","tool":"Navigator's Tools","equipment":[{"equipment_id":"moonblade"},{"name":"Arrow","quantity":20}],"equipment_gold":12,"gold_alternative":50}]"#,
947        )
948        .expect("write backgrounds");
949        std::fs::write(
950            directory.join("equipment.json"),
951            r#"[{"id":"moonblade","name":"Moonblade","kind":{"type":"weapon","category":"Simple","kind":"Melee","properties":["Finesse","Light"],"mastery":"Vex","damage":"1d8","damage_type":"Radiant","normal_range":5}}]"#,
952        )
953        .expect("write equipment");
954        let pack = crate::data_pack::load(&directory).expect("load pack");
955        let json = directory.join("moonfolk.json");
956        let pdf = directory.join("moonfolk.pdf");
957        random(
958            RandomArgs {
959                class_name: Some("fighter".to_owned()),
960                background: Some("lunar-scout".to_owned()),
961                species: Some("moonfolk".to_owned()),
962                template: Some(PathBuf::from("assets/character-sheet.pdf")),
963                json: Some(json.clone()),
964                pdf: Some(pdf.clone()),
965                force: true,
966            },
967            pack.rules(),
968        )
969        .expect("generate pack species");
970
971        assert!(
972            load_character(&json, RulesContext::srd())
973                .expect_err("pack reference is required")
974                .message()
975                .contains("requires data pack moon-pack")
976        );
977        let character = load_character(&json, pack.rules()).expect("reload pack character");
978        assert_eq!(
979            character
980                .data_pack
981                .as_ref()
982                .expect("pack reference")
983                .version,
984            1
985        );
986        assert_eq!(character.species, "moonfolk");
987        assert_eq!(character.background, "lunar-scout");
988        assert_eq!(character.background_name(), "Lunar Scout");
989        assert!(character.skills().contains("Perception"));
990        assert!(
991            character
992                .all_tool_proficiencies()
993                .contains(&"Navigator's Tools".to_owned())
994        );
995        assert_eq!(character.species_name(), "Moonfolk");
996        assert_eq!(character.size, "Small");
997        assert_eq!(character.speed(), 35);
998        assert_eq!(character.darkvision_range(), Some(60));
999        assert!(
1000            character
1001                .species_traits()
1002                .iter()
1003                .any(|value| value == "Moonlit Step")
1004        );
1005        let field = crate::character_wizard_pdf_renderer::read_field_value(&pdf, "Text8")
1006            .expect("read species field");
1007        assert_eq!(field.as_str().expect("text value"), b"Moonfolk");
1008        let field = crate::character_wizard_pdf_renderer::read_field_value(&pdf, "Text6")
1009            .expect("read background field");
1010        assert_eq!(field.as_str().expect("text value"), b"Lunar Scout");
1011        std::fs::remove_dir_all(directory).expect("remove pack");
1012    }
1013
1014    #[test]
1015    fn a_bare_name_resolves_from_the_selected_collection_directory() {
1016        let character = CharacterRefArgs {
1017            character: PathBuf::from("legolas"),
1018            directory: Some(PathBuf::from("party")),
1019        };
1020        assert_eq!(
1021            resolve_character_path(&character),
1022            PathBuf::from("party/legolas.json")
1023        );
1024    }
1025
1026    #[test]
1027    fn collection_listing_loads_json_characters_in_path_order() {
1028        static NEXT: AtomicUsize = AtomicUsize::new(0);
1029        let directory = std::env::temp_dir().join(format!(
1030            "character-wizard-collection-test-{}-{}",
1031            std::process::id(),
1032            NEXT.fetch_add(1, Ordering::Relaxed)
1033        ));
1034        std::fs::create_dir(&directory).expect("create collection");
1035        std::fs::write(
1036            directory.join("rogue.json"),
1037            include_str!("../fixtures/complete-character.json"),
1038        )
1039        .expect("write character");
1040        std::fs::write(directory.join("notes.txt"), "not a character").expect("write note");
1041        std::fs::write(
1042            directory.join("package.json"),
1043            r#"{"name":"other-project"}"#,
1044        )
1045        .expect("write unrelated JSON");
1046        std::fs::write(directory.join("broken.json"), "{not json").expect("write malformed JSON");
1047
1048        let characters =
1049            collection_characters(&directory, RulesContext::srd()).expect("load collection");
1050        std::fs::remove_dir_all(&directory).expect("remove collection");
1051        assert_eq!(characters.len(), 1);
1052        assert_eq!(characters[0].name, "Binary Smoke Test");
1053    }
1054
1055    #[test]
1056    fn character_name_becomes_safe_default_output_name() {
1057        assert_eq!(
1058            character_output_path("Legolas", "json"),
1059            PathBuf::from("legolas.json")
1060        );
1061        assert_eq!(
1062            character_output_path("Aelinor of Rivendell", "pdf"),
1063            PathBuf::from("aelinor-of-rivendell.pdf")
1064        );
1065        assert_eq!(
1066            character_output_path("../../", "json"),
1067            PathBuf::from("character.json")
1068        );
1069    }
1070}