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