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