1pub mod bibliography;
9pub mod cascade;
10pub mod contributors;
11pub mod dates;
12pub mod integral_name_memory;
13pub mod localization;
14pub mod locators;
15pub mod multilingual;
16pub mod processing;
17pub mod scoped;
18pub mod sorting;
19pub mod substitute;
20pub mod title_class;
21
22pub use crate::presets::{MultilingualConfigEntry, MultilingualPreset};
23pub use bibliography::{
24 AnonymousEntriesMode, ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback,
25 BibliographyConfig, BibliographyPartitionHeading, BibliographyPartitionKind,
26 BibliographyPartitionMode, BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
27};
28pub use cascade::ScopedRawOptions;
29pub use contributors::{
30 AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry,
31 ContributorSuppressionRule, DelimiterPrecedesLast, DemoteNonDroppingParticle, DisplayAsSort,
32 NameForm, RoleLabelDefaults, RoleLabelPresentation, RoleLabelPreset, RoleOptions,
33 RoleOptionsEntry, RoleRendering, ShortenListOptions,
34};
35pub use dates::{DateConfig, DateConfigEntry, DateRangeFormat, NoDateForm};
36pub use integral_name_memory::{
37 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, OrgAbbreviationMemoryConfig,
38 ResolvedIntegralNameMemoryConfig, ResolvedOrgAbbreviationMemoryConfig, ShortNameDisplay,
39 SubsequentNameForm,
40};
41pub use localization::{Localize, MonthFormat, Scope};
42pub use locators::{
43 LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
44 LocatorPreset, TypeClass,
45};
46pub use multilingual::{
47 MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView,
48 PunctuationRealization, PunctuationStyle, PunctuationWidth, RealizationDefault, ScriptConfig,
49 SegmentWrap, TermLocale,
50};
51pub use processing::{
52 CitationSortPolicy, Disambiguation, GivennameRule, Group, LabelConfig, LabelParams,
53 LabelPreset, Processing, ProcessingBase, ProcessingCustom, RegimeFamily, Sort, SortEntry,
54 SortKey, SortSpec,
55};
56pub use scoped::{
57 BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, DatePosition, LabelWrap,
58 RepeatedAuthorRendering, TitleTerminator,
59};
60pub use sorting::{SortingConfig, SortingLocale, SortingMultilingualMode};
61pub use substitute::{
62 Substitute, SubstituteConfig, SubstituteContributor, SubstituteField, SubstituteKey,
63 SubstituteTitleQuoteMode,
64};
65
66use crate::template::DelimiterPunctuation;
67#[cfg(feature = "schema")]
68use schemars::JsonSchema;
69use serde::{Deserialize, Serialize};
70use std::collections::HashMap;
71
72#[derive(Debug, Default, PartialEq, Clone, Serialize)]
74#[cfg_attr(feature = "schema", derive(JsonSchema))]
75#[serde(rename_all = "kebab-case")]
76pub struct Config {
77 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
79 pub messages: HashMap<String, String>,
80 #[serde(
83 skip_serializing_if = "Option::is_none",
84 deserialize_with = "deserialize_substitute_config",
85 default
86 )]
87 pub substitute: Option<SubstituteConfig>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub processing: Option<Processing>,
91 #[serde(skip_serializing_if = "Option::is_none")]
97 pub locale_override: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub localize: Option<Localize>,
101 #[serde(
104 skip_serializing_if = "Option::is_none",
105 deserialize_with = "deserialize_multilingual_config",
106 default
107 )]
108 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
109 pub multilingual: Option<MultilingualConfig>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub sorting: Option<SortingConfig>,
113 #[serde(
116 skip_serializing_if = "Option::is_none",
117 deserialize_with = "deserialize_contributor_config",
118 default
119 )]
120 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
121 pub contributors: Option<ContributorConfig>,
122 #[serde(
125 skip_serializing_if = "Option::is_none",
126 deserialize_with = "deserialize_date_config",
127 default
128 )]
129 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
130 pub dates: Option<DateConfig>,
131 #[serde(
134 skip_serializing_if = "Option::is_none",
135 deserialize_with = "deserialize_titles_config",
136 default
137 )]
138 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
139 pub titles: Option<crate::options::titles::TitlesConfig>,
140 #[serde(
143 skip_serializing_if = "Option::is_none",
144 deserialize_with = "deserialize_locator_config",
145 default
146 )]
147 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
148 pub locators: Option<LocatorConfig>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub page_range_format: Option<PageRangeFormat>,
152 #[serde(skip_serializing_if = "Option::is_none")]
155 pub page_range_delimiter: Option<String>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub links: Option<LinksConfig>,
159 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166 pub punctuation_in_quote: bool,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub punctuation: Option<PunctuationConfig>,
170 #[serde(skip_serializing_if = "Option::is_none")]
174 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
175 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
177 pub strip_periods: Option<bool>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub notes: Option<NoteConfig>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub custom: Option<HashMap<String, serde_json::Value>>,
190 #[serde(
194 flatten,
195 default,
196 skip_serializing_if = "std::collections::BTreeMap::is_empty"
197 )]
198 #[cfg_attr(feature = "schema", schemars(skip))]
199 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
200}
201
202#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
204#[cfg_attr(feature = "schema", derive(JsonSchema))]
205#[serde(rename_all = "kebab-case")]
206pub struct CitationOptions {
207 #[serde(
210 skip_serializing_if = "Option::is_none",
211 deserialize_with = "deserialize_substitute_config",
212 default
213 )]
214 pub substitute: Option<SubstituteConfig>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub processing: Option<Processing>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub localize: Option<Localize>,
221 #[serde(
224 skip_serializing_if = "Option::is_none",
225 deserialize_with = "deserialize_multilingual_config",
226 default
227 )]
228 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
229 pub multilingual: Option<MultilingualConfig>,
230 #[serde(
232 skip_serializing_if = "Option::is_none",
233 deserialize_with = "deserialize_contributor_config",
234 default
235 )]
236 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
237 pub contributors: Option<ContributorConfig>,
238 #[serde(
240 skip_serializing_if = "Option::is_none",
241 deserialize_with = "deserialize_date_config",
242 default
243 )]
244 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
245 pub dates: Option<DateConfig>,
246 #[serde(
248 skip_serializing_if = "Option::is_none",
249 deserialize_with = "deserialize_titles_config",
250 default
251 )]
252 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
253 pub titles: Option<crate::options::titles::TitlesConfig>,
254 #[serde(
256 skip_serializing_if = "Option::is_none",
257 deserialize_with = "deserialize_locator_config",
258 default
259 )]
260 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
261 pub locators: Option<LocatorConfig>,
262 #[serde(skip_serializing_if = "Option::is_none")]
264 pub page_range_format: Option<PageRangeFormat>,
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub links: Option<LinksConfig>,
268 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
270 pub punctuation_in_quote: bool,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
274 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
276 pub strip_periods: Option<bool>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub notes: Option<NoteConfig>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub label_wrap: Option<LabelWrap>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub group_delimiter: Option<CitationGroupDelimiter>,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub custom: Option<HashMap<String, serde_json::Value>>,
295 #[serde(
299 flatten,
300 default,
301 skip_serializing_if = "std::collections::BTreeMap::is_empty"
302 )]
303 #[cfg_attr(feature = "schema", schemars(skip))]
304 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
305}
306
307#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "schema", derive(JsonSchema))]
310#[serde(rename_all = "kebab-case")]
311pub struct BibliographyOptions {
312 #[serde(
315 skip_serializing_if = "Option::is_none",
316 deserialize_with = "deserialize_substitute_config",
317 default
318 )]
319 pub substitute: Option<SubstituteConfig>,
320 #[serde(skip_serializing_if = "Option::is_none")]
322 pub processing: Option<Processing>,
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub localize: Option<Localize>,
326 #[serde(
329 skip_serializing_if = "Option::is_none",
330 deserialize_with = "deserialize_multilingual_config",
331 default
332 )]
333 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
334 pub multilingual: Option<MultilingualConfig>,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub sorting: Option<SortingConfig>,
338 #[serde(
340 skip_serializing_if = "Option::is_none",
341 deserialize_with = "deserialize_contributor_config",
342 default
343 )]
344 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
345 pub contributors: Option<ContributorConfig>,
346 #[serde(
348 skip_serializing_if = "Option::is_none",
349 deserialize_with = "deserialize_date_config",
350 default
351 )]
352 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
353 pub dates: Option<DateConfig>,
354 #[serde(
356 skip_serializing_if = "Option::is_none",
357 deserialize_with = "deserialize_titles_config",
358 default
359 )]
360 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
361 pub titles: Option<crate::options::titles::TitlesConfig>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub page_range_format: Option<PageRangeFormat>,
365 #[serde(skip_serializing_if = "Option::is_none")]
367 pub article_journal: Option<ArticleJournalBibliographyConfig>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub subsequent_author_substitute: Option<String>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub hanging_indent: Option<bool>,
377 #[serde(skip_serializing_if = "Option::is_none")]
380 pub entry_suffix: Option<DelimiterPunctuation>,
381 #[serde(skip_serializing_if = "Option::is_none")]
384 pub separator: Option<DelimiterPunctuation>,
385 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
387 pub suppress_period_after_url: bool,
388 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390 pub entry_suffix_after_url: bool,
391 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
393 pub entry_suffix_after_doi: bool,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
400 #[serde(skip_serializing_if = "Option::is_none")]
404 pub anonymous_entries: Option<AnonymousEntriesMode>,
405 #[serde(skip_serializing_if = "Option::is_none")]
407 pub links: Option<LinksConfig>,
408 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
410 pub punctuation_in_quote: bool,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub label_mode: Option<BibliographyLabelMode>,
417 #[serde(skip_serializing_if = "Option::is_none")]
419 pub label_wrap: Option<BibliographyLabelWrap>,
420 #[serde(skip_serializing_if = "Option::is_none")]
422 pub date_position: Option<DatePosition>,
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub title_terminator: Option<TitleTerminator>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
429 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
431 pub strip_periods: Option<bool>,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub custom: Option<HashMap<String, serde_json::Value>>,
435 #[serde(
439 flatten,
440 default,
441 skip_serializing_if = "std::collections::BTreeMap::is_empty"
442 )]
443 #[cfg_attr(feature = "schema", schemars(skip))]
444 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
445}
446
447#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
449#[cfg_attr(feature = "schema", derive(JsonSchema))]
450#[serde(rename_all = "kebab-case")]
451pub struct NoteConfig {
452 #[serde(skip_serializing_if = "Option::is_none")]
455 pub punctuation: Option<NoteQuotePlacement>,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub number: Option<NoteNumberPlacement>,
459 #[serde(skip_serializing_if = "Option::is_none")]
462 pub order: Option<NoteMarkerOrder>,
463 #[serde(
467 flatten,
468 default,
469 skip_serializing_if = "std::collections::BTreeMap::is_empty"
470 )]
471 #[cfg_attr(feature = "schema", schemars(skip))]
472 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
473}
474
475#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "schema", derive(JsonSchema))]
478#[serde(rename_all = "kebab-case")]
479pub struct PunctuationConfig {
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub strong_terminal_comma_policy: Option<StrongTerminalCommaPolicy>,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub delimiter_suppressing_terminal_marks: Option<String>,
486}
487
488impl PunctuationConfig {
489 fn merge(&mut self, other: &Self) {
491 if let Some(policy) = other.strong_terminal_comma_policy {
492 self.strong_terminal_comma_policy = Some(policy);
493 }
494 if let Some(marks) = &other.delimiter_suppressing_terminal_marks {
495 self.delimiter_suppressing_terminal_marks = Some(marks.clone());
496 }
497 }
498}
499
500#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
502#[cfg_attr(feature = "schema", derive(JsonSchema))]
503#[serde(rename_all = "kebab-case")]
504pub enum StrongTerminalCommaPolicy {
505 #[default]
507 KeepBoth,
508 KeepTerminal,
510}
511
512#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
514#[cfg_attr(feature = "schema", derive(JsonSchema))]
515#[serde(rename_all = "kebab-case")]
516pub enum NoteQuotePlacement {
517 Inside,
519 Outside,
521 #[default]
525 Adaptive,
526}
527
528#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
530#[cfg_attr(feature = "schema", derive(JsonSchema))]
531#[serde(rename_all = "kebab-case")]
532pub enum NoteNumberPlacement {
533 Inside,
535 #[default]
537 Outside,
538 Same,
541}
542
543#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
545#[cfg_attr(feature = "schema", derive(JsonSchema))]
546#[serde(rename_all = "kebab-case")]
547pub enum NoteMarkerOrder {
548 Before,
550 #[default]
552 After,
553}
554
555#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
557#[cfg_attr(feature = "schema", derive(JsonSchema))]
558#[serde(rename_all = "kebab-case")]
559#[non_exhaustive]
560pub enum PageRangeFormat {
561 #[default]
563 Expanded,
564 Minimal,
566 MinimalTwo,
568 Chicago,
570 Chicago16,
572}
573
574pub mod titles;
575
576pub use title_class::{
577 TitleCategory, classified_ref_types, container_title_category, parent_serial_title_category,
578 title_category,
579};
580pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
581
582#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
584#[cfg_attr(feature = "schema", derive(JsonSchema))]
585#[serde(rename_all = "kebab-case")]
586pub struct LinksConfig {
587 #[serde(skip_serializing_if = "Option::is_none")]
589 pub doi: Option<bool>,
590 #[serde(skip_serializing_if = "Option::is_none")]
592 pub url: Option<bool>,
593 #[serde(skip_serializing_if = "Option::is_none")]
595 pub target: Option<LinkTarget>,
596 #[serde(skip_serializing_if = "Option::is_none")]
598 pub anchor: Option<LinkAnchor>,
599 #[serde(skip_serializing_if = "Option::is_none")]
601 pub strip_protocol: Option<bool>,
602}
603
604#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "schema", derive(JsonSchema))]
607#[serde(rename_all = "kebab-case")]
608pub enum LinkTarget {
609 Url,
610 Doi,
611 UrlOrDoi,
612 Pubmed,
613 Pmcid,
614}
615
616#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "schema", derive(JsonSchema))]
619#[serde(rename_all = "kebab-case")]
620pub enum LinkAnchor {
621 Title,
623 Url,
625 Doi,
627 Component,
629 Entry,
631}
632
633impl Config {
634 fn merge_multilingual(&mut self, other: &Config) {
640 let Some(other_multilingual) = &other.multilingual else {
641 return;
642 };
643 if let Some(multilingual) = &mut self.multilingual {
644 multilingual.merge(other_multilingual);
645 } else {
646 self.multilingual = Some(other_multilingual.clone());
647 }
648 }
649
650 fn merge_punctuation(&mut self, other: &Config) {
651 let Some(other_punctuation) = &other.punctuation else {
652 return;
653 };
654 if let Some(punctuation) = &mut self.punctuation {
655 punctuation.merge(other_punctuation);
656 } else {
657 self.punctuation = Some(other_punctuation.clone());
658 }
659 }
660
661 pub fn effective_processing(&self) -> Processing {
666 self.processing.clone().unwrap_or_default()
667 }
668
669 pub fn merge(&mut self, other: &Config) {
674 crate::merge_options!(
675 self,
676 other,
677 processing,
678 locale_override,
679 localize,
680 dates,
681 titles,
682 locators,
683 page_range_format,
684 page_range_delimiter,
685 links,
686 volume_pages_delimiter,
687 locale_override,
688 strip_periods,
689 notes,
690 integral_name_memory,
691 org_abbreviation_memory,
692 custom,
693 );
694
695 self.merge_multilingual(other);
696 self.merge_punctuation(other);
697 self.messages.extend(other.messages.clone());
698
699 if let Some(other_sorting) = &other.sorting {
700 if let Some(this_sorting) = &mut self.sorting {
701 this_sorting.merge(other_sorting);
702 } else {
703 self.sorting = Some(other_sorting.clone());
704 }
705 }
706
707 if let Some(other_substitute) = &other.substitute {
708 if let Some(this_substitute) = &self.substitute {
709 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
710 } else {
711 self.substitute = Some(other_substitute.clone());
712 }
713 }
714
715 if let Some(other_contributors) = &other.contributors {
716 if let Some(this_contributors) = &mut self.contributors {
717 this_contributors.merge(other_contributors);
718 } else {
719 self.contributors = Some(other_contributors.clone());
720 }
721 }
722
723 if other.punctuation_in_quote {
724 self.punctuation_in_quote = true;
725 }
726 }
727
728 pub fn merged(base: &Config, override_config: &Config) -> Config {
732 let mut result = base.clone();
733 result.merge(override_config);
734 result
735 }
736}
737
738impl CitationOptions {
739 #[must_use]
741 pub fn to_config(&self) -> Config {
742 Config {
743 messages: HashMap::new(),
744 substitute: self.substitute.clone(),
745 processing: self.processing.clone(),
746 locale_override: None,
747 localize: self.localize.clone(),
748 multilingual: self.multilingual.clone(),
749 sorting: None,
750 contributors: self.contributors.clone(),
751 dates: self.dates.clone(),
752 titles: self.titles.clone(),
753 locators: self.locators.clone(),
754 page_range_format: self.page_range_format.clone(),
755 page_range_delimiter: None,
756 links: self.links.clone(),
757 punctuation_in_quote: self.punctuation_in_quote,
758 punctuation: None,
759 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
760 strip_periods: self.strip_periods,
761 notes: self.notes.clone(),
762 integral_name_memory: self.integral_name_memory.clone(),
763 org_abbreviation_memory: self.org_abbreviation_memory.clone(),
764 custom: self.custom.clone(),
765 unknown_fields: std::collections::BTreeMap::new(),
766 }
767 }
768
769 #[must_use]
771 pub fn merged_with(&self, base: &Config) -> Config {
772 Config::merged(base, &self.to_config())
773 }
774
775 #[must_use]
784 pub fn merged_with_raw(
785 &self,
786 base: &Config,
787 raw_options: Option<&serde_yaml::Value>,
788 ) -> Config {
789 let mut merged = self.merged_with(base);
790 if let Some(raw) = raw_options
791 && cascade::authored_matches(raw, self)
792 {
793 cascade::merge_citation_config_blocks_from_raw(&mut merged, base, raw);
794 }
795 merged
796 }
797
798 pub fn merge(&mut self, other: &CitationOptions) {
800 crate::merge_options!(
801 self,
802 other,
803 processing,
804 localize,
805 multilingual,
806 dates,
807 titles,
808 locators,
809 page_range_format,
810 links,
811 volume_pages_delimiter,
812 strip_periods,
813 notes,
814 integral_name_memory,
815 org_abbreviation_memory,
816 label_wrap,
817 group_delimiter,
818 custom,
819 );
820
821 if let Some(other_substitute) = &other.substitute {
822 if let Some(this_substitute) = &self.substitute {
823 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
824 } else {
825 self.substitute = Some(other_substitute.clone());
826 }
827 }
828
829 if let Some(other_contributors) = &other.contributors {
830 if let Some(this_contributors) = &mut self.contributors {
831 this_contributors.merge(other_contributors);
832 } else {
833 self.contributors = Some(other_contributors.clone());
834 }
835 }
836
837 if other.punctuation_in_quote {
838 self.punctuation_in_quote = true;
839 }
840 }
841}
842
843impl BibliographyOptions {
844 #[must_use]
846 pub fn to_bibliography_config(&self) -> BibliographyConfig {
847 BibliographyConfig {
848 article_journal: self.article_journal.clone(),
849 subsequent_author_substitute: self.subsequent_author_substitute.clone(),
850 subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
851 hanging_indent: self.hanging_indent,
852 entry_suffix: self.entry_suffix.clone(),
853 separator: self.separator.clone(),
854 suppress_period_after_url: self.suppress_period_after_url,
855 entry_suffix_after_url: self.entry_suffix_after_url,
856 entry_suffix_after_doi: self.entry_suffix_after_doi,
857 custom: None,
858 compound_numeric: self.compound_numeric.clone(),
859 sort_partitioning: self.sort_partitioning.clone(),
860 anonymous_entries: self.anonymous_entries,
861 unknown_fields: std::collections::BTreeMap::new(),
862 }
863 }
864
865 #[must_use]
867 pub fn to_config(&self) -> Config {
868 Config {
869 messages: HashMap::new(),
870 substitute: self.substitute.clone(),
871 processing: self.processing.clone(),
872 locale_override: None,
873 localize: self.localize.clone(),
874 multilingual: self.multilingual.clone(),
875 sorting: self.sorting.clone(),
876 contributors: self.contributors.clone(),
877 dates: self.dates.clone(),
878 titles: self.titles.clone(),
879 locators: None,
880 page_range_format: self.page_range_format.clone(),
881 page_range_delimiter: None,
882 links: self.links.clone(),
883 punctuation_in_quote: self.punctuation_in_quote,
884 punctuation: None,
885 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
886 strip_periods: self.strip_periods,
887 notes: None,
888 integral_name_memory: None,
889 org_abbreviation_memory: None,
890 custom: self.custom.clone(),
891 unknown_fields: std::collections::BTreeMap::new(),
892 }
893 }
894
895 #[must_use]
897 pub fn merged_with(&self, base: &Config) -> Config {
898 Config::merged(base, &self.to_config())
899 }
900
901 #[must_use]
910 pub fn merged_with_raw(
911 &self,
912 base: &Config,
913 raw_options: Option<&serde_yaml::Value>,
914 ) -> Config {
915 let mut merged = self.merged_with(base);
916 if let Some(raw) = raw_options
917 && cascade::authored_matches(raw, self)
918 {
919 cascade::merge_bibliography_config_blocks_from_raw(&mut merged, base, raw);
920 }
921 merged
922 }
923
924 pub fn merge(&mut self, other: &BibliographyOptions) {
926 crate::merge_options!(
927 self,
928 other,
929 processing,
930 localize,
931 multilingual,
932 dates,
933 titles,
934 page_range_format,
935 links,
936 volume_pages_delimiter,
937 strip_periods,
938 article_journal,
939 subsequent_author_substitute,
940 subsequent_author_substitute_rule,
941 hanging_indent,
942 entry_suffix,
943 separator,
944 compound_numeric,
945 sort_partitioning,
946 anonymous_entries,
947 label_mode,
948 label_wrap,
949 date_position,
950 title_terminator,
951 repeated_author_rendering,
952 custom,
953 );
954
955 self.merge_shared_fields(other);
956 }
957
958 fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
959 if let Some(other_sorting) = &other.sorting {
960 if let Some(this_sorting) = &mut self.sorting {
961 this_sorting.merge(other_sorting);
962 } else {
963 self.sorting = Some(other_sorting.clone());
964 }
965 }
966
967 if let Some(other_substitute) = &other.substitute {
968 if let Some(this_substitute) = &self.substitute {
969 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
970 } else {
971 self.substitute = Some(other_substitute.clone());
972 }
973 }
974
975 if let Some(other_contributors) = &other.contributors {
976 if let Some(this_contributors) = &mut self.contributors {
977 this_contributors.merge(other_contributors);
978 } else {
979 self.contributors = Some(other_contributors.clone());
980 }
981 }
982
983 if other.punctuation_in_quote {
984 self.punctuation_in_quote = true;
985 }
986 if other.suppress_period_after_url {
987 self.suppress_period_after_url = true;
988 }
989 if other.entry_suffix_after_url {
990 self.entry_suffix_after_url = true;
991 }
992 if other.entry_suffix_after_doi {
993 self.entry_suffix_after_doi = true;
994 }
995
996 for (key, value) in &other.unknown_fields {
997 self.unknown_fields.insert(key.clone(), value.clone());
998 }
999 }
1000}
1001
1002fn deserialize_contributor_config<'de, D>(
1004 deserializer: D,
1005) -> Result<Option<ContributorConfig>, D::Error>
1006where
1007 D: serde::Deserializer<'de>,
1008{
1009 let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
1010 Ok(value.map(|entry| entry.resolve()))
1011}
1012
1013fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
1015where
1016 D: serde::Deserializer<'de>,
1017{
1018 let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
1019 Ok(value.map(|entry| entry.resolve()))
1020}
1021
1022fn deserialize_titles_config<'de, D>(
1024 deserializer: D,
1025) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
1026where
1027 D: serde::Deserializer<'de>,
1028{
1029 let value: Option<crate::options::titles::TitlesConfigEntry> =
1030 Option::deserialize(deserializer)?;
1031 Ok(value.map(|entry| entry.resolve()))
1032}
1033
1034fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
1036where
1037 D: serde::Deserializer<'de>,
1038{
1039 let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
1040 Ok(value.map(|entry| entry.resolve()))
1041}
1042
1043fn deserialize_substitute_config<'de, D>(
1053 deserializer: D,
1054) -> Result<Option<SubstituteConfig>, D::Error>
1055where
1056 D: serde::Deserializer<'de>,
1057{
1058 let value: Option<SubstituteConfig> = Option::deserialize(deserializer)?;
1059 Ok(value.map(|config| match config {
1060 SubstituteConfig::Preset(preset) => SubstituteConfig::Explicit(preset.config()),
1061 explicit @ SubstituteConfig::Explicit(_) => explicit,
1062 }))
1063}
1064
1065fn deserialize_multilingual_config<'de, D>(
1067 deserializer: D,
1068) -> Result<Option<MultilingualConfig>, D::Error>
1069where
1070 D: serde::Deserializer<'de>,
1071{
1072 let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
1073 Ok(value.map(|entry| entry.resolve()))
1074}
1075
1076impl<'de> Deserialize<'de> for Config {
1077 #[allow(
1078 clippy::too_many_lines,
1079 reason = "the local wire type intentionally mirrors the complete public config"
1080 )]
1081 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1082 where
1083 D: serde::Deserializer<'de>,
1084 {
1085 #[derive(Deserialize)]
1086 #[serde(rename_all = "kebab-case")]
1087 struct ConfigWire {
1088 #[serde(default)]
1089 messages: HashMap<String, String>,
1090 #[serde(
1091 skip_serializing_if = "Option::is_none",
1092 deserialize_with = "deserialize_substitute_config",
1093 default
1094 )]
1095 substitute: Option<SubstituteConfig>,
1096 #[serde(skip_serializing_if = "Option::is_none")]
1097 processing: Option<Processing>,
1098 #[serde(skip_serializing_if = "Option::is_none")]
1099 locale_override: Option<String>,
1100 #[serde(skip_serializing_if = "Option::is_none")]
1101 localize: Option<Localize>,
1102 #[serde(
1103 skip_serializing_if = "Option::is_none",
1104 deserialize_with = "deserialize_multilingual_config",
1105 default
1106 )]
1107 multilingual: Option<MultilingualConfig>,
1108 #[serde(skip_serializing_if = "Option::is_none")]
1109 sorting: Option<SortingConfig>,
1110 #[serde(
1111 skip_serializing_if = "Option::is_none",
1112 deserialize_with = "deserialize_contributor_config",
1113 default
1114 )]
1115 contributors: Option<ContributorConfig>,
1116 #[serde(
1117 skip_serializing_if = "Option::is_none",
1118 deserialize_with = "deserialize_date_config",
1119 default
1120 )]
1121 dates: Option<DateConfig>,
1122 #[serde(
1123 skip_serializing_if = "Option::is_none",
1124 deserialize_with = "deserialize_titles_config",
1125 default
1126 )]
1127 titles: Option<crate::options::titles::TitlesConfig>,
1128 #[serde(
1129 skip_serializing_if = "Option::is_none",
1130 deserialize_with = "deserialize_locator_config",
1131 default
1132 )]
1133 locators: Option<LocatorConfig>,
1134 #[serde(skip_serializing_if = "Option::is_none")]
1135 page_range_format: Option<PageRangeFormat>,
1136 #[serde(skip_serializing_if = "Option::is_none")]
1137 page_range_delimiter: Option<String>,
1138 #[serde(skip_serializing_if = "Option::is_none")]
1139 links: Option<LinksConfig>,
1140 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1141 punctuation_in_quote: bool,
1142 #[serde(skip_serializing_if = "Option::is_none")]
1143 punctuation: Option<PunctuationConfig>,
1144 #[serde(skip_serializing_if = "Option::is_none")]
1145 volume_pages_delimiter: Option<DelimiterPunctuation>,
1146 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
1147 strip_periods: Option<bool>,
1148 #[serde(skip_serializing_if = "Option::is_none")]
1149 notes: Option<NoteConfig>,
1150 #[serde(skip_serializing_if = "Option::is_none")]
1151 integral_name_memory: Option<IntegralNameMemoryConfig>,
1152 #[serde(skip_serializing_if = "Option::is_none")]
1153 org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
1154 #[serde(default)]
1155 profile: Option<serde_yaml::Value>,
1156 #[serde(skip_serializing_if = "Option::is_none")]
1157 custom: Option<HashMap<String, serde_json::Value>>,
1158 #[serde(flatten)]
1159 unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
1160 }
1161
1162 let wire = ConfigWire::deserialize(deserializer)?;
1163 if wire.profile.is_some() {
1164 return Err(serde::de::Error::custom(
1165 "`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`",
1166 ));
1167 }
1168
1169 Ok(Self {
1170 messages: wire.messages,
1171 substitute: wire.substitute,
1172 processing: wire.processing,
1173 locale_override: wire.locale_override,
1174 localize: wire.localize,
1175 multilingual: wire.multilingual,
1176 sorting: wire.sorting,
1177 contributors: wire.contributors,
1178 dates: wire.dates,
1179 titles: wire.titles,
1180 locators: wire.locators,
1181 page_range_format: wire.page_range_format,
1182 page_range_delimiter: wire.page_range_delimiter,
1183 links: wire.links,
1184 punctuation_in_quote: wire.punctuation_in_quote,
1185 punctuation: wire.punctuation,
1186 volume_pages_delimiter: wire.volume_pages_delimiter,
1187 strip_periods: wire.strip_periods,
1188 notes: wire.notes,
1189 integral_name_memory: wire.integral_name_memory,
1190 org_abbreviation_memory: wire.org_abbreviation_memory,
1191 custom: wire.custom,
1192 unknown_fields: wire.unknown_fields,
1193 })
1194 }
1195}
1196
1197#[cfg(test)]
1198#[allow(
1199 clippy::unwrap_used,
1200 clippy::expect_used,
1201 clippy::panic,
1202 clippy::indexing_slicing,
1203 clippy::todo,
1204 clippy::unimplemented,
1205 clippy::unreachable,
1206 clippy::get_unwrap,
1207 reason = "Panicking is acceptable and often desired in tests."
1208)]
1209mod tests {
1210 use super::*;
1211 use rstest::rstest;
1212
1213 #[test]
1214 fn test_config_default() {
1215 let config = Config::default();
1216 assert!(config.substitute.is_none());
1217 assert!(config.processing.is_none());
1218 }
1219
1220 #[test]
1221 fn test_author_date_processing() {
1222 let processing = Processing::AuthorDate;
1223 let config = processing.config();
1224 let disambiguate = config.disambiguate.unwrap();
1225 assert!(disambiguate.year_suffix);
1226 assert!(!disambiguate.names);
1227 assert!(!disambiguate.add_givenname);
1228 assert_eq!(
1229 processing.default_bibliography_sort(),
1230 Some(crate::presets::SortPreset::AuthorDateTitle)
1231 );
1232 assert_eq!(
1233 config.sort,
1234 Some(SortEntry::Preset(
1235 crate::presets::SortPreset::AuthorDateTitle
1236 ))
1237 );
1238 }
1239
1240 #[test]
1241 fn test_processing_default_bibliography_sorts() {
1242 assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
1243 assert_eq!(
1244 Processing::Note.default_bibliography_sort(),
1245 Some(crate::presets::SortPreset::AuthorTitleDate)
1246 );
1247 assert_eq!(
1248 Processing::Label(LabelConfig::default()).default_bibliography_sort(),
1249 Some(crate::presets::SortPreset::AuthorDateTitle)
1250 );
1251 }
1252
1253 #[test]
1254 fn test_processing_default_citation_sort_policy_is_explicit_only() {
1255 assert_eq!(
1256 Processing::AuthorDate.default_citation_sort_policy(),
1257 CitationSortPolicy::ExplicitOnly
1258 );
1259 assert_eq!(
1260 Processing::Note.default_citation_sort_policy(),
1261 CitationSortPolicy::ExplicitOnly
1262 );
1263 }
1264
1265 #[test]
1266 fn test_substitute_default() {
1267 let sub = Substitute::default();
1268 assert_eq!(sub.template.len(), 3);
1269 }
1270
1271 #[test]
1272 fn test_config_yaml_roundtrip() {
1273 let yaml = r#"
1274substitute:
1275 contributor-role-form: short
1276 template:
1277 - editor
1278 - title
1279processing: author-date
1280contributors:
1281 display-as-sort: first
1282 and: symbol
1283"#;
1284 let config: Config = serde_yaml::from_str(yaml).unwrap();
1285 assert!(config.substitute.is_some());
1286 assert_eq!(config.processing, Some(Processing::AuthorDate));
1287 assert_eq!(
1288 config.contributors.as_ref().unwrap().and,
1289 Some(AndOptions::Symbol)
1290 );
1291 }
1292
1293 #[test]
1294 fn test_sorting_config_deserializes_and_roundtrips() {
1295 let yaml = r#"
1296sorting:
1297 locale: sv-SE
1298 multilingual: romanized
1299"#;
1300 let config: Config = serde_yaml::from_str(yaml).unwrap();
1301 let sorting = config.sorting.as_ref().expect("sorting should parse");
1302
1303 assert_eq!(
1304 sorting.locale,
1305 Some(SortingLocale::Bcp47("sv-SE".to_string()))
1306 );
1307 assert_eq!(
1308 sorting.multilingual,
1309 Some(SortingMultilingualMode::Romanized)
1310 );
1311
1312 let serialized = serde_yaml::to_string(&config).unwrap();
1313 let reparsed: Config = serde_yaml::from_str(&serialized).unwrap();
1314 assert_eq!(reparsed.sorting, config.sorting);
1315 }
1316
1317 #[test]
1318 fn test_sorting_config_defaults_and_unknown_fields() {
1319 let yaml = r#"
1320sorting:
1321 future-key: true
1322"#;
1323 let config: Config = serde_yaml::from_str(yaml).unwrap();
1324 let sorting = config.sorting.as_ref().expect("sorting should parse");
1325
1326 assert_eq!(sorting.effective_locale(), SortingLocale::Auto);
1327 assert_eq!(
1328 sorting.effective_multilingual(),
1329 SortingMultilingualMode::Uniform
1330 );
1331 assert!(sorting.unknown_fields.contains_key("future-key"));
1332 }
1333
1334 #[test]
1335 fn test_bibliography_sorting_override_merges_partially() {
1336 let base: Config = serde_yaml::from_str(
1337 r#"
1338sorting:
1339 locale: de-DE
1340 multilingual: uniform
1341"#,
1342 )
1343 .unwrap();
1344 let bib: BibliographyOptions = serde_yaml::from_str(
1345 r#"
1346sorting:
1347 multilingual: romanized
1348"#,
1349 )
1350 .unwrap();
1351
1352 let merged = bib.merged_with(&base);
1353 let sorting = merged.sorting.expect("merged sorting should exist");
1354 assert_eq!(
1355 sorting.locale,
1356 Some(SortingLocale::Bcp47("de-DE".to_string()))
1357 );
1358 assert_eq!(
1359 sorting.multilingual,
1360 Some(SortingMultilingualMode::Romanized)
1361 );
1362 }
1363
1364 #[test]
1365 fn test_contributor_config_preset() {
1366 let yaml = r#"contributors: apa"#;
1368 let config: Config = serde_yaml::from_str(yaml).unwrap();
1369 let contributors = config.contributors.unwrap();
1370 assert_eq!(contributors.and, Some(AndOptions::Symbol));
1371 assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1372 }
1373
1374 #[test]
1375 fn test_role_label_presets_parse_and_resolve_precedence() {
1376 let yaml = r#"
1377contributors:
1378 role:
1379 preset: short-suffix
1380 roles:
1381 editor:
1382 preset: long-suffix
1383"#;
1384 let config: Config = serde_yaml::from_str(yaml).unwrap();
1385 let contributors = config.contributors.unwrap();
1386
1387 assert_eq!(
1388 contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1389 Some(RoleLabelPreset::LongSuffix)
1390 );
1391 assert_eq!(
1392 contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1393 Some(RoleLabelPreset::ShortSuffix)
1394 );
1395
1396 let yaml_scalar = r#"
1398contributors:
1399 role: short-suffix
1400"#;
1401 let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1402 let contributors2 = config2.contributors.unwrap();
1403
1404 assert_eq!(
1405 contributors2
1406 .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1407 Some(RoleLabelPreset::ShortSuffix)
1408 );
1409 }
1410
1411 #[test]
1412 fn test_role_specific_name_order_override_is_available() {
1413 let yaml = r#"
1414contributors:
1415 role:
1416 roles:
1417 translator:
1418 name-order: given-first
1419"#;
1420 let config: Config = serde_yaml::from_str(yaml).unwrap();
1421 let contributors = config.contributors.unwrap();
1422
1423 assert_eq!(
1424 contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1425 Some(&crate::template::NameOrder::GivenFirst)
1426 );
1427 }
1428
1429 #[test]
1430 fn test_date_config_preset() {
1431 let yaml = r#"dates: long"#;
1433 let config: Config = serde_yaml::from_str(yaml).unwrap();
1434 let dates = config.dates.unwrap();
1435 assert_eq!(dates.month, MonthFormat::Long);
1436 }
1437
1438 #[test]
1439 fn test_titles_config_preset() {
1440 let yaml = r#"titles: chicago"#;
1442 let config: Config = serde_yaml::from_str(yaml).unwrap();
1443 let titles = config.titles.unwrap();
1444 assert_eq!(titles.component.unwrap().quote, Some(true));
1445 assert_eq!(titles.monograph.unwrap().emph, Some(true));
1446 }
1447
1448 #[test]
1449 fn test_substitute_config_preset() {
1450 let yaml = r#"substitute: standard"#;
1452 let config: Config = serde_yaml::from_str(yaml).unwrap();
1453 assert!(config.substitute.is_some());
1454 let resolved = config.substitute.unwrap().resolve();
1455 assert_eq!(resolved.template.len(), 3);
1456 assert_eq!(resolved.template[0], SubstituteKey::Editor);
1457 }
1458
1459 #[test]
1460 fn test_substitute_config_explicit() {
1461 let yaml = r#"
1463substitute:
1464 template:
1465 - title
1466 - editor
1467"#;
1468 let config: Config = serde_yaml::from_str(yaml).unwrap();
1469 let resolved = config.substitute.unwrap().resolve();
1470 assert_eq!(resolved.template[0], SubstituteKey::Title);
1471 assert_eq!(resolved.template[1], SubstituteKey::Editor);
1472 }
1473
1474 #[test]
1475 fn test_config_merge_precedence() {
1476 let base_yaml = r#"
1478processing: author-date
1479locale-override: en-US-base
1480contributors:
1481 display-as-sort: first
1482 and: symbol
1483"#;
1484 let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1485
1486 let override_yaml = r#"
1488contributors:
1489 and: text
1490locale-override: en-US-chicago
1491"#;
1492 let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1493
1494 base.merge(&override_config);
1496
1497 assert_eq!(base.processing, Some(Processing::AuthorDate));
1499 assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1500
1501 assert_eq!(
1503 base.contributors.as_ref().unwrap().and,
1504 Some(AndOptions::Text)
1505 );
1506 }
1507
1508 #[test]
1509 fn test_config_deserializes_locale_override() {
1510 let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1511 assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1512 }
1513
1514 #[test]
1515 fn test_config_merged_convenience() {
1516 let base = Config {
1517 processing: Some(Processing::AuthorDate),
1518 ..Default::default()
1519 };
1520 let override_config = Config {
1521 punctuation_in_quote: true,
1522 ..Default::default()
1523 };
1524
1525 let merged = Config::merged(&base, &override_config);
1526
1527 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1529 assert!(merged.punctuation_in_quote);
1530 }
1531
1532 #[test]
1533 fn test_citation_options_merge_overrides_citation_fields_only() {
1534 let base = Config {
1535 processing: Some(Processing::AuthorDate),
1536 ..Default::default()
1537 };
1538
1539 let overrides = CitationOptions {
1540 strip_periods: Some(true),
1541 locators: Some(LocatorConfig::default()),
1542 ..Default::default()
1543 };
1544
1545 let merged = overrides.merged_with(&base);
1546 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1547 assert!(merged.strip_periods.unwrap_or(false));
1548 assert!(merged.locators.is_some());
1549 }
1550
1551 #[test]
1552 fn test_punctuation_config_deserializes_and_merges_field_by_field() {
1553 let yaml = r#"
1554punctuation:
1555 strong-terminal-comma-policy: keep-terminal
1556 delimiter-suppressing-terminal-marks: "?!…"
1557"#;
1558 let config: Config = serde_yaml::from_str(yaml).unwrap();
1559 let punctuation = config.punctuation.as_ref().unwrap();
1560 assert_eq!(
1561 punctuation.strong_terminal_comma_policy,
1562 Some(StrongTerminalCommaPolicy::KeepTerminal)
1563 );
1564 assert_eq!(
1565 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1566 Some("?!…")
1567 );
1568
1569 let override_config = Config {
1570 punctuation: Some(PunctuationConfig {
1571 strong_terminal_comma_policy: Some(StrongTerminalCommaPolicy::KeepBoth),
1572 delimiter_suppressing_terminal_marks: None,
1573 }),
1574 ..Default::default()
1575 };
1576 let merged = Config::merged(&config, &override_config);
1577 let punctuation = merged.punctuation.as_ref().unwrap();
1578 assert_eq!(
1579 punctuation.strong_terminal_comma_policy,
1580 Some(StrongTerminalCommaPolicy::KeepBoth)
1581 );
1582 assert_eq!(
1583 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1584 Some("?!…")
1585 );
1586 }
1587
1588 #[test]
1589 fn test_bibliography_options_merge_projects_shared_fields_only() {
1590 let base = Config {
1591 processing: Some(Processing::AuthorDate),
1592 ..Default::default()
1593 };
1594
1595 let overrides = BibliographyOptions {
1596 entry_suffix: Some(".".into()),
1597 separator: Some(", ".into()),
1598 suppress_period_after_url: true,
1599 ..Default::default()
1600 };
1601
1602 let merged = overrides.merged_with(&base);
1603 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1604 assert!(merged.locators.is_none());
1605 assert!(merged.notes.is_none());
1606 let bibliography = overrides.to_bibliography_config();
1607 assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1608 assert_eq!(bibliography.separator.as_deref(), Some(", "));
1609 assert!(bibliography.suppress_period_after_url);
1610 }
1611
1612 #[test]
1613 fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1614 let base = Config {
1615 processing: Some(Processing::AuthorDate),
1616 ..Default::default()
1617 };
1618
1619 let overrides = BibliographyOptions {
1620 contributors: Some(ContributorConfig::default()),
1621 ..Default::default()
1622 };
1623
1624 let merged = overrides.merged_with(&base);
1625 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1626 assert!(merged.contributors.is_some());
1627 }
1628
1629 #[test]
1630 fn citation_options_captures_unknown_fields_for_forward_compat() {
1631 let yaml = "future-key: true\n";
1632 let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1633 assert!(opts.unknown_fields.contains_key("future-key"));
1634 }
1635
1636 #[test]
1637 fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1638 let yaml = "future-key: true\n";
1639 let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1640 assert!(opts.unknown_fields.contains_key("future-key"));
1641 }
1642
1643 #[test]
1644 fn note_config_captures_unknown_fields_for_forward_compat() {
1645 let yaml = "punctuation: inside\nfuture-key: true\n";
1646 let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1647 assert!(cfg.unknown_fields.contains_key("future-key"));
1648 assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1649 }
1650
1651 #[test]
1652 fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1653 let yaml = r#"multilingual: romanized-translated"#;
1655 let config: Config = serde_yaml::from_str(yaml).unwrap();
1656 let ml = config.multilingual.unwrap();
1657 assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1658 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1659 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1660 }
1661
1662 #[test]
1663 fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1664 let yaml = r#"multilingual: romanized-only"#;
1666 let config: Config = serde_yaml::from_str(yaml).unwrap();
1667 let ml = config.multilingual.unwrap();
1668 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1669 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1670 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1671 }
1672
1673 #[test]
1674 fn test_multilingual_preset_romanized_script_translated_parses_and_resolves() {
1675 use crate::options::multilingual::{MultilingualSegment, MultilingualView, SegmentWrap};
1678 let yaml = r#"multilingual: romanized-script-translated"#;
1679 let config: Config = serde_yaml::from_str(yaml).unwrap();
1680 let ml = config.multilingual.unwrap();
1681 assert_eq!(
1682 ml.title_mode,
1683 Some(MultilingualMode::Pattern(vec![
1684 MultilingualSegment {
1685 view: MultilingualView::Transliterated,
1686 wrap: SegmentWrap::None,
1687 },
1688 MultilingualSegment {
1689 view: MultilingualView::OriginalScript,
1690 wrap: SegmentWrap::None,
1691 },
1692 MultilingualSegment {
1693 view: MultilingualView::Translated,
1694 wrap: SegmentWrap::Brackets,
1695 },
1696 ]))
1697 );
1698 assert_eq!(
1699 ml.name_mode,
1700 Some(MultilingualMode::Pattern(vec![
1701 MultilingualSegment {
1702 view: MultilingualView::Transliterated,
1703 wrap: SegmentWrap::None,
1704 },
1705 MultilingualSegment {
1706 view: MultilingualView::OriginalScript,
1707 wrap: SegmentWrap::None,
1708 },
1709 ]))
1710 );
1711 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1712 assert!(ml.scripts.get("Han").is_some_and(|s| s.use_native_ordering));
1713 assert!(
1714 ml.scripts
1715 .get("Hangul")
1716 .is_some_and(|s| s.use_native_ordering)
1717 );
1718 }
1719
1720 #[test]
1721 fn test_multilingual_explicit_block_transliterated_roundtrips() {
1722 let yaml = r#"
1724multilingual:
1725 title-mode: transliterated
1726 preferred-script: Latn
1727"#;
1728 let config: Config = serde_yaml::from_str(yaml).unwrap();
1729 let ml = config.multilingual.clone().unwrap();
1730 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1731 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1732
1733 let yaml2 = serde_yaml::to_string(&config).unwrap();
1734 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1735 assert_eq!(config2.multilingual, config.multilingual);
1736 }
1737
1738 #[test]
1739 fn test_multilingual_pattern_block_roundtrips() {
1740 let yaml = r#"
1743multilingual:
1744 title-mode:
1745 pattern:
1746 - view: original-script
1747 - view: translated
1748 wrap: brackets
1749"#;
1750 let config: Config = serde_yaml::from_str(yaml).unwrap();
1751 let ml = config.multilingual.clone().unwrap();
1752 assert!(
1753 matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1754 "expected Pattern mode, got {:?}",
1755 ml.title_mode
1756 );
1757
1758 let yaml2 = serde_yaml::to_string(&config).unwrap();
1759 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1760 assert_eq!(config2.multilingual, config.multilingual);
1761 }
1762
1763 #[rstest]
1769 #[case::scripts_overlay(
1770 "multilingual:\n scripts:\n Hang:\n use-native-ordering: true\n",
1771 "Hang"
1772 )]
1773 #[case::term_locale_overlay("multilingual:\n term-locale: item\n", "Hani")]
1774 fn given_partial_multilingual_overlay_when_merging_then_inherited_fields_survive(
1775 #[case] overlay_yaml: &str,
1776 #[case] expected_script_key: &str,
1777 ) {
1778 let base_yaml = "\
1780multilingual:
1781 punctuation-width: mixed
1782 preferred-script: Latn
1783 scripts:
1784 Hani:
1785 use-native-ordering: true
1786";
1787 let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1788
1789 let overlay: Config = serde_yaml::from_str(overlay_yaml).unwrap();
1791 base.merge(&overlay);
1792
1793 let merged = base.multilingual.expect("merged multilingual block");
1795 assert_eq!(
1796 merged.punctuation_width,
1797 Some(PunctuationWidth::Mixed),
1798 "inherited punctuation-width must survive a partial overlay"
1799 );
1800 assert_eq!(merged.preferred_script.as_deref(), Some("Latn"));
1801 assert!(
1802 merged.scripts.contains_key(expected_script_key),
1803 "expected scripts key {expected_script_key:?}, got {:?}",
1804 merged.scripts.keys().collect::<Vec<_>>()
1805 );
1806 }
1807
1808 #[test]
1810 fn given_multilingual_overlay_setting_a_field_when_merging_then_overlay_wins() {
1811 let mut base: Config =
1812 serde_yaml::from_str("multilingual:\n punctuation-width: mixed\n").unwrap();
1813 let overlay: Config =
1814 serde_yaml::from_str("multilingual:\n punctuation-width: bylan\n").unwrap();
1815
1816 base.merge(&overlay);
1817
1818 assert_eq!(
1819 base.multilingual.unwrap().punctuation_width,
1820 Some(PunctuationWidth::Bylan)
1821 );
1822 }
1823}