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