1pub mod bibliography;
9pub mod cascade;
10pub mod contributors;
11pub mod date_substitute;
12pub mod dates;
13pub mod integral_name_memory;
14pub mod localization;
15pub mod locators;
16pub mod multilingual;
17pub mod processing;
18pub mod scoped;
19pub mod sorting;
20pub mod substitute;
21pub mod title_class;
22
23pub use crate::presets::{MultilingualConfigEntry, MultilingualPreset};
24pub use bibliography::{
25 AnonymousEntriesMode, ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback,
26 BibliographyConfig, BibliographyPartitionHeading, BibliographyPartitionKind,
27 BibliographyPartitionMode, BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
28};
29pub use cascade::ScopedRawOptions;
30pub use contributors::{
31 AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry,
32 ContributorSuppressionRule, DelimiterPrecedesLast, DemoteNonDroppingParticle, DisplayAsSort,
33 NameForm, RoleLabelDefaults, RoleLabelPresentation, RoleLabelPreset, RoleOptions,
34 RoleOptionsEntry, RoleRendering, ShortenListOptions, TwoNameDelimiterPolicy,
35};
36pub use date_substitute::{
37 DateSubstitute, DateSubstituteCandidate, DateSubstituteDate, DateSubstituteEntry,
38 DateSubstituteMessage, DateSubstitutePreset,
39};
40pub use dates::{DateConfig, DateConfigEntry, DateRangeFormat, NoDateForm};
41pub use integral_name_memory::{
42 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, OrgAbbreviationMemoryConfig,
43 ResolvedIntegralNameMemoryConfig, ResolvedOrgAbbreviationMemoryConfig, ShortNameDisplay,
44 SubsequentNameForm,
45};
46pub use localization::{Localize, MonthFormat, Scope};
47pub use locators::{
48 LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
49 LocatorPreset, TypeClass,
50};
51pub use multilingual::{
52 MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView,
53 PunctuationRealization, PunctuationStyle, PunctuationWidth, RealizationDefault, ScriptConfig,
54 SegmentWrap, TermLocale,
55};
56pub use processing::{
57 CitationSortPolicy, Disambiguation, GivennameRule, Group, LabelConfig, LabelParams,
58 LabelPreset, Processing, ProcessingBase, ProcessingCustom, RegimeFamily, Sort, SortEntry,
59 SortKey, SortSpec,
60};
61pub use scoped::{
62 BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, CitationLabelMode,
63 DatePosition, LabelWrap, RepeatedAuthorRendering, TitleTerminator,
64};
65pub use sorting::{SortingConfig, SortingLocale, SortingMultilingualMode};
66pub use substitute::{
67 Substitute, SubstituteConfig, SubstituteContributor, SubstituteField, SubstituteKey,
68 SubstituteTitleQuoteMode,
69};
70
71use crate::template::DelimiterPunctuation;
72#[cfg(feature = "schema")]
73use schemars::JsonSchema;
74use serde::{Deserialize, Serialize};
75use std::collections::HashMap;
76
77#[derive(Debug, Default, PartialEq, Clone, Serialize)]
79#[cfg_attr(feature = "schema", derive(JsonSchema))]
80#[serde(rename_all = "kebab-case")]
81pub struct Config {
82 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84 pub messages: HashMap<String, String>,
85 #[serde(
88 skip_serializing_if = "Option::is_none",
89 deserialize_with = "deserialize_substitute_config",
90 default
91 )]
92 pub substitute: Option<SubstituteConfig>,
93 #[serde(
96 skip_serializing_if = "Option::is_none",
97 deserialize_with = "deserialize_date_substitute",
98 default
99 )]
100 #[cfg_attr(feature = "schema", schemars(with = "Option<DateSubstituteEntry>"))]
101 pub date_substitute: Option<DateSubstitute>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub processing: Option<Processing>,
105 #[serde(skip_serializing_if = "Option::is_none")]
111 pub locale_override: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub localize: Option<Localize>,
115 #[serde(
118 skip_serializing_if = "Option::is_none",
119 deserialize_with = "deserialize_multilingual_config",
120 default
121 )]
122 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
123 pub multilingual: Option<MultilingualConfig>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub sorting: Option<SortingConfig>,
127 #[serde(
130 skip_serializing_if = "Option::is_none",
131 deserialize_with = "deserialize_contributor_config",
132 default
133 )]
134 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
135 pub contributors: Option<ContributorConfig>,
136 #[serde(
139 skip_serializing_if = "Option::is_none",
140 deserialize_with = "deserialize_date_config",
141 default
142 )]
143 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
144 pub dates: Option<DateConfig>,
145 #[serde(
148 skip_serializing_if = "Option::is_none",
149 deserialize_with = "deserialize_titles_config",
150 default
151 )]
152 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
153 pub titles: Option<crate::options::titles::TitlesConfig>,
154 #[serde(
157 skip_serializing_if = "Option::is_none",
158 deserialize_with = "deserialize_locator_config",
159 default
160 )]
161 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
162 pub locators: Option<LocatorConfig>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub page_range_format: Option<PageRangeFormat>,
166 #[serde(skip_serializing_if = "Option::is_none")]
169 pub page_range_delimiter: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub links: Option<LinksConfig>,
173 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183 pub punctuation_in_quote: bool,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub punctuation: Option<PunctuationConfig>,
187 #[serde(skip_serializing_if = "Option::is_none")]
191 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
192 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
194 pub strip_periods: Option<bool>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub notes: Option<NoteConfig>,
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub custom: Option<HashMap<String, serde_json::Value>>,
207 #[serde(
211 flatten,
212 default,
213 skip_serializing_if = "std::collections::BTreeMap::is_empty"
214 )]
215 #[cfg_attr(feature = "schema", schemars(skip))]
216 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
217}
218
219#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "schema", derive(JsonSchema))]
222#[serde(rename_all = "kebab-case")]
223pub struct CitationOptions {
224 #[serde(
227 skip_serializing_if = "Option::is_none",
228 deserialize_with = "deserialize_substitute_config",
229 default
230 )]
231 pub substitute: Option<SubstituteConfig>,
232 #[serde(
234 skip_serializing_if = "Option::is_none",
235 deserialize_with = "deserialize_date_substitute",
236 default
237 )]
238 #[cfg_attr(feature = "schema", schemars(with = "Option<DateSubstituteEntry>"))]
239 pub date_substitute: Option<DateSubstitute>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub processing: Option<Processing>,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub localize: Option<Localize>,
246 #[serde(
249 skip_serializing_if = "Option::is_none",
250 deserialize_with = "deserialize_multilingual_config",
251 default
252 )]
253 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
254 pub multilingual: Option<MultilingualConfig>,
255 #[serde(
257 skip_serializing_if = "Option::is_none",
258 deserialize_with = "deserialize_contributor_config",
259 default
260 )]
261 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
262 pub contributors: Option<ContributorConfig>,
263 #[serde(
265 skip_serializing_if = "Option::is_none",
266 deserialize_with = "deserialize_date_config",
267 default
268 )]
269 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
270 pub dates: Option<DateConfig>,
271 #[serde(
273 skip_serializing_if = "Option::is_none",
274 deserialize_with = "deserialize_titles_config",
275 default
276 )]
277 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
278 pub titles: Option<crate::options::titles::TitlesConfig>,
279 #[serde(
281 skip_serializing_if = "Option::is_none",
282 deserialize_with = "deserialize_locator_config",
283 default
284 )]
285 #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
286 pub locators: Option<LocatorConfig>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub page_range_format: Option<PageRangeFormat>,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub links: Option<LinksConfig>,
293 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
295 pub punctuation_in_quote: bool,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
299 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
301 pub strip_periods: Option<bool>,
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub notes: Option<NoteConfig>,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub integral_name_memory: Option<IntegralNameMemoryConfig>,
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
311 #[serde(skip_serializing_if = "Option::is_none")]
315 pub label_mode: Option<CitationLabelMode>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub label_wrap: Option<LabelWrap>,
319 #[serde(skip_serializing_if = "Option::is_none")]
328 pub item_wrap: Option<LabelWrap>,
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub group_delimiter: Option<CitationGroupDelimiter>,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub custom: Option<HashMap<String, serde_json::Value>>,
335 #[serde(
339 flatten,
340 default,
341 skip_serializing_if = "std::collections::BTreeMap::is_empty"
342 )]
343 #[cfg_attr(feature = "schema", schemars(skip))]
344 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
345}
346
347#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
349#[cfg_attr(feature = "schema", derive(JsonSchema))]
350#[serde(rename_all = "kebab-case")]
351pub struct BibliographyOptions {
352 #[serde(
355 skip_serializing_if = "Option::is_none",
356 deserialize_with = "deserialize_substitute_config",
357 default
358 )]
359 pub substitute: Option<SubstituteConfig>,
360 #[serde(
362 skip_serializing_if = "Option::is_none",
363 deserialize_with = "deserialize_date_substitute",
364 default
365 )]
366 #[cfg_attr(feature = "schema", schemars(with = "Option<DateSubstituteEntry>"))]
367 pub date_substitute: Option<DateSubstitute>,
368 #[serde(skip_serializing_if = "Option::is_none")]
370 pub processing: Option<Processing>,
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub localize: Option<Localize>,
374 #[serde(
377 skip_serializing_if = "Option::is_none",
378 deserialize_with = "deserialize_multilingual_config",
379 default
380 )]
381 #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
382 pub multilingual: Option<MultilingualConfig>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub sorting: Option<SortingConfig>,
386 #[serde(
388 skip_serializing_if = "Option::is_none",
389 deserialize_with = "deserialize_contributor_config",
390 default
391 )]
392 #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
393 pub contributors: Option<ContributorConfig>,
394 #[serde(
396 skip_serializing_if = "Option::is_none",
397 deserialize_with = "deserialize_date_config",
398 default
399 )]
400 #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
401 pub dates: Option<DateConfig>,
402 #[serde(
404 skip_serializing_if = "Option::is_none",
405 deserialize_with = "deserialize_titles_config",
406 default
407 )]
408 #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
409 pub titles: Option<crate::options::titles::TitlesConfig>,
410 #[serde(skip_serializing_if = "Option::is_none")]
412 pub page_range_format: Option<PageRangeFormat>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub article_journal: Option<ArticleJournalBibliographyConfig>,
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub subsequent_author_substitute: Option<String>,
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
422 #[serde(skip_serializing_if = "Option::is_none")]
424 pub hanging_indent: Option<bool>,
425 #[serde(skip_serializing_if = "Option::is_none")]
428 pub entry_suffix: Option<DelimiterPunctuation>,
429 #[serde(skip_serializing_if = "Option::is_none")]
432 pub separator: Option<DelimiterPunctuation>,
433 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
435 pub suppress_period_after_url: bool,
436 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438 pub entry_suffix_after_url: bool,
439 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
441 pub entry_suffix_after_doi: bool,
442 #[serde(skip_serializing_if = "Option::is_none")]
444 pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
445 #[serde(skip_serializing_if = "Option::is_none")]
447 pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
448 #[serde(skip_serializing_if = "Option::is_none")]
452 pub anonymous_entries: Option<AnonymousEntriesMode>,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub links: Option<LinksConfig>,
456 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
458 pub punctuation_in_quote: bool,
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub volume_pages_delimiter: Option<DelimiterPunctuation>,
462 #[serde(skip_serializing_if = "Option::is_none")]
466 pub label_mode: Option<BibliographyLabelMode>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub label_wrap: Option<BibliographyLabelWrap>,
470 #[serde(skip_serializing_if = "Option::is_none")]
476 pub label_separator: Option<String>,
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub date_position: Option<DatePosition>,
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub title_terminator: Option<TitleTerminator>,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
486 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
488 pub strip_periods: Option<bool>,
489 #[serde(skip_serializing_if = "Option::is_none")]
491 pub custom: Option<HashMap<String, serde_json::Value>>,
492 #[serde(
496 flatten,
497 default,
498 skip_serializing_if = "std::collections::BTreeMap::is_empty"
499 )]
500 #[cfg_attr(feature = "schema", schemars(skip))]
501 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
502}
503
504#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "schema", derive(JsonSchema))]
507#[serde(rename_all = "kebab-case")]
508pub struct NoteConfig {
509 #[serde(skip_serializing_if = "Option::is_none")]
512 pub punctuation: Option<NoteQuotePlacement>,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 pub number: Option<NoteNumberPlacement>,
516 #[serde(skip_serializing_if = "Option::is_none")]
519 pub order: Option<NoteMarkerOrder>,
520 #[serde(
524 flatten,
525 default,
526 skip_serializing_if = "std::collections::BTreeMap::is_empty"
527 )]
528 #[cfg_attr(feature = "schema", schemars(skip))]
529 pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
530}
531
532#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
534#[cfg_attr(feature = "schema", derive(JsonSchema))]
535#[serde(rename_all = "kebab-case")]
536pub struct PunctuationConfig {
537 #[serde(skip_serializing_if = "Option::is_none")]
539 pub strong_terminal_comma_policy: Option<StrongTerminalCommaPolicy>,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub delimiter_suppressing_terminal_marks: Option<String>,
543}
544
545impl PunctuationConfig {
546 fn merge(&mut self, other: &Self) {
548 if let Some(policy) = other.strong_terminal_comma_policy {
549 self.strong_terminal_comma_policy = Some(policy);
550 }
551 if let Some(marks) = &other.delimiter_suppressing_terminal_marks {
552 self.delimiter_suppressing_terminal_marks = Some(marks.clone());
553 }
554 }
555}
556
557#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
559#[cfg_attr(feature = "schema", derive(JsonSchema))]
560#[serde(rename_all = "kebab-case")]
561pub enum StrongTerminalCommaPolicy {
562 #[default]
564 KeepBoth,
565 KeepTerminal,
567}
568
569#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
571#[cfg_attr(feature = "schema", derive(JsonSchema))]
572#[serde(rename_all = "kebab-case")]
573pub enum NoteQuotePlacement {
574 Inside,
576 Outside,
578 #[default]
582 Adaptive,
583}
584
585#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
587#[cfg_attr(feature = "schema", derive(JsonSchema))]
588#[serde(rename_all = "kebab-case")]
589pub enum NoteNumberPlacement {
590 Inside,
592 #[default]
594 Outside,
595 Same,
598}
599
600#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
602#[cfg_attr(feature = "schema", derive(JsonSchema))]
603#[serde(rename_all = "kebab-case")]
604pub enum NoteMarkerOrder {
605 Before,
607 #[default]
609 After,
610}
611
612#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
614#[cfg_attr(feature = "schema", derive(JsonSchema))]
615#[serde(rename_all = "kebab-case")]
616#[non_exhaustive]
617pub enum PageRangeFormat {
618 #[default]
620 Expanded,
621 Minimal,
623 MinimalTwo,
625 Chicago,
627 Chicago16,
629}
630
631pub mod titles;
632
633pub use title_class::{
634 KNOWN_REFERENCE_TYPE_NAMES, ReferenceTypeName, TitleCategory, classified_ref_types,
635 container_title_category, parent_serial_title_category, title_category,
636};
637pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
638
639fn merge_date_substitute(base: &mut Option<DateSubstitute>, overlay: Option<&DateSubstitute>) {
640 let Some(overlay) = overlay else {
641 return;
642 };
643 if let Some(base) = base {
644 base.merge(overlay);
645 } else {
646 *base = Some(overlay.clone());
647 }
648}
649
650#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
652#[cfg_attr(feature = "schema", derive(JsonSchema))]
653#[serde(rename_all = "kebab-case")]
654pub struct LinksConfig {
655 #[serde(skip_serializing_if = "Option::is_none")]
657 pub doi: Option<bool>,
658 #[serde(skip_serializing_if = "Option::is_none")]
660 pub url: Option<bool>,
661 #[serde(skip_serializing_if = "Option::is_none")]
663 pub target: Option<LinkTarget>,
664 #[serde(skip_serializing_if = "Option::is_none")]
666 pub anchor: Option<LinkAnchor>,
667 #[serde(skip_serializing_if = "Option::is_none")]
669 pub strip_protocol: Option<bool>,
670}
671
672#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
674#[cfg_attr(feature = "schema", derive(JsonSchema))]
675#[serde(rename_all = "kebab-case")]
676pub enum LinkTarget {
677 Url,
678 Doi,
679 UrlOrDoi,
680 Pubmed,
681 Pmcid,
682}
683
684#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
686#[cfg_attr(feature = "schema", derive(JsonSchema))]
687#[serde(rename_all = "kebab-case")]
688pub enum LinkAnchor {
689 Title,
691 Url,
693 Doi,
695 Component,
697 Entry,
699}
700
701impl Config {
702 fn merge_multilingual(&mut self, other: &Config) {
708 let Some(other_multilingual) = &other.multilingual else {
709 return;
710 };
711 if let Some(multilingual) = &mut self.multilingual {
712 multilingual.merge(other_multilingual);
713 } else {
714 self.multilingual = Some(other_multilingual.clone());
715 }
716 }
717
718 fn merge_punctuation(&mut self, other: &Config) {
719 let Some(other_punctuation) = &other.punctuation else {
720 return;
721 };
722 if let Some(punctuation) = &mut self.punctuation {
723 punctuation.merge(other_punctuation);
724 } else {
725 self.punctuation = Some(other_punctuation.clone());
726 }
727 }
728
729 pub fn effective_processing(&self) -> Processing {
734 self.processing.clone().unwrap_or_default()
735 }
736
737 pub fn merge(&mut self, other: &Config) {
742 crate::merge_options!(
743 self,
744 other,
745 processing,
746 locale_override,
747 localize,
748 dates,
749 titles,
750 locators,
751 page_range_format,
752 page_range_delimiter,
753 links,
754 volume_pages_delimiter,
755 locale_override,
756 strip_periods,
757 notes,
758 integral_name_memory,
759 org_abbreviation_memory,
760 custom,
761 );
762
763 self.merge_multilingual(other);
764 self.merge_punctuation(other);
765 self.messages.extend(other.messages.clone());
766
767 if let Some(other_sorting) = &other.sorting {
768 if let Some(this_sorting) = &mut self.sorting {
769 this_sorting.merge(other_sorting);
770 } else {
771 self.sorting = Some(other_sorting.clone());
772 }
773 }
774
775 if let Some(other_substitute) = &other.substitute {
776 if let Some(this_substitute) = &self.substitute {
777 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
778 } else {
779 self.substitute = Some(other_substitute.clone());
780 }
781 }
782
783 merge_date_substitute(&mut self.date_substitute, other.date_substitute.as_ref());
784
785 if let Some(other_contributors) = &other.contributors {
786 if let Some(this_contributors) = &mut self.contributors {
787 this_contributors.merge(other_contributors);
788 } else {
789 self.contributors = Some(other_contributors.clone());
790 }
791 }
792
793 if other.punctuation_in_quote {
794 self.punctuation_in_quote = true;
795 }
796 }
797
798 pub fn merged(base: &Config, override_config: &Config) -> Config {
802 let mut result = base.clone();
803 result.merge(override_config);
804 result
805 }
806}
807
808impl CitationOptions {
809 #[must_use]
811 pub fn to_config(&self) -> Config {
812 Config {
813 messages: HashMap::new(),
814 substitute: self.substitute.clone(),
815 date_substitute: self.date_substitute.clone(),
816 processing: self.processing.clone(),
817 locale_override: None,
818 localize: self.localize.clone(),
819 multilingual: self.multilingual.clone(),
820 sorting: None,
821 contributors: self.contributors.clone(),
822 dates: self.dates.clone(),
823 titles: self.titles.clone(),
824 locators: self.locators.clone(),
825 page_range_format: self.page_range_format.clone(),
826 page_range_delimiter: None,
827 links: self.links.clone(),
828 punctuation_in_quote: self.punctuation_in_quote,
829 punctuation: None,
830 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
831 strip_periods: self.strip_periods,
832 notes: self.notes.clone(),
833 integral_name_memory: self.integral_name_memory.clone(),
834 org_abbreviation_memory: self.org_abbreviation_memory.clone(),
835 custom: self.custom.clone(),
836 unknown_fields: std::collections::BTreeMap::new(),
837 }
838 }
839
840 #[must_use]
842 pub fn merged_with(&self, base: &Config) -> Config {
843 Config::merged(base, &self.to_config())
844 }
845
846 #[must_use]
855 pub fn merged_with_raw(
856 &self,
857 base: &Config,
858 raw_options: Option<&serde_yaml::Value>,
859 ) -> Config {
860 let mut merged = self.merged_with(base);
861 if let Some(raw) = raw_options
862 && cascade::authored_matches(raw, self)
863 {
864 cascade::merge_citation_config_blocks_from_raw(&mut merged, base, raw);
865 }
866 merged
867 }
868
869 pub fn merge(&mut self, other: &CitationOptions) {
871 crate::merge_options!(
872 self,
873 other,
874 processing,
875 localize,
876 multilingual,
877 dates,
878 titles,
879 locators,
880 page_range_format,
881 links,
882 volume_pages_delimiter,
883 strip_periods,
884 notes,
885 integral_name_memory,
886 org_abbreviation_memory,
887 label_mode,
888 label_wrap,
889 item_wrap,
890 group_delimiter,
891 custom,
892 );
893
894 if let Some(other_substitute) = &other.substitute {
895 if let Some(this_substitute) = &self.substitute {
896 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
897 } else {
898 self.substitute = Some(other_substitute.clone());
899 }
900 }
901
902 merge_date_substitute(&mut self.date_substitute, other.date_substitute.as_ref());
903
904 if let Some(other_contributors) = &other.contributors {
905 if let Some(this_contributors) = &mut self.contributors {
906 this_contributors.merge(other_contributors);
907 } else {
908 self.contributors = Some(other_contributors.clone());
909 }
910 }
911
912 if other.punctuation_in_quote {
913 self.punctuation_in_quote = true;
914 }
915 }
916}
917
918impl BibliographyOptions {
919 #[must_use]
921 pub fn to_bibliography_config(&self) -> BibliographyConfig {
922 BibliographyConfig {
923 article_journal: self.article_journal.clone(),
924 subsequent_author_substitute: self.subsequent_author_substitute.clone(),
925 subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
926 hanging_indent: self.hanging_indent,
927 entry_suffix: self.entry_suffix.clone(),
928 separator: self.separator.clone(),
929 suppress_period_after_url: self.suppress_period_after_url,
930 entry_suffix_after_url: self.entry_suffix_after_url,
931 entry_suffix_after_doi: self.entry_suffix_after_doi,
932 label_mode: self.label_mode,
933 label_wrap: self.label_wrap,
934 label_separator: self.label_separator.clone(),
935 custom: None,
936 compound_numeric: self.compound_numeric.clone(),
937 sort_partitioning: self.sort_partitioning.clone(),
938 anonymous_entries: self.anonymous_entries,
939 unknown_fields: std::collections::BTreeMap::new(),
940 }
941 }
942
943 #[must_use]
945 pub fn to_config(&self) -> Config {
946 Config {
947 messages: HashMap::new(),
948 substitute: self.substitute.clone(),
949 date_substitute: self.date_substitute.clone(),
950 processing: self.processing.clone(),
951 locale_override: None,
952 localize: self.localize.clone(),
953 multilingual: self.multilingual.clone(),
954 sorting: self.sorting.clone(),
955 contributors: self.contributors.clone(),
956 dates: self.dates.clone(),
957 titles: self.titles.clone(),
958 locators: None,
959 page_range_format: self.page_range_format.clone(),
960 page_range_delimiter: None,
961 links: self.links.clone(),
962 punctuation_in_quote: self.punctuation_in_quote,
963 punctuation: None,
964 volume_pages_delimiter: self.volume_pages_delimiter.clone(),
965 strip_periods: self.strip_periods,
966 notes: None,
967 integral_name_memory: None,
968 org_abbreviation_memory: None,
969 custom: self.custom.clone(),
970 unknown_fields: std::collections::BTreeMap::new(),
971 }
972 }
973
974 #[must_use]
976 pub fn merged_with(&self, base: &Config) -> Config {
977 Config::merged(base, &self.to_config())
978 }
979
980 #[must_use]
989 pub fn merged_with_raw(
990 &self,
991 base: &Config,
992 raw_options: Option<&serde_yaml::Value>,
993 ) -> Config {
994 let mut merged = self.merged_with(base);
995 if let Some(raw) = raw_options
996 && cascade::authored_matches(raw, self)
997 {
998 cascade::merge_bibliography_config_blocks_from_raw(&mut merged, base, raw);
999 }
1000 merged
1001 }
1002
1003 pub fn merge(&mut self, other: &BibliographyOptions) {
1005 crate::merge_options!(
1006 self,
1007 other,
1008 processing,
1009 localize,
1010 multilingual,
1011 dates,
1012 titles,
1013 page_range_format,
1014 links,
1015 volume_pages_delimiter,
1016 strip_periods,
1017 article_journal,
1018 subsequent_author_substitute,
1019 subsequent_author_substitute_rule,
1020 hanging_indent,
1021 entry_suffix,
1022 separator,
1023 compound_numeric,
1024 sort_partitioning,
1025 anonymous_entries,
1026 date_position,
1027 title_terminator,
1028 repeated_author_rendering,
1029 custom,
1030 );
1031
1032 self.merge_marker_fields(other);
1033 self.merge_shared_fields(other);
1034 }
1035
1036 fn merge_marker_fields(&mut self, other: &BibliographyOptions) {
1041 crate::merge_options!(self, other, label_mode, label_wrap, label_separator);
1042 }
1043
1044 fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
1045 if let Some(other_sorting) = &other.sorting {
1046 if let Some(this_sorting) = &mut self.sorting {
1047 this_sorting.merge(other_sorting);
1048 } else {
1049 self.sorting = Some(other_sorting.clone());
1050 }
1051 }
1052
1053 if let Some(other_substitute) = &other.substitute {
1054 if let Some(this_substitute) = &self.substitute {
1055 self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
1056 } else {
1057 self.substitute = Some(other_substitute.clone());
1058 }
1059 }
1060
1061 merge_date_substitute(&mut self.date_substitute, other.date_substitute.as_ref());
1062
1063 if let Some(other_contributors) = &other.contributors {
1064 if let Some(this_contributors) = &mut self.contributors {
1065 this_contributors.merge(other_contributors);
1066 } else {
1067 self.contributors = Some(other_contributors.clone());
1068 }
1069 }
1070
1071 if other.punctuation_in_quote {
1072 self.punctuation_in_quote = true;
1073 }
1074 if other.suppress_period_after_url {
1075 self.suppress_period_after_url = true;
1076 }
1077 if other.entry_suffix_after_url {
1078 self.entry_suffix_after_url = true;
1079 }
1080 if other.entry_suffix_after_doi {
1081 self.entry_suffix_after_doi = true;
1082 }
1083
1084 for (key, value) in &other.unknown_fields {
1085 self.unknown_fields.insert(key.clone(), value.clone());
1086 }
1087 }
1088}
1089
1090fn deserialize_contributor_config<'de, D>(
1092 deserializer: D,
1093) -> Result<Option<ContributorConfig>, D::Error>
1094where
1095 D: serde::Deserializer<'de>,
1096{
1097 let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
1098 Ok(value.map(|entry| entry.resolve()))
1099}
1100
1101fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
1103where
1104 D: serde::Deserializer<'de>,
1105{
1106 let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
1107 Ok(value.map(|entry| entry.resolve()))
1108}
1109
1110fn deserialize_date_substitute<'de, D>(deserializer: D) -> Result<Option<DateSubstitute>, D::Error>
1112where
1113 D: serde::Deserializer<'de>,
1114{
1115 let value: Option<DateSubstituteEntry> = Option::deserialize(deserializer)?;
1116 Ok(value.map(|entry| entry.resolve()))
1117}
1118
1119fn deserialize_titles_config<'de, D>(
1121 deserializer: D,
1122) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
1123where
1124 D: serde::Deserializer<'de>,
1125{
1126 let value: Option<crate::options::titles::TitlesConfigEntry> =
1127 Option::deserialize(deserializer)?;
1128 Ok(value.map(|entry| entry.resolve()))
1129}
1130
1131fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
1133where
1134 D: serde::Deserializer<'de>,
1135{
1136 let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
1137 Ok(value.map(|entry| entry.resolve()))
1138}
1139
1140fn deserialize_substitute_config<'de, D>(
1150 deserializer: D,
1151) -> Result<Option<SubstituteConfig>, D::Error>
1152where
1153 D: serde::Deserializer<'de>,
1154{
1155 let value: Option<SubstituteConfig> = Option::deserialize(deserializer)?;
1156 Ok(value.map(|config| match config {
1157 SubstituteConfig::Preset(preset) => SubstituteConfig::Explicit(preset.config()),
1158 explicit @ SubstituteConfig::Explicit(_) => explicit,
1159 }))
1160}
1161
1162fn deserialize_multilingual_config<'de, D>(
1164 deserializer: D,
1165) -> Result<Option<MultilingualConfig>, D::Error>
1166where
1167 D: serde::Deserializer<'de>,
1168{
1169 let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
1170 Ok(value.map(|entry| entry.resolve()))
1171}
1172
1173impl<'de> Deserialize<'de> for Config {
1174 #[allow(
1175 clippy::too_many_lines,
1176 reason = "the local wire type intentionally mirrors the complete public config"
1177 )]
1178 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1179 where
1180 D: serde::Deserializer<'de>,
1181 {
1182 #[derive(Deserialize)]
1183 #[serde(rename_all = "kebab-case")]
1184 struct ConfigWire {
1185 #[serde(default)]
1186 messages: HashMap<String, String>,
1187 #[serde(
1188 skip_serializing_if = "Option::is_none",
1189 deserialize_with = "deserialize_substitute_config",
1190 default
1191 )]
1192 substitute: Option<SubstituteConfig>,
1193 #[serde(
1194 skip_serializing_if = "Option::is_none",
1195 deserialize_with = "deserialize_date_substitute",
1196 default
1197 )]
1198 date_substitute: Option<DateSubstitute>,
1199 #[serde(skip_serializing_if = "Option::is_none")]
1200 processing: Option<Processing>,
1201 #[serde(skip_serializing_if = "Option::is_none")]
1202 locale_override: Option<String>,
1203 #[serde(skip_serializing_if = "Option::is_none")]
1204 localize: Option<Localize>,
1205 #[serde(
1206 skip_serializing_if = "Option::is_none",
1207 deserialize_with = "deserialize_multilingual_config",
1208 default
1209 )]
1210 multilingual: Option<MultilingualConfig>,
1211 #[serde(skip_serializing_if = "Option::is_none")]
1212 sorting: Option<SortingConfig>,
1213 #[serde(
1214 skip_serializing_if = "Option::is_none",
1215 deserialize_with = "deserialize_contributor_config",
1216 default
1217 )]
1218 contributors: Option<ContributorConfig>,
1219 #[serde(
1220 skip_serializing_if = "Option::is_none",
1221 deserialize_with = "deserialize_date_config",
1222 default
1223 )]
1224 dates: Option<DateConfig>,
1225 #[serde(
1226 skip_serializing_if = "Option::is_none",
1227 deserialize_with = "deserialize_titles_config",
1228 default
1229 )]
1230 titles: Option<crate::options::titles::TitlesConfig>,
1231 #[serde(
1232 skip_serializing_if = "Option::is_none",
1233 deserialize_with = "deserialize_locator_config",
1234 default
1235 )]
1236 locators: Option<LocatorConfig>,
1237 #[serde(skip_serializing_if = "Option::is_none")]
1238 page_range_format: Option<PageRangeFormat>,
1239 #[serde(skip_serializing_if = "Option::is_none")]
1240 page_range_delimiter: Option<String>,
1241 #[serde(skip_serializing_if = "Option::is_none")]
1242 links: Option<LinksConfig>,
1243 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1244 punctuation_in_quote: bool,
1245 #[serde(skip_serializing_if = "Option::is_none")]
1246 punctuation: Option<PunctuationConfig>,
1247 #[serde(skip_serializing_if = "Option::is_none")]
1248 volume_pages_delimiter: Option<DelimiterPunctuation>,
1249 #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
1250 strip_periods: Option<bool>,
1251 #[serde(skip_serializing_if = "Option::is_none")]
1252 notes: Option<NoteConfig>,
1253 #[serde(skip_serializing_if = "Option::is_none")]
1254 integral_name_memory: Option<IntegralNameMemoryConfig>,
1255 #[serde(skip_serializing_if = "Option::is_none")]
1256 org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
1257 #[serde(default)]
1258 profile: Option<serde_yaml::Value>,
1259 #[serde(skip_serializing_if = "Option::is_none")]
1260 custom: Option<HashMap<String, serde_json::Value>>,
1261 #[serde(flatten)]
1262 unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
1263 }
1264
1265 let wire = ConfigWire::deserialize(deserializer)?;
1266 if wire.profile.is_some() {
1267 return Err(serde::de::Error::custom(
1268 "`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`",
1269 ));
1270 }
1271
1272 Ok(Self {
1273 messages: wire.messages,
1274 substitute: wire.substitute,
1275 date_substitute: wire.date_substitute,
1276 processing: wire.processing,
1277 locale_override: wire.locale_override,
1278 localize: wire.localize,
1279 multilingual: wire.multilingual,
1280 sorting: wire.sorting,
1281 contributors: wire.contributors,
1282 dates: wire.dates,
1283 titles: wire.titles,
1284 locators: wire.locators,
1285 page_range_format: wire.page_range_format,
1286 page_range_delimiter: wire.page_range_delimiter,
1287 links: wire.links,
1288 punctuation_in_quote: wire.punctuation_in_quote,
1289 punctuation: wire.punctuation,
1290 volume_pages_delimiter: wire.volume_pages_delimiter,
1291 strip_periods: wire.strip_periods,
1292 notes: wire.notes,
1293 integral_name_memory: wire.integral_name_memory,
1294 org_abbreviation_memory: wire.org_abbreviation_memory,
1295 custom: wire.custom,
1296 unknown_fields: wire.unknown_fields,
1297 })
1298 }
1299}
1300
1301#[cfg(test)]
1302#[allow(
1303 clippy::unwrap_used,
1304 clippy::expect_used,
1305 clippy::panic,
1306 clippy::indexing_slicing,
1307 clippy::todo,
1308 clippy::unimplemented,
1309 clippy::unreachable,
1310 clippy::get_unwrap,
1311 reason = "Panicking is acceptable and often desired in tests."
1312)]
1313mod tests {
1314 use super::*;
1315 use rstest::rstest;
1316
1317 #[test]
1318 fn date_substitute_omission_is_not_resolved_to_standard() {
1319 let config: Config = serde_yaml::from_str("{}").expect("empty config should parse");
1320
1321 assert!(config.date_substitute.is_none());
1322 }
1323
1324 #[test]
1325 fn date_substitute_preset_is_eagerly_expanded() {
1326 let config: Config = serde_yaml::from_str("date-substitute: standard")
1327 .expect("standard preset should parse");
1328 let serialized = serde_yaml::to_value(&config).expect("config should serialize");
1329
1330 assert!(
1331 config
1332 .date_substitute
1333 .as_ref()
1334 .and_then(|policy| policy.candidates_for("report"))
1335 .is_some_and(|candidates| matches!(
1336 candidates,
1337 [DateSubstituteCandidate::Message(_)]
1338 ))
1339 );
1340 assert!(serialized["date-substitute"].is_mapping());
1341 }
1342
1343 #[test]
1344 fn explicit_date_substitute_map_preserves_authored_selector_order() {
1345 let config: Config = serde_yaml::from_str(
1346 r#"
1347date-substitute:
1348 book,thesis,map:
1349 - date: copyright
1350 form: year
1351 prefix: c
1352 default:
1353 - message: term.no-date
1354 form: short
1355"#,
1356 )
1357 .expect("explicit selector map should parse");
1358 let policy = config
1359 .date_substitute
1360 .expect("date substitute should be present");
1361
1362 let selectors: Vec<String> = policy.entries().keys().map(ToString::to_string).collect();
1363 assert_eq!(selectors, ["book,thesis,map", "default"]);
1364 assert!(matches!(
1365 policy.candidates_for("book"),
1366 Some([DateSubstituteCandidate::Date(_)])
1367 ));
1368 }
1369
1370 #[test]
1371 fn test_config_default() {
1372 let config = Config::default();
1373 assert!(config.substitute.is_none());
1374 assert!(config.processing.is_none());
1375 }
1376
1377 #[test]
1378 fn test_author_date_processing() {
1379 let processing = Processing::AuthorDate;
1380 let config = processing.config();
1381 let disambiguate = config.disambiguate.unwrap();
1382 assert!(disambiguate.year_suffix);
1383 assert!(!disambiguate.names);
1384 assert!(!disambiguate.add_givenname);
1385 assert_eq!(
1386 processing.default_bibliography_sort(),
1387 Some(crate::presets::SortPreset::AuthorDateTitle)
1388 );
1389 assert_eq!(
1390 config.sort,
1391 Some(SortEntry::Preset(
1392 crate::presets::SortPreset::AuthorDateTitle
1393 ))
1394 );
1395 }
1396
1397 #[test]
1398 fn test_processing_default_bibliography_sorts() {
1399 assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
1400 assert_eq!(
1401 Processing::Note.default_bibliography_sort(),
1402 Some(crate::presets::SortPreset::AuthorTitleDate)
1403 );
1404 assert_eq!(
1405 Processing::Label(LabelConfig::default()).default_bibliography_sort(),
1406 Some(crate::presets::SortPreset::AuthorDateTitle)
1407 );
1408 }
1409
1410 #[test]
1411 fn test_processing_default_citation_sort_policy_is_explicit_only() {
1412 assert_eq!(
1413 Processing::AuthorDate.default_citation_sort_policy(),
1414 CitationSortPolicy::ExplicitOnly
1415 );
1416 assert_eq!(
1417 Processing::Note.default_citation_sort_policy(),
1418 CitationSortPolicy::ExplicitOnly
1419 );
1420 }
1421
1422 #[test]
1423 fn test_substitute_default() {
1424 let sub = Substitute::default();
1425 assert_eq!(sub.template.len(), 3);
1426 }
1427
1428 #[test]
1429 fn test_config_yaml_roundtrip() {
1430 let yaml = r#"
1431substitute:
1432 contributor-role-form: short
1433 template:
1434 - editor
1435 - title
1436processing: author-date
1437contributors:
1438 display-as-sort: first
1439 and: symbol
1440"#;
1441 let config: Config = serde_yaml::from_str(yaml).unwrap();
1442 assert!(config.substitute.is_some());
1443 assert_eq!(config.processing, Some(Processing::AuthorDate));
1444 assert_eq!(
1445 config.contributors.as_ref().unwrap().and,
1446 Some(AndOptions::Symbol)
1447 );
1448 }
1449
1450 #[test]
1451 fn test_sorting_config_deserializes_and_roundtrips() {
1452 let yaml = r#"
1453sorting:
1454 locale: sv-SE
1455 multilingual: romanized
1456"#;
1457 let config: Config = serde_yaml::from_str(yaml).unwrap();
1458 let sorting = config.sorting.as_ref().expect("sorting should parse");
1459
1460 assert_eq!(
1461 sorting.locale,
1462 Some(SortingLocale::Bcp47("sv-SE".to_string()))
1463 );
1464 assert_eq!(
1465 sorting.multilingual,
1466 Some(SortingMultilingualMode::Romanized)
1467 );
1468
1469 let serialized = serde_yaml::to_string(&config).unwrap();
1470 let reparsed: Config = serde_yaml::from_str(&serialized).unwrap();
1471 assert_eq!(reparsed.sorting, config.sorting);
1472 }
1473
1474 #[test]
1475 fn test_sorting_config_defaults_and_unknown_fields() {
1476 let yaml = r#"
1477sorting:
1478 future-key: true
1479"#;
1480 let config: Config = serde_yaml::from_str(yaml).unwrap();
1481 let sorting = config.sorting.as_ref().expect("sorting should parse");
1482
1483 assert_eq!(sorting.effective_locale(), SortingLocale::Auto);
1484 assert_eq!(
1485 sorting.effective_multilingual(),
1486 SortingMultilingualMode::Uniform
1487 );
1488 assert!(sorting.unknown_fields.contains_key("future-key"));
1489 }
1490
1491 #[test]
1492 fn test_bibliography_sorting_override_merges_partially() {
1493 let base: Config = serde_yaml::from_str(
1494 r#"
1495sorting:
1496 locale: de-DE
1497 multilingual: uniform
1498"#,
1499 )
1500 .unwrap();
1501 let bib: BibliographyOptions = serde_yaml::from_str(
1502 r#"
1503sorting:
1504 multilingual: romanized
1505"#,
1506 )
1507 .unwrap();
1508
1509 let merged = bib.merged_with(&base);
1510 let sorting = merged.sorting.expect("merged sorting should exist");
1511 assert_eq!(
1512 sorting.locale,
1513 Some(SortingLocale::Bcp47("de-DE".to_string()))
1514 );
1515 assert_eq!(
1516 sorting.multilingual,
1517 Some(SortingMultilingualMode::Romanized)
1518 );
1519 }
1520
1521 #[test]
1522 fn test_contributor_config_preset() {
1523 let yaml = r#"contributors: apa"#;
1525 let config: Config = serde_yaml::from_str(yaml).unwrap();
1526 let contributors = config.contributors.unwrap();
1527 assert_eq!(contributors.and, Some(AndOptions::Symbol));
1528 assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1529 }
1530
1531 #[test]
1532 fn test_role_label_presets_parse_and_resolve_precedence() {
1533 let yaml = r#"
1534contributors:
1535 role:
1536 preset: short-suffix
1537 roles:
1538 editor:
1539 preset: long-suffix
1540"#;
1541 let config: Config = serde_yaml::from_str(yaml).unwrap();
1542 let contributors = config.contributors.unwrap();
1543
1544 assert_eq!(
1545 contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1546 Some(RoleLabelPreset::LongSuffix)
1547 );
1548 assert_eq!(
1549 contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1550 Some(RoleLabelPreset::ShortSuffix)
1551 );
1552
1553 let yaml_scalar = r#"
1555contributors:
1556 role: short-suffix
1557"#;
1558 let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1559 let contributors2 = config2.contributors.unwrap();
1560
1561 assert_eq!(
1562 contributors2
1563 .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1564 Some(RoleLabelPreset::ShortSuffix)
1565 );
1566 }
1567
1568 #[test]
1569 fn test_role_specific_name_order_override_is_available() {
1570 let yaml = r#"
1571contributors:
1572 role:
1573 roles:
1574 translator:
1575 name-order: given-first
1576"#;
1577 let config: Config = serde_yaml::from_str(yaml).unwrap();
1578 let contributors = config.contributors.unwrap();
1579
1580 assert_eq!(
1581 contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1582 Some(&crate::template::NameOrder::GivenFirst)
1583 );
1584 }
1585
1586 #[test]
1587 fn test_date_config_preset() {
1588 let yaml = r#"dates: long"#;
1590 let config: Config = serde_yaml::from_str(yaml).unwrap();
1591 let dates = config.dates.unwrap();
1592 assert_eq!(dates.month, MonthFormat::Long);
1593 }
1594
1595 #[test]
1596 fn test_titles_config_preset() {
1597 let yaml = r#"titles: chicago"#;
1599 let config: Config = serde_yaml::from_str(yaml).unwrap();
1600 let titles = config.titles.unwrap();
1601 assert_eq!(titles.component.unwrap().quote, Some(true));
1602 assert_eq!(titles.monograph.unwrap().emph, Some(true));
1603 }
1604
1605 #[test]
1606 fn test_substitute_config_preset() {
1607 let yaml = r#"substitute: standard"#;
1609 let config: Config = serde_yaml::from_str(yaml).unwrap();
1610 assert!(config.substitute.is_some());
1611 let resolved = config.substitute.unwrap().resolve();
1612 assert_eq!(resolved.template.len(), 3);
1613 assert_eq!(resolved.template[0], SubstituteKey::Editor);
1614 }
1615
1616 #[test]
1617 fn test_substitute_config_explicit() {
1618 let yaml = r#"
1620substitute:
1621 template:
1622 - title
1623 - editor
1624"#;
1625 let config: Config = serde_yaml::from_str(yaml).unwrap();
1626 let resolved = config.substitute.unwrap().resolve();
1627 assert_eq!(resolved.template[0], SubstituteKey::Title);
1628 assert_eq!(resolved.template[1], SubstituteKey::Editor);
1629 }
1630
1631 #[test]
1632 fn test_config_merge_precedence() {
1633 let base_yaml = r#"
1635processing: author-date
1636locale-override: en-US-base
1637contributors:
1638 display-as-sort: first
1639 and: symbol
1640"#;
1641 let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1642
1643 let override_yaml = r#"
1645contributors:
1646 and: text
1647locale-override: en-US-chicago
1648"#;
1649 let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1650
1651 base.merge(&override_config);
1653
1654 assert_eq!(base.processing, Some(Processing::AuthorDate));
1656 assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1657
1658 assert_eq!(
1660 base.contributors.as_ref().unwrap().and,
1661 Some(AndOptions::Text)
1662 );
1663 }
1664
1665 #[test]
1666 fn test_config_deserializes_locale_override() {
1667 let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1668 assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1669 }
1670
1671 #[test]
1672 fn test_config_merged_convenience() {
1673 let base = Config {
1674 processing: Some(Processing::AuthorDate),
1675 ..Default::default()
1676 };
1677 let override_config = Config {
1678 punctuation_in_quote: true,
1679 ..Default::default()
1680 };
1681
1682 let merged = Config::merged(&base, &override_config);
1683
1684 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1686 assert!(merged.punctuation_in_quote);
1687 }
1688
1689 #[test]
1690 fn test_citation_options_merge_overrides_citation_fields_only() {
1691 let base = Config {
1692 processing: Some(Processing::AuthorDate),
1693 ..Default::default()
1694 };
1695
1696 let overrides = CitationOptions {
1697 strip_periods: Some(true),
1698 locators: Some(LocatorConfig::default()),
1699 ..Default::default()
1700 };
1701
1702 let merged = overrides.merged_with(&base);
1703 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1704 assert!(merged.strip_periods.unwrap_or(false));
1705 assert!(merged.locators.is_some());
1706 }
1707
1708 #[test]
1709 fn test_punctuation_config_deserializes_and_merges_field_by_field() {
1710 let yaml = r#"
1711punctuation:
1712 strong-terminal-comma-policy: keep-terminal
1713 delimiter-suppressing-terminal-marks: "?!…"
1714"#;
1715 let config: Config = serde_yaml::from_str(yaml).unwrap();
1716 let punctuation = config.punctuation.as_ref().unwrap();
1717 assert_eq!(
1718 punctuation.strong_terminal_comma_policy,
1719 Some(StrongTerminalCommaPolicy::KeepTerminal)
1720 );
1721 assert_eq!(
1722 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1723 Some("?!…")
1724 );
1725
1726 let override_config = Config {
1727 punctuation: Some(PunctuationConfig {
1728 strong_terminal_comma_policy: Some(StrongTerminalCommaPolicy::KeepBoth),
1729 delimiter_suppressing_terminal_marks: None,
1730 }),
1731 ..Default::default()
1732 };
1733 let merged = Config::merged(&config, &override_config);
1734 let punctuation = merged.punctuation.as_ref().unwrap();
1735 assert_eq!(
1736 punctuation.strong_terminal_comma_policy,
1737 Some(StrongTerminalCommaPolicy::KeepBoth)
1738 );
1739 assert_eq!(
1740 punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1741 Some("?!…")
1742 );
1743 }
1744
1745 #[test]
1746 fn test_bibliography_options_merge_projects_shared_fields_only() {
1747 let base = Config {
1748 processing: Some(Processing::AuthorDate),
1749 ..Default::default()
1750 };
1751
1752 let overrides = BibliographyOptions {
1753 entry_suffix: Some(".".into()),
1754 separator: Some(", ".into()),
1755 suppress_period_after_url: true,
1756 ..Default::default()
1757 };
1758
1759 let merged = overrides.merged_with(&base);
1760 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1761 assert!(merged.locators.is_none());
1762 assert!(merged.notes.is_none());
1763 let bibliography = overrides.to_bibliography_config();
1764 assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1765 assert_eq!(bibliography.separator.as_deref(), Some(", "));
1766 assert!(bibliography.suppress_period_after_url);
1767 }
1768
1769 #[test]
1770 fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1771 let base = Config {
1772 processing: Some(Processing::AuthorDate),
1773 ..Default::default()
1774 };
1775
1776 let overrides = BibliographyOptions {
1777 contributors: Some(ContributorConfig::default()),
1778 ..Default::default()
1779 };
1780
1781 let merged = overrides.merged_with(&base);
1782 assert_eq!(merged.processing, Some(Processing::AuthorDate));
1783 assert!(merged.contributors.is_some());
1784 }
1785
1786 #[test]
1787 fn citation_options_captures_unknown_fields_for_forward_compat() {
1788 let yaml = "future-key: true\n";
1789 let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1790 assert!(opts.unknown_fields.contains_key("future-key"));
1791 }
1792
1793 #[test]
1794 fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1795 let yaml = "future-key: true\n";
1796 let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1797 assert!(opts.unknown_fields.contains_key("future-key"));
1798 }
1799
1800 #[test]
1801 fn note_config_captures_unknown_fields_for_forward_compat() {
1802 let yaml = "punctuation: inside\nfuture-key: true\n";
1803 let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1804 assert!(cfg.unknown_fields.contains_key("future-key"));
1805 assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1806 }
1807
1808 #[test]
1809 fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1810 let yaml = r#"multilingual: romanized-translated"#;
1812 let config: Config = serde_yaml::from_str(yaml).unwrap();
1813 let ml = config.multilingual.unwrap();
1814 assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1815 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1816 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1817 }
1818
1819 #[test]
1820 fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1821 let yaml = r#"multilingual: romanized-only"#;
1823 let config: Config = serde_yaml::from_str(yaml).unwrap();
1824 let ml = config.multilingual.unwrap();
1825 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1826 assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1827 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1828 }
1829
1830 #[test]
1831 fn test_multilingual_preset_romanized_script_translated_parses_and_resolves() {
1832 use crate::options::multilingual::{MultilingualSegment, MultilingualView, SegmentWrap};
1835 let yaml = r#"multilingual: romanized-script-translated"#;
1836 let config: Config = serde_yaml::from_str(yaml).unwrap();
1837 let ml = config.multilingual.unwrap();
1838 assert_eq!(
1839 ml.title_mode,
1840 Some(MultilingualMode::Pattern(vec![
1841 MultilingualSegment {
1842 view: MultilingualView::Transliterated,
1843 wrap: SegmentWrap::None,
1844 },
1845 MultilingualSegment {
1846 view: MultilingualView::OriginalScript,
1847 wrap: SegmentWrap::None,
1848 },
1849 MultilingualSegment {
1850 view: MultilingualView::Translated,
1851 wrap: SegmentWrap::Brackets,
1852 },
1853 ]))
1854 );
1855 assert_eq!(
1856 ml.name_mode,
1857 Some(MultilingualMode::Pattern(vec![
1858 MultilingualSegment {
1859 view: MultilingualView::Transliterated,
1860 wrap: SegmentWrap::None,
1861 },
1862 MultilingualSegment {
1863 view: MultilingualView::OriginalScript,
1864 wrap: SegmentWrap::None,
1865 },
1866 ]))
1867 );
1868 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1869 assert!(ml.scripts.get("Han").is_some_and(|s| s.use_native_ordering));
1870 assert!(
1871 ml.scripts
1872 .get("Hangul")
1873 .is_some_and(|s| s.use_native_ordering)
1874 );
1875 }
1876
1877 #[test]
1878 fn test_multilingual_explicit_block_transliterated_roundtrips() {
1879 let yaml = r#"
1881multilingual:
1882 title-mode: transliterated
1883 preferred-script: Latn
1884"#;
1885 let config: Config = serde_yaml::from_str(yaml).unwrap();
1886 let ml = config.multilingual.clone().unwrap();
1887 assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1888 assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1889
1890 let yaml2 = serde_yaml::to_string(&config).unwrap();
1891 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1892 assert_eq!(config2.multilingual, config.multilingual);
1893 }
1894
1895 #[test]
1896 fn test_multilingual_pattern_block_roundtrips() {
1897 let yaml = r#"
1900multilingual:
1901 title-mode:
1902 pattern:
1903 - view: original-script
1904 - view: translated
1905 wrap: brackets
1906"#;
1907 let config: Config = serde_yaml::from_str(yaml).unwrap();
1908 let ml = config.multilingual.clone().unwrap();
1909 assert!(
1910 matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1911 "expected Pattern mode, got {:?}",
1912 ml.title_mode
1913 );
1914
1915 let yaml2 = serde_yaml::to_string(&config).unwrap();
1916 let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1917 assert_eq!(config2.multilingual, config.multilingual);
1918 }
1919
1920 #[rstest]
1926 #[case::scripts_overlay(
1927 "multilingual:\n scripts:\n Hang:\n use-native-ordering: true\n",
1928 "Hang"
1929 )]
1930 #[case::term_locale_overlay("multilingual:\n term-locale: item\n", "Hani")]
1931 fn given_partial_multilingual_overlay_when_merging_then_inherited_fields_survive(
1932 #[case] overlay_yaml: &str,
1933 #[case] expected_script_key: &str,
1934 ) {
1935 let base_yaml = "\
1937multilingual:
1938 punctuation-width: mixed
1939 preferred-script: Latn
1940 scripts:
1941 Hani:
1942 use-native-ordering: true
1943";
1944 let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1945
1946 let overlay: Config = serde_yaml::from_str(overlay_yaml).unwrap();
1948 base.merge(&overlay);
1949
1950 let merged = base.multilingual.expect("merged multilingual block");
1952 assert_eq!(
1953 merged.punctuation_width,
1954 Some(PunctuationWidth::Mixed),
1955 "inherited punctuation-width must survive a partial overlay"
1956 );
1957 assert_eq!(merged.preferred_script.as_deref(), Some("Latn"));
1958 assert!(
1959 merged.scripts.contains_key(expected_script_key),
1960 "expected scripts key {expected_script_key:?}, got {:?}",
1961 merged.scripts.keys().collect::<Vec<_>>()
1962 );
1963 }
1964
1965 #[test]
1967 fn given_multilingual_overlay_setting_a_field_when_merging_then_overlay_wins() {
1968 let mut base: Config =
1969 serde_yaml::from_str("multilingual:\n punctuation-width: mixed\n").unwrap();
1970 let overlay: Config =
1971 serde_yaml::from_str("multilingual:\n punctuation-width: bylan\n").unwrap();
1972
1973 base.merge(&overlay);
1974
1975 assert_eq!(
1976 base.multilingual.unwrap().punctuation_width,
1977 Some(PunctuationWidth::Bylan)
1978 );
1979 }
1980}