1use 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}