1pub mod bibliography;
9pub mod contributors;
10pub mod dates;
11pub mod integral_name_memory;
12pub mod localization;
13pub mod locators;
14pub mod multilingual;
15pub mod processing;
16pub mod scoped;
17pub mod sorting;
18pub mod substitute;
19pub mod title_class;
20
21pub use crate::presets::{MultilingualConfigEntry, MultilingualPreset};
22pub use bibliography::{
23 AnonymousEntriesMode, ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback,
24 BibliographyConfig, BibliographyPartitionHeading, BibliographyPartitionKind,
25 BibliographyPartitionMode, BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
26};
27pub use contributors::{
28 AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry, DelimiterPrecedesLast,
29 DemoteNonDroppingParticle, DisplayAsSort, NameForm, RoleLabelDefaults, RoleLabelPreset,
30 RoleOptions, RoleOptionsEntry, RoleRendering, ShortenListOptions,
31};
32pub use dates::{DateConfig, DateConfigEntry, NoDateForm};
33pub use integral_name_memory::{
34 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, OrgAbbreviationMemoryConfig,
35 ResolvedIntegralNameMemoryConfig, ResolvedOrgAbbreviationMemoryConfig, ShortNameDisplay,
36 SubsequentNameForm,
37};
38pub use localization::{Localize, MonthFormat, Scope};
39pub use locators::{
40 LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
41 LocatorPreset, TypeClass,
42};
43pub use multilingual::{
44 MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView, ScriptConfig,
45 SegmentWrap,
46};
47pub use processing::{
48 CitationSortPolicy, Disambiguation, GivennameRule, Group, LabelConfig, LabelParams,
49 LabelPreset, Processing, ProcessingBase, ProcessingCustom, RegimeFamily, Sort, SortEntry,
50 SortKey, SortSpec,
51};
52pub use scoped::{
53 BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, DatePosition, LabelWrap,
54 RepeatedAuthorRendering, TitleTerminator,
55};
56pub use sorting::{SortingConfig, SortingLocale, SortingMultilingualMode};
57pub use substitute::{Substitute, SubstituteConfig, SubstituteKey, SubstituteTitleQuoteMode};
58
59use crate::template::DelimiterPunctuation;
60#[cfg(feature = "schema")]
61use schemars::JsonSchema;
62use serde::{Deserialize, Serialize};
63use std::collections::HashMap;
64
65#[derive(Debug, Default, PartialEq, Clone, Serialize)]
67#[cfg_attr(feature = "schema", derive(JsonSchema))]
68#[serde(rename_all = "kebab-case")]
69pub struct Config {
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub substitute: Option<SubstituteConfig>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub processing: Option<Processing>,
76 #[serde(skip_serializing_if = "Option::is_none")]
82 pub locale_override: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub localize: Option<Localize>,
86 #[serde(
89 skip_serializing_if = "Option::is_none",
90 deserialize_with = "deserialize_multilingual_config",
91 default
92 )]
93 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
94 pub multilingual: Option<MultilingualConfig>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub sorting: Option<SortingConfig>,
98 #[serde(
101 skip_serializing_if = "Option::is_none",
102 deserialize_with = "deserialize_contributor_config",
103 default
104 )]
105 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
106 pub contributors: Option<ContributorConfig>,
107 #[serde(
110 skip_serializing_if = "Option::is_none",
111 deserialize_with = "deserialize_date_config",
112 default
113 )]
114 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
115 pub dates: Option<DateConfig>,
116 #[serde(
119 skip_serializing_if = "Option::is_none",
120 deserialize_with = "deserialize_titles_config",
121 default
122 )]
123 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
124 pub titles: Option<crate::options::titles::TitlesConfig>,
125 #[serde(
128 skip_serializing_if = "Option::is_none",
129 deserialize_with = "deserialize_locator_config",
130 default
131 )]
132 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
133 pub locators: Option<LocatorConfig>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub page_range_format: Option<PageRangeFormat>,
137 #[serde(skip_serializing_if = "Option::is_none")]
140 pub page_range_delimiter: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub links: Option<LinksConfig>,
144 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
148 pub punctuation_in_quote: bool,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub punctuation: Option<PunctuationConfig>,
152 #[serde(skip_serializing_if = "Option::is_none")]
156 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
157 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
159 pub strip_periods: Option<bool>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub notes: Option<NoteConfig>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub custom: Option<HashMap<String, serde_json::Value>>,
172 #[serde(
176 flatten,
177 default,
178 skip_serializing_if = "std::collections::BTreeMap::is_empty"
179 )]
180 #[cfg_attr(feature = "schema", schemars(skip))]
181 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
182}
183
184#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
186#[cfg_attr(feature = "schema", derive(JsonSchema))]
187#[serde(rename_all = "kebab-case")]
188pub struct CitationOptions {
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub substitute: Option<SubstituteConfig>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub processing: Option<Processing>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub localize: Option<Localize>,
198 #[serde(
201 skip_serializing_if = "Option::is_none",
202 deserialize_with = "deserialize_multilingual_config",
203 default
204 )]
205 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
206 pub multilingual: Option<MultilingualConfig>,
207 #[serde(
209 skip_serializing_if = "Option::is_none",
210 deserialize_with = "deserialize_contributor_config",
211 default
212 )]
213 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
214 pub contributors: Option<ContributorConfig>,
215 #[serde(
217 skip_serializing_if = "Option::is_none",
218 deserialize_with = "deserialize_date_config",
219 default
220 )]
221 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
222 pub dates: Option<DateConfig>,
223 #[serde(
225 skip_serializing_if = "Option::is_none",
226 deserialize_with = "deserialize_titles_config",
227 default
228 )]
229 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
230 pub titles: Option<crate::options::titles::TitlesConfig>,
231 #[serde(
233 skip_serializing_if = "Option::is_none",
234 deserialize_with = "deserialize_locator_config",
235 default
236 )]
237 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
238 pub locators: Option<LocatorConfig>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub page_range_format: Option<PageRangeFormat>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub links: Option<LinksConfig>,
245 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
247 pub punctuation_in_quote: bool,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
251 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
253 pub strip_periods: Option<bool>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub notes: Option<NoteConfig>,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub label_wrap: Option<LabelWrap>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub group_delimiter: Option<CitationGroupDelimiter>,
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub custom: Option<HashMap<String, serde_json::Value>>,
272 #[serde(
276 flatten,
277 default,
278 skip_serializing_if = "std::collections::BTreeMap::is_empty"
279 )]
280 #[cfg_attr(feature = "schema", schemars(skip))]
281 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
282}
283
284#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
286#[cfg_attr(feature = "schema", derive(JsonSchema))]
287#[serde(rename_all = "kebab-case")]
288pub struct BibliographyOptions {
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub substitute: Option<SubstituteConfig>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub processing: Option<Processing>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub localize: Option<Localize>,
298 #[serde(
301 skip_serializing_if = "Option::is_none",
302 deserialize_with = "deserialize_multilingual_config",
303 default
304 )]
305 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
306 pub multilingual: Option<MultilingualConfig>,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub sorting: Option<SortingConfig>,
310 #[serde(
312 skip_serializing_if = "Option::is_none",
313 deserialize_with = "deserialize_contributor_config",
314 default
315 )]
316 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
317 pub contributors: Option<ContributorConfig>,
318 #[serde(
320 skip_serializing_if = "Option::is_none",
321 deserialize_with = "deserialize_date_config",
322 default
323 )]
324 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
325 pub dates: Option<DateConfig>,
326 #[serde(
328 skip_serializing_if = "Option::is_none",
329 deserialize_with = "deserialize_titles_config",
330 default
331 )]
332 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
333 pub titles: Option<crate::options::titles::TitlesConfig>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub page_range_format: Option<PageRangeFormat>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub article_journal: Option<ArticleJournalBibliographyConfig>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub subsequent_author_substitute: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub hanging_indent: Option<bool>,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub entry_suffix: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub separator: Option<String>,
355 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
357 pub suppress_period_after_url: bool,
358 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
360 pub entry_suffix_after_url: bool,
361 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
363 pub entry_suffix_after_doi: bool,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
370 #[serde(skip_serializing_if = "Option::is_none")]
374 pub anonymous_entries: Option<AnonymousEntriesMode>,
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub links: Option<LinksConfig>,
378 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
380 pub punctuation_in_quote: bool,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub label_mode: Option<BibliographyLabelMode>,
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub label_wrap: Option<BibliographyLabelWrap>,
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub date_position: Option<DatePosition>,
393 #[serde(skip_serializing_if = "Option::is_none")]
395 pub title_terminator: Option<TitleTerminator>,
396 #[serde(skip_serializing_if = "Option::is_none")]
398 pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
399 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
401 pub strip_periods: Option<bool>,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub custom: Option<HashMap<String, serde_json::Value>>,
405 #[serde(
409 flatten,
410 default,
411 skip_serializing_if = "std::collections::BTreeMap::is_empty"
412 )]
413 #[cfg_attr(feature = "schema", schemars(skip))]
414 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
415}
416
417#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
419#[cfg_attr(feature = "schema", derive(JsonSchema))]
420#[serde(rename_all = "kebab-case")]
421pub struct NoteConfig {
422 #[serde(skip_serializing_if = "Option::is_none")]
425 pub punctuation: Option<NoteQuotePlacement>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub number: Option<NoteNumberPlacement>,
429 #[serde(skip_serializing_if = "Option::is_none")]
432 pub order: Option<NoteMarkerOrder>,
433 #[serde(
437 flatten,
438 default,
439 skip_serializing_if = "std::collections::BTreeMap::is_empty"
440 )]
441 #[cfg_attr(feature = "schema", schemars(skip))]
442 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
443}
444
445#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
447#[cfg_attr(feature = "schema", derive(JsonSchema))]
448#[serde(rename_all = "kebab-case")]
449pub struct PunctuationConfig {
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub strong_terminal_comma_policy: Option<StrongTerminalCommaPolicy>,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub delimiter_suppressing_terminal_marks: Option<String>,
456}
457
458impl PunctuationConfig {
459 fn merge(&mut self, other: &Self) {
461 if let Some(policy) = other.strong_terminal_comma_policy {
462 self.strong_terminal_comma_policy = Some(policy);
463 }
464 if let Some(marks) = &other.delimiter_suppressing_terminal_marks {
465 self.delimiter_suppressing_terminal_marks = Some(marks.clone());
466 }
467 }
468}
469
470#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
472#[cfg_attr(feature = "schema", derive(JsonSchema))]
473#[serde(rename_all = "kebab-case")]
474pub enum StrongTerminalCommaPolicy {
475 #[default]
477 KeepBoth,
478 KeepTerminal,
480}
481
482#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
484#[cfg_attr(feature = "schema", derive(JsonSchema))]
485#[serde(rename_all = "kebab-case")]
486pub enum NoteQuotePlacement {
487 Inside,
489 Outside,
491 #[default]
495 Adaptive,
496}
497
498#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
500#[cfg_attr(feature = "schema", derive(JsonSchema))]
501#[serde(rename_all = "kebab-case")]
502pub enum NoteNumberPlacement {
503 Inside,
505 #[default]
507 Outside,
508 Same,
511}
512
513#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
515#[cfg_attr(feature = "schema", derive(JsonSchema))]
516#[serde(rename_all = "kebab-case")]
517pub enum NoteMarkerOrder {
518 Before,
520 #[default]
522 After,
523}
524
525#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
527#[cfg_attr(feature = "schema", derive(JsonSchema))]
528#[serde(rename_all = "kebab-case")]
529#[non_exhaustive]
530pub enum PageRangeFormat {
531 #[default]
533 Expanded,
534 Minimal,
536 MinimalTwo,
538 Chicago,
540 Chicago16,
542}
543
544pub mod titles;
545
546pub use title_class::{
547 TitleCategory, classified_ref_types, container_title_category, parent_serial_title_category,
548 title_category,
549};
550pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
551
552#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
554#[cfg_attr(feature = "schema", derive(JsonSchema))]
555#[serde(rename_all = "kebab-case")]
556pub struct LinksConfig {
557 #[serde(skip_serializing_if = "Option::is_none")]
559 pub doi: Option<bool>,
560 #[serde(skip_serializing_if = "Option::is_none")]
562 pub url: Option<bool>,
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub target: Option<LinkTarget>,
566 #[serde(skip_serializing_if = "Option::is_none")]
568 pub anchor: Option<LinkAnchor>,
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub strip_protocol: Option<bool>,
572}
573
574#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
576#[cfg_attr(feature = "schema", derive(JsonSchema))]
577#[serde(rename_all = "kebab-case")]
578pub enum LinkTarget {
579 Url,
580 Doi,
581 UrlOrDoi,
582 Pubmed,
583 Pmcid,
584}
585
586#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
588#[cfg_attr(feature = "schema", derive(JsonSchema))]
589#[serde(rename_all = "kebab-case")]
590pub enum LinkAnchor {
591 Title,
593 Url,
595 Doi,
597 Component,
599 Entry,
601}
602
603impl Config {
604 fn merge_punctuation(&mut self, other: &Config) {
605 let Some(other_punctuation) = &other.punctuation else {
606 return;
607 };
608 if let Some(punctuation) = &mut self.punctuation {
609 punctuation.merge(other_punctuation);
610 } else {
611 self.punctuation = Some(other_punctuation.clone());
612 }
613 }
614
615 pub fn effective_processing(&self) -> Processing {
620 self.processing.clone().unwrap_or_default()
621 }
622
623 pub fn merge(&mut self, other: &Config) {
628 crate::merge_options!(
629 self,
630 other,
631 processing,
632 locale_override,
633 localize,
634 multilingual,
635 dates,
636 titles,
637 locators,
638 page_range_format,
639 page_range_delimiter,
640 links,
641 volume_pages_delimiter,
642 locale_override,
643 strip_periods,
644 notes,
645 integral_name_memory,
646 org_abbreviation_memory,
647 custom,
648 );
649
650 self.merge_punctuation(other);
651
652 if let Some(other_sorting) = &other.sorting {
653 if let Some(this_sorting) = &mut self.sorting {
654 this_sorting.merge(other_sorting);
655 } else {
656 self.sorting = Some(other_sorting.clone());
657 }
658 }
659
660 if let Some(other_substitute) = &other.substitute {
661 if let Some(this_substitute) = &self.substitute {
662 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
663 } else {
664 self.substitute = Some(other_substitute.clone());
665 }
666 }
667
668 if let Some(other_contributors) = &other.contributors {
669 if let Some(this_contributors) = &mut self.contributors {
670 this_contributors.merge(other_contributors);
671 } else {
672 self.contributors = Some(other_contributors.clone());
673 }
674 }
675
676 if other.punctuation_in_quote {
677 self.punctuation_in_quote = true;
678 }
679 }
680
681 pub fn merged(base: &Config, override_config: &Config) -> Config {
685 let mut result = base.clone();
686 result.merge(override_config);
687 result
688 }
689}
690
691impl CitationOptions {
692 #[must_use]
694 pub fn to_config(&self) -> Config {
695 Config {
696 substitute: self.substitute.clone(),
697 processing: self.processing.clone(),
698 locale_override: None,
699 localize: self.localize.clone(),
700 multilingual: self.multilingual.clone(),
701 sorting: None,
702 contributors: self.contributors.clone(),
703 dates: self.dates.clone(),
704 titles: self.titles.clone(),
705 locators: self.locators.clone(),
706 page_range_format: self.page_range_format.clone(),
707 page_range_delimiter: None,
708 links: self.links.clone(),
709 punctuation_in_quote: self.punctuation_in_quote,
710 punctuation: None,
711 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
712 strip_periods: self.strip_periods,
713 notes: self.notes.clone(),
714 integral_name_memory: self.integral_name_memory.clone(),
715 org_abbreviation_memory: self.org_abbreviation_memory.clone(),
716 custom: self.custom.clone(),
717 unknown_fields: std::collections::BTreeMap::new(),
718 }
719 }
720
721 #[must_use]
723 pub fn merged_with(&self, base: &Config) -> Config {
724 Config::merged(base, &self.to_config())
725 }
726
727 pub fn merge(&mut self, other: &CitationOptions) {
729 crate::merge_options!(
730 self,
731 other,
732 processing,
733 localize,
734 multilingual,
735 dates,
736 titles,
737 locators,
738 page_range_format,
739 links,
740 volume_pages_delimiter,
741 strip_periods,
742 notes,
743 integral_name_memory,
744 org_abbreviation_memory,
745 label_wrap,
746 group_delimiter,
747 custom,
748 );
749
750 if let Some(other_substitute) = &other.substitute {
751 if let Some(this_substitute) = &self.substitute {
752 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
753 } else {
754 self.substitute = Some(other_substitute.clone());
755 }
756 }
757
758 if let Some(other_contributors) = &other.contributors {
759 if let Some(this_contributors) = &mut self.contributors {
760 this_contributors.merge(other_contributors);
761 } else {
762 self.contributors = Some(other_contributors.clone());
763 }
764 }
765
766 if other.punctuation_in_quote {
767 self.punctuation_in_quote = true;
768 }
769 }
770}
771
772impl BibliographyOptions {
773 #[must_use]
775 pub fn to_bibliography_config(&self) -> BibliographyConfig {
776 BibliographyConfig {
777 article_journal: self.article_journal.clone(),
778 subsequent_author_substitute: self.subsequent_author_substitute.clone(),
779 subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
780 hanging_indent: self.hanging_indent,
781 entry_suffix: self.entry_suffix.clone(),
782 separator: self.separator.clone(),
783 suppress_period_after_url: self.suppress_period_after_url,
784 entry_suffix_after_url: self.entry_suffix_after_url,
785 entry_suffix_after_doi: self.entry_suffix_after_doi,
786 custom: None,
787 compound_numeric: self.compound_numeric.clone(),
788 sort_partitioning: self.sort_partitioning.clone(),
789 anonymous_entries: self.anonymous_entries,
790 unknown_fields: std::collections::BTreeMap::new(),
791 }
792 }
793
794 #[must_use]
796 pub fn to_config(&self) -> Config {
797 Config {
798 substitute: self.substitute.clone(),
799 processing: self.processing.clone(),
800 locale_override: None,
801 localize: self.localize.clone(),
802 multilingual: self.multilingual.clone(),
803 sorting: self.sorting.clone(),
804 contributors: self.contributors.clone(),
805 dates: self.dates.clone(),
806 titles: self.titles.clone(),
807 locators: None,
808 page_range_format: self.page_range_format.clone(),
809 page_range_delimiter: None,
810 links: self.links.clone(),
811 punctuation_in_quote: self.punctuation_in_quote,
812 punctuation: None,
813 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
814 strip_periods: self.strip_periods,
815 notes: None,
816 integral_name_memory: None,
817 org_abbreviation_memory: None,
818 custom: self.custom.clone(),
819 unknown_fields: std::collections::BTreeMap::new(),
820 }
821 }
822
823 #[must_use]
825 pub fn merged_with(&self, base: &Config) -> Config {
826 Config::merged(base, &self.to_config())
827 }
828
829 pub fn merge(&mut self, other: &BibliographyOptions) {
831 crate::merge_options!(
832 self,
833 other,
834 processing,
835 localize,
836 multilingual,
837 dates,
838 titles,
839 page_range_format,
840 links,
841 volume_pages_delimiter,
842 strip_periods,
843 article_journal,
844 subsequent_author_substitute,
845 subsequent_author_substitute_rule,
846 hanging_indent,
847 entry_suffix,
848 separator,
849 compound_numeric,
850 sort_partitioning,
851 anonymous_entries,
852 label_mode,
853 label_wrap,
854 date_position,
855 title_terminator,
856 repeated_author_rendering,
857 custom,
858 );
859
860 self.merge_shared_fields(other);
861 }
862
863 fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
864 if let Some(other_sorting) = &other.sorting {
865 if let Some(this_sorting) = &mut self.sorting {
866 this_sorting.merge(other_sorting);
867 } else {
868 self.sorting = Some(other_sorting.clone());
869 }
870 }
871
872 if let Some(other_substitute) = &other.substitute {
873 if let Some(this_substitute) = &self.substitute {
874 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
875 } else {
876 self.substitute = Some(other_substitute.clone());
877 }
878 }
879
880 if let Some(other_contributors) = &other.contributors {
881 if let Some(this_contributors) = &mut self.contributors {
882 this_contributors.merge(other_contributors);
883 } else {
884 self.contributors = Some(other_contributors.clone());
885 }
886 }
887
888 if other.punctuation_in_quote {
889 self.punctuation_in_quote = true;
890 }
891 if other.suppress_period_after_url {
892 self.suppress_period_after_url = true;
893 }
894 if other.entry_suffix_after_url {
895 self.entry_suffix_after_url = true;
896 }
897 if other.entry_suffix_after_doi {
898 self.entry_suffix_after_doi = true;
899 }
900
901 for (key, value) in &other.unknown_fields {
902 self.unknown_fields.insert(key.clone(), value.clone());
903 }
904 }
905}
906
907fn deserialize_contributor_config<'de, D>(
909 deserializer: D,
910) -> Result<Option<ContributorConfig>, D::Error>
911where
912 D: serde::Deserializer<'de>,
913{
914 let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
915 Ok(value.map(|entry| entry.resolve()))
916}
917
918fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
920where
921 D: serde::Deserializer<'de>,
922{
923 let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
924 Ok(value.map(|entry| entry.resolve()))
925}
926
927fn deserialize_titles_config<'de, D>(
929 deserializer: D,
930) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
931where
932 D: serde::Deserializer<'de>,
933{
934 let value: Option<crate::options::titles::TitlesConfigEntry> =
935 Option::deserialize(deserializer)?;
936 Ok(value.map(|entry| entry.resolve()))
937}
938
939fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
941where
942 D: serde::Deserializer<'de>,
943{
944 let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
945 Ok(value.map(|entry| entry.resolve()))
946}
947
948fn deserialize_multilingual_config<'de, D>(
950 deserializer: D,
951) -> Result<Option<MultilingualConfig>, D::Error>
952where
953 D: serde::Deserializer<'de>,
954{
955 let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
956 Ok(value.map(|entry| entry.resolve()))
957}
958
959impl<'de> Deserialize<'de> for Config {
960 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
961 where
962 D: serde::Deserializer<'de>,
963 {
964 #[derive(Deserialize)]
965 #[serde(rename_all = "kebab-case")]
966 struct ConfigWire {
967 #[serde(skip_serializing_if = "Option::is_none")]
968 substitute: Option<SubstituteConfig>,
969 #[serde(skip_serializing_if = "Option::is_none")]
970 processing: Option<Processing>,
971 #[serde(skip_serializing_if = "Option::is_none")]
972 locale_override: Option<String>,
973 #[serde(skip_serializing_if = "Option::is_none")]
974 localize: Option<Localize>,
975 #[serde(
976 skip_serializing_if = "Option::is_none",
977 deserialize_with = "deserialize_multilingual_config",
978 default
979 )]
980 multilingual: Option<MultilingualConfig>,
981 #[serde(skip_serializing_if = "Option::is_none")]
982 sorting: Option<SortingConfig>,
983 #[serde(
984 skip_serializing_if = "Option::is_none",
985 deserialize_with = "deserialize_contributor_config",
986 default
987 )]
988 contributors: Option<ContributorConfig>,
989 #[serde(
990 skip_serializing_if = "Option::is_none",
991 deserialize_with = "deserialize_date_config",
992 default
993 )]
994 dates: Option<DateConfig>,
995 #[serde(
996 skip_serializing_if = "Option::is_none",
997 deserialize_with = "deserialize_titles_config",
998 default
999 )]
1000 titles: Option<crate::options::titles::TitlesConfig>,
1001 #[serde(
1002 skip_serializing_if = "Option::is_none",
1003 deserialize_with = "deserialize_locator_config",
1004 default
1005 )]
1006 locators: Option<LocatorConfig>,
1007 #[serde(skip_serializing_if = "Option::is_none")]
1008 page_range_format: Option<PageRangeFormat>,
1009 #[serde(skip_serializing_if = "Option::is_none")]
1010 page_range_delimiter: Option<String>,
1011 #[serde(skip_serializing_if = "Option::is_none")]
1012 links: Option<LinksConfig>,
1013 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1014 punctuation_in_quote: bool,
1015 #[serde(skip_serializing_if = "Option::is_none")]
1016 punctuation: Option<PunctuationConfig>,
1017 #[serde(skip_serializing_if = "Option::is_none")]
1018 volume_pages_delimiter: Option<DelimiterPunctuation>,
1019 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
1020 strip_periods: Option<bool>,
1021 #[serde(skip_serializing_if = "Option::is_none")]
1022 notes: Option<NoteConfig>,
1023 #[serde(skip_serializing_if = "Option::is_none")]
1024 integral_name_memory: Option<IntegralNameMemoryConfig>,
1025 #[serde(skip_serializing_if = "Option::is_none")]
1026 org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
1027 #[serde(default)]
1028 profile: Option<serde_yaml::Value>,
1029 #[serde(skip_serializing_if = "Option::is_none")]
1030 custom: Option<HashMap<String, serde_json::Value>>,
1031 #[serde(flatten)]
1032 unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
1033 }
1034
1035 let wire = ConfigWire::deserialize(deserializer)?;
1036 if wire.profile.is_some() {
1037 return Err(serde::de::Error::custom(
1038 "`options.profile` was removed; use `options.contributors`, `citation.options.label-wrap`, `citation.options.group-delimiter`, `bibliography.options.label-mode`, `bibliography.options.label-wrap`, `bibliography.options.date-position`, `bibliography.options.title-terminator`, `bibliography.options.repeated-author-rendering`, or `bibliography.options.volume-pages-delimiter`",
1039 ));
1040 }
1041
1042 Ok(Self {
1043 substitute: wire.substitute,
1044 processing: wire.processing,
1045 locale_override: wire.locale_override,
1046 localize: wire.localize,
1047 multilingual: wire.multilingual,
1048 sorting: wire.sorting,
1049 contributors: wire.contributors,
1050 dates: wire.dates,
1051 titles: wire.titles,
1052 locators: wire.locators,
1053 page_range_format: wire.page_range_format,
1054 page_range_delimiter: wire.page_range_delimiter,
1055 links: wire.links,
1056 punctuation_in_quote: wire.punctuation_in_quote,
1057 punctuation: wire.punctuation,
1058 volume_pages_delimiter: wire.volume_pages_delimiter,
1059 strip_periods: wire.strip_periods,
1060 notes: wire.notes,
1061 integral_name_memory: wire.integral_name_memory,
1062 org_abbreviation_memory: wire.org_abbreviation_memory,
1063 custom: wire.custom,
1064 unknown_fields: wire.unknown_fields,
1065 })
1066 }
1067}
1068
1069#[cfg(test)]
1070#[allow(
1071 clippy::unwrap_used,
1072 clippy::expect_used,
1073 clippy::panic,
1074 clippy::indexing_slicing,
1075 clippy::todo,
1076 clippy::unimplemented,
1077 clippy::unreachable,
1078 clippy::get_unwrap,
1079 reason = "Panicking is acceptable and often desired in tests."
1080)]
1081mod tests {
1082 use super::*;
1083
1084 #[test]
1085 fn test_config_default() {
1086 let config = Config::default();
1087 assert!(config.substitute.is_none());
1088 assert!(config.processing.is_none());
1089 }
1090
1091 #[test]
1092 fn test_author_date_processing() {
1093 let processing = Processing::AuthorDate;
1094 let config = processing.config();
1095 let disambiguate = config.disambiguate.unwrap();
1096 assert!(disambiguate.year_suffix);
1097 assert!(!disambiguate.names);
1098 assert!(!disambiguate.add_givenname);
1099 assert_eq!(
1100 processing.default_bibliography_sort(),
1101 Some(crate::presets::SortPreset::AuthorDateTitle)
1102 );
1103 assert_eq!(
1104 config.sort,
1105 Some(SortEntry::Preset(
1106 crate::presets::SortPreset::AuthorDateTitle
1107 ))
1108 );
1109 }
1110
1111 #[test]
1112 fn test_processing_default_bibliography_sorts() {
1113 assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
1114 assert_eq!(
1115 Processing::Note.default_bibliography_sort(),
1116 Some(crate::presets::SortPreset::AuthorTitleDate)
1117 );
1118 assert_eq!(
1119 Processing::Label(LabelConfig::default()).default_bibliography_sort(),
1120 Some(crate::presets::SortPreset::AuthorDateTitle)
1121 );
1122 }
1123
1124 #[test]
1125 fn test_processing_default_citation_sort_policy_is_explicit_only() {
1126 assert_eq!(
1127 Processing::AuthorDate.default_citation_sort_policy(),
1128 CitationSortPolicy::ExplicitOnly
1129 );
1130 assert_eq!(
1131 Processing::Note.default_citation_sort_policy(),
1132 CitationSortPolicy::ExplicitOnly
1133 );
1134 }
1135
1136 #[test]
1137 fn test_substitute_default() {
1138 let sub = Substitute::default();
1139 assert_eq!(sub.template.len(), 3);
1140 }
1141
1142 #[test]
1143 fn test_config_yaml_roundtrip() {
1144 let yaml = r#"
1145substitute:
1146 contributor-role-form: short
1147 template:
1148 - editor
1149 - title
1150processing: author-date
1151contributors:
1152 display-as-sort: first
1153 and: symbol
1154"#;
1155 let config: Config = serde_yaml::from_str(yaml).unwrap();
1156 assert!(config.substitute.is_some());
1157 assert_eq!(config.processing, Some(Processing::AuthorDate));
1158 assert_eq!(
1159 config.contributors.as_ref().unwrap().and,
1160 Some(AndOptions::Symbol)
1161 );
1162 }
1163
1164 #[test]
1165 fn test_sorting_config_deserializes_and_roundtrips() {
1166 let yaml = r#"
1167sorting:
1168 locale: sv-SE
1169 multilingual: romanized
1170"#;
1171 let config: Config = serde_yaml::from_str(yaml).unwrap();
1172 let sorting = config.sorting.as_ref().expect("sorting should parse");
1173
1174 assert_eq!(
1175 sorting.locale,
1176 Some(SortingLocale::Bcp47("sv-SE".to_string()))
1177 );
1178 assert_eq!(
1179 sorting.multilingual,
1180 Some(SortingMultilingualMode::Romanized)
1181 );
1182
1183 let serialized = serde_yaml::to_string(&config).unwrap();
1184 let reparsed: Config = serde_yaml::from_str(&serialized).unwrap();
1185 assert_eq!(reparsed.sorting, config.sorting);
1186 }
1187
1188 #[test]
1189 fn test_sorting_config_defaults_and_unknown_fields() {
1190 let yaml = r#"
1191sorting:
1192 future-key: true
1193"#;
1194 let config: Config = serde_yaml::from_str(yaml).unwrap();
1195 let sorting = config.sorting.as_ref().expect("sorting should parse");
1196
1197 assert_eq!(sorting.effective_locale(), SortingLocale::Auto);
1198 assert_eq!(
1199 sorting.effective_multilingual(),
1200 SortingMultilingualMode::Uniform
1201 );
1202 assert!(sorting.unknown_fields.contains_key("future-key"));
1203 }
1204
1205 #[test]
1206 fn test_bibliography_sorting_override_merges_partially() {
1207 let base: Config = serde_yaml::from_str(
1208 r#"
1209sorting:
1210 locale: de-DE
1211 multilingual: uniform
1212"#,
1213 )
1214 .unwrap();
1215 let bib: BibliographyOptions = serde_yaml::from_str(
1216 r#"
1217sorting:
1218 multilingual: romanized
1219"#,
1220 )
1221 .unwrap();
1222
1223 let merged = bib.merged_with(&base);
1224 let sorting = merged.sorting.expect("merged sorting should exist");
1225 assert_eq!(
1226 sorting.locale,
1227 Some(SortingLocale::Bcp47("de-DE".to_string()))
1228 );
1229 assert_eq!(
1230 sorting.multilingual,
1231 Some(SortingMultilingualMode::Romanized)
1232 );
1233 }
1234
1235 #[test]
1236 fn test_contributor_config_preset() {
1237 let yaml = r#"contributors: apa"#;
1239 let config: Config = serde_yaml::from_str(yaml).unwrap();
1240 let contributors = config.contributors.unwrap();
1241 assert_eq!(contributors.and, Some(AndOptions::Symbol));
1242 assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1243 }
1244
1245 #[test]
1246 fn test_role_label_presets_parse_and_resolve_precedence() {
1247 let yaml = r#"
1248contributors:
1249 role:
1250 preset: short-suffix
1251 roles:
1252 editor:
1253 preset: long-suffix
1254"#;
1255 let config: Config = serde_yaml::from_str(yaml).unwrap();
1256 let contributors = config.contributors.unwrap();
1257
1258 assert_eq!(
1259 contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1260 Some(RoleLabelPreset::LongSuffix)
1261 );
1262 assert_eq!(
1263 contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1264 Some(RoleLabelPreset::ShortSuffix)
1265 );
1266
1267 let yaml_scalar = r#"
1269contributors:
1270 role: short-suffix
1271"#;
1272 let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1273 let contributors2 = config2.contributors.unwrap();
1274
1275 assert_eq!(
1276 contributors2
1277 .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1278 Some(RoleLabelPreset::ShortSuffix)
1279 );
1280 }
1281
1282 #[test]
1283 fn test_role_specific_name_order_override_is_available() {
1284 let yaml = r#"
1285contributors:
1286 role:
1287 roles:
1288 translator:
1289 name-order: given-first
1290"#;
1291 let config: Config = serde_yaml::from_str(yaml).unwrap();
1292 let contributors = config.contributors.unwrap();
1293
1294 assert_eq!(
1295 contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1296 Some(&crate::template::NameOrder::GivenFirst)
1297 );
1298 }
1299
1300 #[test]
1301 fn test_date_config_preset() {
1302 let yaml = r#"dates: long"#;
1304 let config: Config = serde_yaml::from_str(yaml).unwrap();
1305 let dates = config.dates.unwrap();
1306 assert_eq!(dates.month, MonthFormat::Long);
1307 }
1308
1309 #[test]
1310 fn test_titles_config_preset() {
1311 let yaml = r#"titles: chicago"#;
1313 let config: Config = serde_yaml::from_str(yaml).unwrap();
1314 let titles = config.titles.unwrap();
1315 assert_eq!(titles.component.unwrap().quote, Some(true));
1316 assert_eq!(titles.monograph.unwrap().emph, Some(true));
1317 }
1318
1319 #[test]
1320 fn test_substitute_config_preset() {
1321 let yaml = r#"substitute: standard"#;
1323 let config: Config = serde_yaml::from_str(yaml).unwrap();
1324 assert!(config.substitute.is_some());
1325 let resolved = config.substitute.unwrap().resolve();
1326 assert_eq!(resolved.template.len(), 3);
1327 assert_eq!(resolved.template[0], SubstituteKey::Editor);
1328 }
1329
1330 #[test]
1331 fn test_substitute_config_explicit() {
1332 let yaml = r#"
1334substitute:
1335 template:
1336 - title
1337 - editor
1338"#;
1339 let config: Config = serde_yaml::from_str(yaml).unwrap();
1340 let resolved = config.substitute.unwrap().resolve();
1341 assert_eq!(resolved.template[0], SubstituteKey::Title);
1342 assert_eq!(resolved.template[1], SubstituteKey::Editor);
1343 }
1344
1345 #[test]
1346 fn test_config_merge_precedence() {
1347 let base_yaml = r#"
1349processing: author-date
1350locale-override: en-US-base
1351contributors:
1352 display-as-sort: first
1353 and: symbol
1354"#;
1355 let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1356
1357 let override_yaml = r#"
1359contributors:
1360 and: text
1361locale-override: en-US-chicago
1362"#;
1363 let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1364
1365 base.merge(&override_config);
1367
1368 assert_eq!(base.processing, Some(Processing::AuthorDate));
1370 assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1371
1372 assert_eq!(
1374 base.contributors.as_ref().unwrap().and,
1375 Some(AndOptions::Text)
1376 );
1377 }
1378
1379 #[test]
1380 fn test_config_deserializes_locale_override() {
1381 let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1382 assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1383 }
1384
1385 #[test]
1386 fn test_config_merged_convenience() {
1387 let base = Config {
1388 processing: Some(Processing::AuthorDate),
1389 ..Default::default()
1390 };
1391 let override_config = Config {
1392 punctuation_in_quote: true,
1393 ..Default::default()
1394 };
1395
1396 let merged = Config::merged(&base, &override_config);
1397
1398 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1400 assert!(merged.punctuation_in_quote);
1401 }
1402
1403 #[test]
1404 fn test_citation_options_merge_overrides_citation_fields_only() {
1405 let base = Config {
1406 processing: Some(Processing::AuthorDate),
1407 ..Default::default()
1408 };
1409
1410 let overrides = CitationOptions {
1411 strip_periods: Some(true),
1412 locators: Some(LocatorConfig::default()),
1413 ..Default::default()
1414 };
1415
1416 let merged = overrides.merged_with(&base);
1417 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1418 assert!(merged.strip_periods.unwrap_or(false));
1419 assert!(merged.locators.is_some());
1420 }
1421
1422 #[test]
1423 fn test_punctuation_config_deserializes_and_merges_field_by_field() {
1424 let yaml = r#"
1425punctuation:
1426 strong-terminal-comma-policy: keep-terminal
1427 delimiter-suppressing-terminal-marks: "?!…"
1428"#;
1429 let config: Config = serde_yaml::from_str(yaml).unwrap();
1430 let punctuation = config.punctuation.as_ref().unwrap();
1431 assert_eq!(
1432 punctuation.strong_terminal_comma_policy,
1433 Some(StrongTerminalCommaPolicy::KeepTerminal)
1434 );
1435 assert_eq!(
1436 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1437 Some("?!…")
1438 );
1439
1440 let override_config = Config {
1441 punctuation: Some(PunctuationConfig {
1442 strong_terminal_comma_policy: Some(StrongTerminalCommaPolicy::KeepBoth),
1443 delimiter_suppressing_terminal_marks: None,
1444 }),
1445 ..Default::default()
1446 };
1447 let merged = Config::merged(&config, &override_config);
1448 let punctuation = merged.punctuation.as_ref().unwrap();
1449 assert_eq!(
1450 punctuation.strong_terminal_comma_policy,
1451 Some(StrongTerminalCommaPolicy::KeepBoth)
1452 );
1453 assert_eq!(
1454 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1455 Some("?!…")
1456 );
1457 }
1458
1459 #[test]
1460 fn test_bibliography_options_merge_projects_shared_fields_only() {
1461 let base = Config {
1462 processing: Some(Processing::AuthorDate),
1463 ..Default::default()
1464 };
1465
1466 let overrides = BibliographyOptions {
1467 entry_suffix: Some(".".to_string()),
1468 separator: Some(", ".to_string()),
1469 suppress_period_after_url: true,
1470 ..Default::default()
1471 };
1472
1473 let merged = overrides.merged_with(&base);
1474 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1475 assert!(merged.locators.is_none());
1476 assert!(merged.notes.is_none());
1477 let bibliography = overrides.to_bibliography_config();
1478 assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1479 assert_eq!(bibliography.separator.as_deref(), Some(", "));
1480 assert!(bibliography.suppress_period_after_url);
1481 }
1482
1483 #[test]
1484 fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1485 let base = Config {
1486 processing: Some(Processing::AuthorDate),
1487 ..Default::default()
1488 };
1489
1490 let overrides = BibliographyOptions {
1491 contributors: Some(ContributorConfig::default()),
1492 ..Default::default()
1493 };
1494
1495 let merged = overrides.merged_with(&base);
1496 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1497 assert!(merged.contributors.is_some());
1498 }
1499
1500 #[test]
1501 fn citation_options_captures_unknown_fields_for_forward_compat() {
1502 let yaml = "future-key: true\n";
1503 let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1504 assert!(opts.unknown_fields.contains_key("future-key"));
1505 }
1506
1507 #[test]
1508 fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1509 let yaml = "future-key: true\n";
1510 let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1511 assert!(opts.unknown_fields.contains_key("future-key"));
1512 }
1513
1514 #[test]
1515 fn note_config_captures_unknown_fields_for_forward_compat() {
1516 let yaml = "punctuation: inside\nfuture-key: true\n";
1517 let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1518 assert!(cfg.unknown_fields.contains_key("future-key"));
1519 assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1520 }
1521
1522 #[test]
1523 fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1524 let yaml = r#"multilingual: romanized-translated"#;
1526 let config: Config = serde_yaml::from_str(yaml).unwrap();
1527 let ml = config.multilingual.unwrap();
1528 assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1529 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1530 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1531 }
1532
1533 #[test]
1534 fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1535 let yaml = r#"multilingual: romanized-only"#;
1537 let config: Config = serde_yaml::from_str(yaml).unwrap();
1538 let ml = config.multilingual.unwrap();
1539 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1540 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1541 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1542 }
1543
1544 #[test]
1545 fn test_multilingual_preset_romanized_script_translated_parses_and_resolves() {
1546 use crate::options::multilingual::{MultilingualSegment, MultilingualView, SegmentWrap};
1549 let yaml = r#"multilingual: romanized-script-translated"#;
1550 let config: Config = serde_yaml::from_str(yaml).unwrap();
1551 let ml = config.multilingual.unwrap();
1552 assert_eq!(
1553 ml.title_mode,
1554 Some(MultilingualMode::Pattern(vec![
1555 MultilingualSegment {
1556 view: MultilingualView::Transliterated,
1557 wrap: SegmentWrap::None,
1558 },
1559 MultilingualSegment {
1560 view: MultilingualView::OriginalScript,
1561 wrap: SegmentWrap::None,
1562 },
1563 MultilingualSegment {
1564 view: MultilingualView::Translated,
1565 wrap: SegmentWrap::Brackets,
1566 },
1567 ]))
1568 );
1569 assert_eq!(
1570 ml.name_mode,
1571 Some(MultilingualMode::Pattern(vec![
1572 MultilingualSegment {
1573 view: MultilingualView::Transliterated,
1574 wrap: SegmentWrap::None,
1575 },
1576 MultilingualSegment {
1577 view: MultilingualView::OriginalScript,
1578 wrap: SegmentWrap::None,
1579 },
1580 ]))
1581 );
1582 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1583 assert!(ml.scripts.get("Han").is_some_and(|s| s.use_native_ordering));
1584 assert!(
1585 ml.scripts
1586 .get("Hangul")
1587 .is_some_and(|s| s.use_native_ordering)
1588 );
1589 }
1590
1591 #[test]
1592 fn test_multilingual_explicit_block_transliterated_roundtrips() {
1593 let yaml = r#"
1595multilingual:
1596 title-mode: transliterated
1597 preferred-script: Latn
1598"#;
1599 let config: Config = serde_yaml::from_str(yaml).unwrap();
1600 let ml = config.multilingual.clone().unwrap();
1601 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1602 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1603
1604 let yaml2 = serde_yaml::to_string(&config).unwrap();
1605 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1606 assert_eq!(config2.multilingual, config.multilingual);
1607 }
1608
1609 #[test]
1610 fn test_multilingual_pattern_block_roundtrips() {
1611 let yaml = r#"
1614multilingual:
1615 title-mode:
1616 pattern:
1617 - view: original-script
1618 - view: translated
1619 wrap: brackets
1620"#;
1621 let config: Config = serde_yaml::from_str(yaml).unwrap();
1622 let ml = config.multilingual.clone().unwrap();
1623 assert!(
1624 matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1625 "expected Pattern mode, got {:?}",
1626 ml.title_mode
1627 );
1628
1629 let yaml2 = serde_yaml::to_string(&config).unwrap();
1630 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1631 assert_eq!(config2.multilingual, config.multilingual);
1632 }
1633}