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 the current directory)"
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 the current directory)"
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 the current directory)"
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    let paths = CharacterRepository::new(Some(directory))
562        .json_paths()
563        .map_err(|error| AppError::new(ErrorKind::Persistence, error))?;
564    let mut characters = Vec::new();
565    for path in paths {
566        let source = read_character_source(&path)?;
567        let Ok(character) = Character::from_json(&source) else {
568            continue;
569        };
570        characters.push(resolve_rules(character, rules)?);
571    }
572    Ok(characters)
573}
574
575fn load_character(path: &Path, rules: RulesContext<'_>) -> Result<ResolvedCharacter, AppError> {
576    let source = read_character_source(path)?;
577    let character = Character::from_json(&source).map_err(|error| {
578        AppError::new(
579            ErrorKind::Input,
580            format!("invalid character JSON {}: {error}", path.display()),
581        )
582    })?;
583    resolve_rules(character, rules)
584}
585
586fn read_character_source(path: &Path) -> Result<String, AppError> {
587    if !path.is_file() {
588        return Err(AppError::new(
589            ErrorKind::Input,
590            format!(
591                "character JSON does not exist or is not a file: {}",
592                path.display()
593            ),
594        ));
595    }
596    fs::read_to_string(path).map_err(|error| {
597        AppError::new(
598            ErrorKind::Persistence,
599            format!("unable to read {}: {error}", path.display()),
600        )
601    })
602}
603
604fn resolve_rules(
605    character: Character,
606    rules: RulesContext<'_>,
607) -> Result<ResolvedCharacter, AppError> {
608    rules.resolve(character).map_err(|error| {
609        AppError::new(
610            ErrorKind::Rules,
611            rules.reference().map_or(error.clone(), |reference| {
612                format!("{error} in data pack {}", reference.id)
613            }),
614        )
615    })
616}
617
618fn confirm_overwrite(paths: &[&Path], force: bool) -> CliResult {
619    let existing: Vec<String> = paths
620        .iter()
621        .filter(|path| path.exists())
622        .map(|path| path.display().to_string())
623        .collect();
624    if existing.is_empty() || force {
625        return Ok(());
626    }
627    print!(
628        "Overwrite existing output(s): {}? [y/N] ",
629        existing.join(", ")
630    );
631    io::stdout()
632        .flush()
633        .map_err(|error| AppError::new(ErrorKind::Persistence, error.to_string()))?;
634    let mut answer = String::new();
635    io::stdin()
636        .read_line(&mut answer)
637        .map_err(|error| AppError::new(ErrorKind::Persistence, error.to_string()))?;
638    if matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
639        Ok(())
640    } else {
641        Err(AppError::new(ErrorKind::Input, "Aborted"))
642    }
643}
644
645fn create_parent(path: &Path) -> CliResult {
646    storage::create_parent(path).map_err(|error| AppError::new(ErrorKind::Persistence, error))
647}
648
649fn write_character(path: &Path, character: &Character) -> CliResult {
650    let source = character.to_json().map_err(|error| (1, error))?;
651    storage::write_atomic(path, source.as_bytes())
652        .map_err(|error| AppError::new(ErrorKind::Persistence, error))
653}
654
655fn languages(character: &Character) -> Vec<String> {
656    let mut values = vec!["Common".to_owned()];
657    values.extend(character.selected_languages.iter().cloned());
658    values.extend(character.class_choices.additional_language.iter().cloned());
659    values
660}
661
662#[cfg(test)]
663mod tests {
664    use std::{
665        path::PathBuf,
666        sync::atomic::{AtomicUsize, Ordering},
667    };
668
669    use clap::Parser as _;
670
671    use super::{
672        CharacterRefArgs, Cli, Command, ImportArgs, RandomArgs, RenderArgs, RulesContext,
673        canonical_srd_choice, character_output_path, collection_characters, import_character,
674        load_character, random, render, resolve_character_path,
675    };
676
677    #[test]
678    fn clap_accepts_the_version_flag() {
679        let error = match Cli::try_parse_from(["character-wizard", "--version"]) {
680            Ok(_) => panic!("--version should display the package version"),
681            Err(error) => error,
682        };
683        assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
684        assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
685    }
686
687    #[test]
688    fn create_does_not_require_a_template_argument() {
689        let cli = Cli::try_parse_from(["character-wizard", "create"]).expect("parse create");
690        let Command::Create(options) = cli.command else {
691            panic!("expected create command");
692        };
693        assert!(options.template.is_none());
694        assert!(!options.quick);
695        assert!(options.json.is_none());
696        assert!(options.output.is_none());
697    }
698
699    #[test]
700    fn create_accepts_explicit_output_paths() {
701        let cli = Cli::try_parse_from([
702            "character-wizard",
703            "create",
704            "--json",
705            "records/legolas.json",
706            "--output",
707            "sheets/legolas.pdf",
708        ])
709        .expect("parse create");
710        let Command::Create(options) = cli.command else {
711            panic!("expected create command");
712        };
713        assert_eq!(options.json, Some(PathBuf::from("records/legolas.json")));
714        assert_eq!(options.output, Some(PathBuf::from("sheets/legolas.pdf")));
715    }
716
717    #[test]
718    fn random_accepts_case_insensitive_constraints() {
719        let cli = Cli::try_parse_from([
720            "character-wizard",
721            "random",
722            "--class",
723            "wizard",
724            "--species",
725            "dwarf",
726        ])
727        .expect("parse random");
728        let Command::Random(options) = cli.command else {
729            panic!("expected random command");
730        };
731        assert_eq!(options.class_name.as_deref(), Some("wizard"));
732        assert_eq!(options.species.as_deref(), Some("dwarf"));
733        assert_eq!(
734            canonical_srd_choice(
735                options.class_name.as_deref().expect("class"),
736                &crate::character_wizard_srd_data::CLASS_NAMES,
737                "class"
738            ),
739            Ok("Wizard".to_owned())
740        );
741    }
742
743    #[test]
744    fn create_accepts_quick_but_not_a_json_source() {
745        let cli = Cli::try_parse_from(["character-wizard", "create", "--quick"])
746            .expect("parse quick create");
747        let Command::Create(options) = cli.command else {
748            panic!("expected create command");
749        };
750        assert!(options.quick);
751        assert!(
752            Cli::try_parse_from([
753                "character-wizard",
754                "create",
755                "--quick",
756                "--from-json",
757                "legolas.json",
758            ])
759            .is_err()
760        );
761    }
762
763    #[test]
764    fn global_data_pack_option_is_available_after_a_command() {
765        let cli = Cli::try_parse_from(["character-wizard", "random", "--data", "my-campaign"])
766            .expect("parse data pack option");
767        assert_eq!(cli.data, Some(PathBuf::from("my-campaign")));
768        assert!(matches!(cli.command, Command::Random(_)));
769    }
770
771    #[test]
772    fn export_and_import_accept_collection_or_explicit_destinations() {
773        let cli = Cli::try_parse_from([
774            "character-wizard",
775            "export",
776            "legolas",
777            "--directory",
778            "party",
779        ])
780        .expect("parse export");
781        let Command::Export(options) = cli.command else {
782            panic!("expected export command");
783        };
784        assert_eq!(options.character.character, PathBuf::from("legolas"));
785        assert_eq!(options.character.directory, Some(PathBuf::from("party")));
786
787        let cli = Cli::try_parse_from([
788            "character-wizard",
789            "import",
790            "cw1:AAAA",
791            "--output",
792            "party/legolas.json",
793            "--force",
794        ])
795        .expect("parse import");
796        let Command::Import(options) = cli.command else {
797            panic!("expected import command");
798        };
799        assert_eq!(options.output, Some(PathBuf::from("party/legolas.json")));
800        assert!(options.force);
801    }
802
803    #[test]
804    fn import_writes_canonical_json_and_refuses_an_existing_destination() {
805        static NEXT: AtomicUsize = AtomicUsize::new(0);
806        let character = crate::character_wizard_domain::Character::from_json(include_str!(
807            "../fixtures/complete-character.json"
808        ))
809        .expect("character fixture");
810        let code = crate::share::encode(&character).expect("share code");
811        let directory = std::env::temp_dir().join(format!(
812            "character-wizard-import-test-{}-{}",
813            std::process::id(),
814            NEXT.fetch_add(1, Ordering::Relaxed)
815        ));
816        let output = directory.join("binary-smoke-test.json");
817        import_character(
818            ImportArgs {
819                code: code.clone(),
820                output: None,
821                directory: Some(directory.clone()),
822                force: false,
823            },
824            RulesContext::srd(),
825        )
826        .expect("import character");
827        let imported =
828            load_character(&output, RulesContext::srd()).expect("load imported character");
829        assert_eq!(imported, character);
830        let error = import_character(
831            ImportArgs {
832                code,
833                output: None,
834                directory: Some(directory.clone()),
835                force: false,
836            },
837            RulesContext::srd(),
838        )
839        .expect_err("refuse collision");
840        assert!(error.message().contains("--force"));
841        std::fs::remove_file(output).expect("remove imported fixture");
842        std::fs::remove_dir(directory).expect("remove import collection");
843    }
844
845    #[test]
846    fn edit_accepts_an_optional_pdf_output() {
847        let cli = Cli::try_parse_from([
848            "character-wizard",
849            "edit",
850            "records/legolas.json",
851            "--template",
852            "assets/character-sheet.pdf",
853            "--output",
854            "sheets/legolas.pdf",
855            "--force",
856        ])
857        .expect("parse edit");
858        let Command::Edit(options) = cli.command else {
859            panic!("expected edit command");
860        };
861        assert_eq!(
862            options.character.character,
863            PathBuf::from("records/legolas.json")
864        );
865        assert_eq!(
866            options.template,
867            Some(PathBuf::from("assets/character-sheet.pdf"))
868        );
869        assert_eq!(options.output, Some(PathBuf::from("sheets/legolas.pdf")));
870        assert!(options.force);
871    }
872
873    #[test]
874    fn render_accepts_explicit_paths() {
875        let cli = Cli::try_parse_from([
876            "character-wizard",
877            "render",
878            "records/legolas.json",
879            "--template",
880            "assets/character-sheet.pdf",
881            "--output",
882            "sheets/legolas.pdf",
883            "--force",
884        ])
885        .expect("parse render");
886        let Command::Render(options) = cli.command else {
887            panic!("expected render command");
888        };
889        assert_eq!(
890            options.character.character,
891            PathBuf::from("records/legolas.json")
892        );
893        assert_eq!(
894            options.template,
895            Some(PathBuf::from("assets/character-sheet.pdf"))
896        );
897        assert_eq!(options.output, Some(PathBuf::from("sheets/legolas.pdf")));
898        assert!(options.force);
899    }
900
901    #[test]
902    fn render_writes_a_pdf_for_a_valid_character() {
903        static NEXT: AtomicUsize = AtomicUsize::new(0);
904        let output = std::env::temp_dir().join(format!(
905            "character-wizard-render-test-{}-{}.pdf",
906            std::process::id(),
907            NEXT.fetch_add(1, Ordering::Relaxed)
908        ));
909        render(
910            RenderArgs {
911                character: CharacterRefArgs {
912                    character: PathBuf::from("fixtures/complete-character.json"),
913                    directory: None,
914                },
915                template: Some(PathBuf::from("assets/character-sheet.pdf")),
916                output: Some(output.clone()),
917                force: true,
918            },
919            RulesContext::srd(),
920        )
921        .expect("render fixture");
922        assert!(output.is_file());
923        std::fs::remove_file(output).expect("remove rendered PDF");
924    }
925
926    #[test]
927    fn random_pack_content_round_trips_and_renders_its_mechanics() {
928        static NEXT: AtomicUsize = AtomicUsize::new(0);
929        let directory = std::env::temp_dir().join(format!(
930            "character-wizard-pack-species-test-{}-{}",
931            std::process::id(),
932            NEXT.fetch_add(1, Ordering::Relaxed)
933        ));
934        std::fs::create_dir(&directory).expect("create pack");
935        std::fs::write(
936            directory.join("data-pack.json"),
937            r#"{"format_version":1,"id":"moon-pack","version":1,"name":"Moon Pack","files":{"species":"species.json","backgrounds":"backgrounds.json","equipment":"equipment.json"}}"#,
938        )
939        .expect("write manifest");
940        std::fs::write(
941            directory.join("species.json"),
942            r#"[{"id":"moonfolk","name":"Moonfolk","sizes":["Small"],"speed":35,"darkvision_range":60,"traits":["Moonlit Step"]}]"#,
943        )
944        .expect("write species");
945        std::fs::write(
946            directory.join("backgrounds.json"),
947            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}]"#,
948        )
949        .expect("write backgrounds");
950        std::fs::write(
951            directory.join("equipment.json"),
952            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}}]"#,
953        )
954        .expect("write equipment");
955        let pack = crate::data_pack::load(&directory).expect("load pack");
956        let json = directory.join("moonfolk.json");
957        let pdf = directory.join("moonfolk.pdf");
958        random(
959            RandomArgs {
960                class_name: Some("fighter".to_owned()),
961                background: Some("lunar-scout".to_owned()),
962                species: Some("moonfolk".to_owned()),
963                template: Some(PathBuf::from("assets/character-sheet.pdf")),
964                json: Some(json.clone()),
965                output: Some(pdf.clone()),
966                force: true,
967            },
968            pack.rules(),
969        )
970        .expect("generate pack species");
971
972        assert!(
973            load_character(&json, RulesContext::srd())
974                .expect_err("pack reference is required")
975                .message()
976                .contains("requires data pack moon-pack")
977        );
978        let character = load_character(&json, pack.rules()).expect("reload pack character");
979        assert_eq!(
980            character
981                .data_pack
982                .as_ref()
983                .expect("pack reference")
984                .version,
985            1
986        );
987        assert_eq!(character.species, "moonfolk");
988        assert_eq!(character.background, "lunar-scout");
989        assert_eq!(character.background_name(), "Lunar Scout");
990        assert!(character.skills().contains("Perception"));
991        assert!(
992            character
993                .all_tool_proficiencies()
994                .contains(&"Navigator's Tools".to_owned())
995        );
996        assert_eq!(character.species_name(), "Moonfolk");
997        assert_eq!(character.size, "Small");
998        assert_eq!(character.speed(), 35);
999        assert_eq!(character.darkvision_range(), Some(60));
1000        assert!(
1001            character
1002                .species_traits()
1003                .iter()
1004                .any(|value| value == "Moonlit Step")
1005        );
1006        let field = crate::character_wizard_pdf_renderer::read_field_value(&pdf, "Text8")
1007            .expect("read species field");
1008        assert_eq!(field.as_str().expect("text value"), b"Moonfolk");
1009        let field = crate::character_wizard_pdf_renderer::read_field_value(&pdf, "Text6")
1010            .expect("read background field");
1011        assert_eq!(field.as_str().expect("text value"), b"Lunar Scout");
1012        std::fs::remove_dir_all(directory).expect("remove pack");
1013    }
1014
1015    #[test]
1016    fn a_bare_name_resolves_from_the_selected_collection_directory() {
1017        let character = CharacterRefArgs {
1018            character: PathBuf::from("legolas"),
1019            directory: Some(PathBuf::from("party")),
1020        };
1021        assert_eq!(
1022            resolve_character_path(&character),
1023            PathBuf::from("party/legolas.json")
1024        );
1025    }
1026
1027    #[test]
1028    fn collection_listing_loads_json_characters_in_path_order() {
1029        static NEXT: AtomicUsize = AtomicUsize::new(0);
1030        let directory = std::env::temp_dir().join(format!(
1031            "character-wizard-collection-test-{}-{}",
1032            std::process::id(),
1033            NEXT.fetch_add(1, Ordering::Relaxed)
1034        ));
1035        std::fs::create_dir(&directory).expect("create collection");
1036        std::fs::write(
1037            directory.join("rogue.json"),
1038            include_str!("../fixtures/complete-character.json"),
1039        )
1040        .expect("write character");
1041        std::fs::write(directory.join("notes.txt"), "not a character").expect("write note");
1042        std::fs::write(
1043            directory.join("package.json"),
1044            r#"{"name":"other-project"}"#,
1045        )
1046        .expect("write unrelated JSON");
1047        std::fs::write(directory.join("broken.json"), "{not json").expect("write malformed JSON");
1048
1049        let characters =
1050            collection_characters(&directory, RulesContext::srd()).expect("load collection");
1051        std::fs::remove_dir_all(&directory).expect("remove collection");
1052        assert_eq!(characters.len(), 1);
1053        assert_eq!(characters[0].name, "Binary Smoke Test");
1054    }
1055
1056    #[test]
1057    fn character_name_becomes_safe_default_output_name() {
1058        assert_eq!(
1059            character_output_path("Legolas", "json"),
1060            PathBuf::from("legolas.json")
1061        );
1062        assert_eq!(
1063            character_output_path("Aelinor of Rivendell", "pdf"),
1064            PathBuf::from("aelinor-of-rivendell.pdf")
1065        );
1066        assert_eq!(
1067            character_output_path("../../", "json"),
1068            PathBuf::from("character.json")
1069        );
1070    }
1071}