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