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