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