Skip to main content

citum_schema_style/options/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style configuration options.
7
8pub mod bibliography;
9pub mod cascade;
10pub mod contributors;
11pub mod dates;
12pub mod integral_name_memory;
13pub mod localization;
14pub mod locators;
15pub mod multilingual;
16pub mod processing;
17pub mod scoped;
18pub mod sorting;
19pub mod substitute;
20pub mod title_class;
21
22pub use crate::presets::{MultilingualConfigEntry, MultilingualPreset};
23pub use bibliography::{
24    AnonymousEntriesMode, ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback,
25    BibliographyConfig, BibliographyPartitionHeading, BibliographyPartitionKind,
26    BibliographyPartitionMode, BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
27};
28pub use cascade::ScopedRawOptions;
29pub use contributors::{
30    AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry,
31    ContributorSuppressionRule, DelimiterPrecedesLast, DemoteNonDroppingParticle, DisplayAsSort,
32    NameForm, RoleLabelDefaults, RoleLabelPresentation, RoleLabelPreset, RoleOptions,
33    RoleOptionsEntry, RoleRendering, ShortenListOptions,
34};
35pub use dates::{DateConfig, DateConfigEntry, DateRangeFormat, NoDateForm};
36pub use integral_name_memory::{
37    IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, OrgAbbreviationMemoryConfig,
38    ResolvedIntegralNameMemoryConfig, ResolvedOrgAbbreviationMemoryConfig, ShortNameDisplay,
39    SubsequentNameForm,
40};
41pub use localization::{Localize, MonthFormat, Scope};
42pub use locators::{
43    LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
44    LocatorPreset, TypeClass,
45};
46pub use multilingual::{
47    MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView,
48    PunctuationRealization, PunctuationStyle, PunctuationWidth, RealizationDefault, ScriptConfig,
49    SegmentWrap, TermLocale,
50};
51pub use processing::{
52    CitationSortPolicy, Disambiguation, GivennameRule, Group, LabelConfig, LabelParams,
53    LabelPreset, Processing, ProcessingBase, ProcessingCustom, RegimeFamily, Sort, SortEntry,
54    SortKey, SortSpec,
55};
56pub use scoped::{
57    BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, DatePosition, LabelWrap,
58    RepeatedAuthorRendering, TitleTerminator,
59};
60pub use sorting::{SortingConfig, SortingLocale, SortingMultilingualMode};
61pub use substitute::{
62    Substitute, SubstituteConfig, SubstituteContributor, SubstituteField, SubstituteKey,
63    SubstituteTitleQuoteMode,
64};
65
66use crate::template::DelimiterPunctuation;
67#[cfg(feature = "schema")]
68use schemars::JsonSchema;
69use serde::{Deserialize, Serialize};
70use std::collections::HashMap;
71
72/// Top-level style configuration.
73#[derive(Debug, Default, PartialEq, Clone, Serialize)]
74#[cfg_attr(feature = "schema", derive(JsonSchema))]
75#[serde(rename_all = "kebab-case")]
76pub struct Config {
77    /// Style-owned MF2 messages, inherited and merged by message ID.
78    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
79    pub messages: HashMap<String, String>,
80    /// Substitution rules for missing data. Accepts a preset name (e.g.
81    /// "standard") or explicit configuration.
82    #[serde(
83        skip_serializing_if = "Option::is_none",
84        deserialize_with = "deserialize_substitute_config",
85        default
86    )]
87    pub substitute: Option<SubstituteConfig>,
88    /// Processing mode (author-date, numeric, etc.).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub processing: Option<Processing>,
91    /// Style-level locale override ID loaded from `locales/overrides/<id>.*`.
92    ///
93    /// This patches the locale selected by `StyleInfo.default_locale` without
94    /// duplicating the full base locale. Runtime loading is limited to the
95    /// style-global config; nested citation or bibliography configs are ignored.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub locale_override: Option<String>,
98    /// Localization settings.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub localize: Option<Localize>,
101    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
102    /// `"romanized-only"`) or an explicit configuration block.
103    #[serde(
104        skip_serializing_if = "Option::is_none",
105        deserialize_with = "deserialize_multilingual_config",
106        default
107    )]
108    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
109    pub multilingual: Option<MultilingualConfig>,
110    /// Bibliography sorting policy.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub sorting: Option<SortingConfig>,
113    /// Contributor formatting defaults. Accepts a preset name (e.g., "apa")
114    /// or explicit configuration.
115    #[serde(
116        skip_serializing_if = "Option::is_none",
117        deserialize_with = "deserialize_contributor_config",
118        default
119    )]
120    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
121    pub contributors: Option<ContributorConfig>,
122    /// Date formatting defaults. Accepts a preset name (e.g., "long")
123    /// or explicit configuration.
124    #[serde(
125        skip_serializing_if = "Option::is_none",
126        deserialize_with = "deserialize_date_config",
127        default
128    )]
129    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
130    pub dates: Option<DateConfig>,
131    /// Title formatting defaults. Accepts a preset name (e.g., "apa")
132    /// or explicit configuration.
133    #[serde(
134        skip_serializing_if = "Option::is_none",
135        deserialize_with = "deserialize_titles_config",
136        default
137    )]
138    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
139    pub titles: Option<crate::options::titles::TitlesConfig>,
140    /// Locator rendering configuration. Accepts a preset name (e.g., "note")
141    /// or explicit configuration.
142    #[serde(
143        skip_serializing_if = "Option::is_none",
144        deserialize_with = "deserialize_locator_config",
145        default
146    )]
147    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
148    pub locators: Option<LocatorConfig>,
149    /// Page range formatting (expanded, minimal, chicago).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub page_range_format: Option<PageRangeFormat>,
152    /// Separator between page-range endpoints. Overrides the locale's
153    /// `page-range-delimiter` (en-dash by default); AMA and similar use `-`.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub page_range_delimiter: Option<String>,
156    /// Hyperlink configuration.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub links: Option<LinksConfig>,
159    /// Whether to place periods/commas inside quotation marks.
160    /// true = American style ("text."), false = British style ("text".)
161    /// Defaults to false; the style must opt in explicitly (embedded styles
162    /// that want American-style placement, including `en-US`-locale ones,
163    /// set this directly — the locale's own `punctuation-in-quote` grammar
164    /// option is not currently consulted by the engine).
165    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166    pub punctuation_in_quote: bool,
167    /// Locale-sensitive punctuation-collision overrides.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub punctuation: Option<PunctuationConfig>,
170    /// Delimiter between volume/issue and pages for serial sources.
171    /// Processor adds trailing space when rendering.
172    /// Examples: Comma (APA ", "), Colon (Chicago ": ").
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
175    /// Strip trailing periods from terms, labels, and abbreviated dates.
176    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
177    pub strip_periods: Option<bool>,
178    /// Document-level note marker placement and punctuation movement rules.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub notes: Option<NoteConfig>,
181    /// Integral citation name-memory behavior.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
184    /// Organizational name abbreviation expansion policy.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
187    /// Custom user-defined fields for extensions.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub custom: Option<HashMap<String, serde_json::Value>>,
190    /// Forward-compat: captures unknown keys when an older engine reads a
191    /// style produced by a newer schema. Empty by default; treated as a
192    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
193    #[serde(
194        flatten,
195        default,
196        skip_serializing_if = "std::collections::BTreeMap::is_empty"
197    )]
198    #[cfg_attr(feature = "schema", schemars(skip))]
199    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
200}
201
202/// Citation-local option overrides.
203#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
204#[cfg_attr(feature = "schema", derive(JsonSchema))]
205#[serde(rename_all = "kebab-case")]
206pub struct CitationOptions {
207    /// Substitution rules for missing data. Accepts a preset name (e.g.
208    /// "standard") or explicit configuration.
209    #[serde(
210        skip_serializing_if = "Option::is_none",
211        deserialize_with = "deserialize_substitute_config",
212        default
213    )]
214    pub substitute: Option<SubstituteConfig>,
215    /// Processing mode (author-date, numeric, etc.).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub processing: Option<Processing>,
218    /// Localization settings.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub localize: Option<Localize>,
221    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
222    /// `"romanized-only"`) or an explicit configuration block.
223    #[serde(
224        skip_serializing_if = "Option::is_none",
225        deserialize_with = "deserialize_multilingual_config",
226        default
227    )]
228    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
229    pub multilingual: Option<MultilingualConfig>,
230    /// Contributor formatting defaults.
231    #[serde(
232        skip_serializing_if = "Option::is_none",
233        deserialize_with = "deserialize_contributor_config",
234        default
235    )]
236    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
237    pub contributors: Option<ContributorConfig>,
238    /// Date formatting defaults.
239    #[serde(
240        skip_serializing_if = "Option::is_none",
241        deserialize_with = "deserialize_date_config",
242        default
243    )]
244    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
245    pub dates: Option<DateConfig>,
246    /// Title formatting defaults.
247    #[serde(
248        skip_serializing_if = "Option::is_none",
249        deserialize_with = "deserialize_titles_config",
250        default
251    )]
252    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
253    pub titles: Option<crate::options::titles::TitlesConfig>,
254    /// Locator rendering configuration.
255    #[serde(
256        skip_serializing_if = "Option::is_none",
257        deserialize_with = "deserialize_locator_config",
258        default
259    )]
260    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
261    pub locators: Option<LocatorConfig>,
262    /// Page range formatting (expanded, minimal, chicago).
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub page_range_format: Option<PageRangeFormat>,
265    /// Hyperlink configuration.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub links: Option<LinksConfig>,
268    /// Whether to place periods/commas inside quotation marks.
269    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
270    pub punctuation_in_quote: bool,
271    /// Delimiter between volume/issue and pages for serial sources.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
274    /// Strip trailing periods from terms, labels, and abbreviated dates.
275    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
276    pub strip_periods: Option<bool>,
277    /// Document-level note marker placement and punctuation movement rules.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub notes: Option<NoteConfig>,
280    /// Integral citation name-memory behavior.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
283    /// Organizational name abbreviation expansion policy.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
286    /// Label wrap policy applied to citation labels.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub label_wrap: Option<LabelWrap>,
289    /// Delimiter between grouped citation items.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub group_delimiter: Option<CitationGroupDelimiter>,
292    /// Custom user-defined fields for extensions.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub custom: Option<HashMap<String, serde_json::Value>>,
295    /// Forward-compat: captures unknown keys when an older engine reads a
296    /// style produced by a newer schema. Empty by default; treated as a
297    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
298    #[serde(
299        flatten,
300        default,
301        skip_serializing_if = "std::collections::BTreeMap::is_empty"
302    )]
303    #[cfg_attr(feature = "schema", schemars(skip))]
304    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
305}
306
307/// Bibliography-local option overrides.
308#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "schema", derive(JsonSchema))]
310#[serde(rename_all = "kebab-case")]
311pub struct BibliographyOptions {
312    /// Substitution rules for missing data. Accepts a preset name (e.g.
313    /// "standard") or explicit configuration.
314    #[serde(
315        skip_serializing_if = "Option::is_none",
316        deserialize_with = "deserialize_substitute_config",
317        default
318    )]
319    pub substitute: Option<SubstituteConfig>,
320    /// Processing mode (author-date, numeric, etc.).
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub processing: Option<Processing>,
323    /// Localization settings.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub localize: Option<Localize>,
326    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
327    /// `"romanized-only"`) or an explicit configuration block.
328    #[serde(
329        skip_serializing_if = "Option::is_none",
330        deserialize_with = "deserialize_multilingual_config",
331        default
332    )]
333    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
334    pub multilingual: Option<MultilingualConfig>,
335    /// Bibliography sorting policy.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub sorting: Option<SortingConfig>,
338    /// Contributor formatting defaults.
339    #[serde(
340        skip_serializing_if = "Option::is_none",
341        deserialize_with = "deserialize_contributor_config",
342        default
343    )]
344    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
345    pub contributors: Option<ContributorConfig>,
346    /// Date formatting defaults.
347    #[serde(
348        skip_serializing_if = "Option::is_none",
349        deserialize_with = "deserialize_date_config",
350        default
351    )]
352    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
353    pub dates: Option<DateConfig>,
354    /// Title formatting defaults.
355    #[serde(
356        skip_serializing_if = "Option::is_none",
357        deserialize_with = "deserialize_titles_config",
358        default
359    )]
360    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
361    pub titles: Option<crate::options::titles::TitlesConfig>,
362    /// Page range formatting (expanded, minimal, chicago).
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub page_range_format: Option<PageRangeFormat>,
365    /// Article-journal-specific bibliography policies.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub article_journal: Option<ArticleJournalBibliographyConfig>,
368    /// String to substitute for repeating authors.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub subsequent_author_substitute: Option<String>,
371    /// Rule for when to apply the substitute.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
374    /// Whether to use a hanging indent.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub hanging_indent: Option<bool>,
377    /// Suffix appended to each bibliography entry. Accepts a semantic mark
378    /// (`{ mark: period }`) or a literal string.
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub entry_suffix: Option<DelimiterPunctuation>,
381    /// Separator between bibliography components. Accepts a semantic mark
382    /// (`{ mark: period }`) or a literal string.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub separator: Option<DelimiterPunctuation>,
385    /// Whether to suppress the trailing period after URLs/DOIs.
386    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
387    pub suppress_period_after_url: bool,
388    /// Force `entry-suffix` even when the entry ends in a URL (MLA).
389    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390    pub entry_suffix_after_url: bool,
391    /// Force `entry-suffix` even when the entry ends in a DOI (IEEE).
392    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
393    pub entry_suffix_after_doi: bool,
394    /// Configuration for compound numeric bibliography entries.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
397    /// Partitioning policy for multilingual bibliography sorting and sections.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
400    /// Policy for reference-work entries (dictionary/encyclopedia and
401    /// dictionary-shaped chapters) with no visible author. See
402    /// [`AnonymousEntriesMode`].
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub anonymous_entries: Option<AnonymousEntriesMode>,
405    /// Hyperlink configuration.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub links: Option<LinksConfig>,
408    /// Whether to place periods/commas inside quotation marks.
409    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
410    pub punctuation_in_quote: bool,
411    /// Delimiter between volume/issue and pages for serial sources.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
414    /// Bibliography label mode for label-bearing styles.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub label_mode: Option<BibliographyLabelMode>,
417    /// Label wrap policy applied to bibliography labels.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub label_wrap: Option<BibliographyLabelWrap>,
420    /// Placement of issued dates within bibliography entries.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub date_position: Option<DatePosition>,
423    /// Terminator applied to primary-title bibliography components.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub title_terminator: Option<TitleTerminator>,
426    /// Repeated-author rendering mode for bibliography entries.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
429    /// Strip trailing periods from terms, labels, and abbreviated dates.
430    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
431    pub strip_periods: Option<bool>,
432    /// Custom user-defined fields for extensions.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub custom: Option<HashMap<String, serde_json::Value>>,
435    /// Forward-compat: captures unknown keys when an older engine reads a
436    /// style produced by a newer schema. Empty by default; treated as a
437    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
438    #[serde(
439        flatten,
440        default,
441        skip_serializing_if = "std::collections::BTreeMap::is_empty"
442    )]
443    #[cfg_attr(feature = "schema", schemars(skip))]
444    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
445}
446
447/// Document-level note marker placement rules.
448#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
449#[cfg_attr(feature = "schema", derive(JsonSchema))]
450#[serde(rename_all = "kebab-case")]
451pub struct NoteConfig {
452    /// Desired location of movable punctuation relative to closing quotation
453    /// marks when note markers are introduced.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub punctuation: Option<NoteQuotePlacement>,
456    /// Desired location of the note marker relative to closing quotation marks.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub number: Option<NoteNumberPlacement>,
459    /// Whether the note marker appears before or after the closest movable
460    /// punctuation mark.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub order: Option<NoteMarkerOrder>,
463    /// Forward-compat: captures unknown keys when an older engine reads a
464    /// style produced by a newer schema. Empty by default; treated as a
465    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
466    #[serde(
467        flatten,
468        default,
469        skip_serializing_if = "std::collections::BTreeMap::is_empty"
470    )]
471    #[cfg_attr(feature = "schema", schemars(skip))]
472    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
473}
474
475/// Style-level overrides for locale punctuation-collision defaults.
476#[derive(Debug, Default, PartialEq, Eq, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "schema", derive(JsonSchema))]
478#[serde(rename_all = "kebab-case")]
479pub struct PunctuationConfig {
480    /// Policy for a strong terminal mark followed by a style-supplied comma.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub strong_terminal_comma_policy: Option<StrongTerminalCommaPolicy>,
483    /// Terminal marks that suppress a following delimiter's punctuation core.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub delimiter_suppressing_terminal_marks: Option<String>,
486}
487
488impl PunctuationConfig {
489    /// Merge `other` over this configuration field by field.
490    fn merge(&mut self, other: &Self) {
491        if let Some(policy) = other.strong_terminal_comma_policy {
492            self.strong_terminal_comma_policy = Some(policy);
493        }
494        if let Some(marks) = &other.delimiter_suppressing_terminal_marks {
495            self.delimiter_suppressing_terminal_marks = Some(marks.clone());
496        }
497    }
498}
499
500/// Controls how a strong terminal mark collides with a following comma.
501#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
502#[cfg_attr(feature = "schema", derive(JsonSchema))]
503#[serde(rename_all = "kebab-case")]
504pub enum StrongTerminalCommaPolicy {
505    /// Preserve both the terminal mark and the following comma.
506    #[default]
507    KeepBoth,
508    /// Preserve the terminal mark and suppress the following comma.
509    KeepTerminal,
510}
511
512/// Controls where movable punctuation is placed relative to closing quotation marks.
513#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
514#[cfg_attr(feature = "schema", derive(JsonSchema))]
515#[serde(rename_all = "kebab-case")]
516pub enum NoteQuotePlacement {
517    /// Keep movable punctuation inside the closing quotation mark.
518    Inside,
519    /// Keep movable punctuation outside the closing quotation mark.
520    Outside,
521    /// Follow org-cite-style adaptive behavior: punctuation stays inside when
522    /// it is already flush with the closing quote, otherwise it is placed
523    /// outside.
524    #[default]
525    Adaptive,
526}
527
528/// Controls where a footnote number marker is placed relative to closing quotation marks.
529#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
530#[cfg_attr(feature = "schema", derive(JsonSchema))]
531#[serde(rename_all = "kebab-case")]
532pub enum NoteNumberPlacement {
533    /// Place the note marker inside the closing quotation mark.
534    Inside,
535    /// Place the note marker outside the closing quotation mark.
536    #[default]
537    Outside,
538    /// Place the note marker on the same side as the movable punctuation when
539    /// only one side has punctuation; otherwise default to outside.
540    Same,
541}
542
543/// Controls whether a note marker appears before or after adjacent movable punctuation.
544#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
545#[cfg_attr(feature = "schema", derive(JsonSchema))]
546#[serde(rename_all = "kebab-case")]
547pub enum NoteMarkerOrder {
548    /// Place the note marker before the closest movable punctuation mark.
549    Before,
550    /// Place the note marker after the closest movable punctuation mark.
551    #[default]
552    After,
553}
554
555/// Page range formatting options.
556#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
557#[cfg_attr(feature = "schema", derive(JsonSchema))]
558#[serde(rename_all = "kebab-case")]
559#[non_exhaustive]
560pub enum PageRangeFormat {
561    /// Full expansion: 321-328 → 321–328
562    #[default]
563    Expanded,
564    /// Minimal digits: 321-328 → 321–8
565    Minimal,
566    /// Minimal two digits: 321-328 → 321–28
567    MinimalTwo,
568    /// Chicago Manual of Style 15th ed rules
569    Chicago,
570    /// Chicago Manual of Style 16th/17th ed rules
571    Chicago16,
572}
573
574pub mod titles;
575
576pub use title_class::{
577    TitleCategory, classified_ref_types, container_title_category, parent_serial_title_category,
578    title_category,
579};
580pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
581
582/// Structured link options.
583#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
584#[cfg_attr(feature = "schema", derive(JsonSchema))]
585#[serde(rename_all = "kebab-case")]
586pub struct LinksConfig {
587    /// Link value to the item's DOI.
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub doi: Option<bool>,
590    /// Link value to the item's URL.
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub url: Option<bool>,
593    /// The target for the link (url, doi, etc.).
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub target: Option<LinkTarget>,
596    /// What text should be hyperlinked (title, url, etc.).
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub anchor: Option<LinkAnchor>,
599    /// Omit the URL scheme (e.g. `http://`, `https://`) when rendering a link.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub strip_protocol: Option<bool>,
602}
603
604/// Link target options.
605#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "schema", derive(JsonSchema))]
607#[serde(rename_all = "kebab-case")]
608pub enum LinkTarget {
609    Url,
610    Doi,
611    UrlOrDoi,
612    Pubmed,
613    Pmcid,
614}
615
616/// Link anchor options.
617#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "schema", derive(JsonSchema))]
619#[serde(rename_all = "kebab-case")]
620pub enum LinkAnchor {
621    /// Link the title component.
622    Title,
623    /// Link the URL component itself.
624    Url,
625    /// Link the DOI component itself.
626    Doi,
627    /// Link the specific component this config is attached to.
628    Component,
629    /// Link the entire bibliography entry.
630    Entry,
631}
632
633impl Config {
634    /// Merge `other`'s multilingual block field-wise rather than replacing it.
635    ///
636    /// Replacing the whole block means a style that extends a parent and sets
637    /// any single multilingual key silently discards every inherited one — see
638    /// [`MultilingualConfig::merge`] and bean `csl26-p7kj`.
639    fn merge_multilingual(&mut self, other: &Config) {
640        let Some(other_multilingual) = &other.multilingual else {
641            return;
642        };
643        if let Some(multilingual) = &mut self.multilingual {
644            multilingual.merge(other_multilingual);
645        } else {
646            self.multilingual = Some(other_multilingual.clone());
647        }
648    }
649
650    fn merge_punctuation(&mut self, other: &Config) {
651        let Some(other_punctuation) = &other.punctuation else {
652            return;
653        };
654        if let Some(punctuation) = &mut self.punctuation {
655            punctuation.merge(other_punctuation);
656        } else {
657            self.punctuation = Some(other_punctuation.clone());
658        }
659    }
660
661    /// Effective processing mode, falling back to the default when unset.
662    ///
663    /// Centralizes the `processing: None` fallback so every consumer resolves
664    /// the same default (`Processing::default()`) instead of hardcoding it.
665    pub fn effective_processing(&self) -> Processing {
666        self.processing.clone().unwrap_or_default()
667    }
668
669    /// Merge another config into this one, with `other` taking precedence.
670    ///
671    /// Used for combining global options with context-specific (citation/bibliography) options.
672    /// Only non-None fields from `other` override fields in `self`.
673    pub fn merge(&mut self, other: &Config) {
674        crate::merge_options!(
675            self,
676            other,
677            processing,
678            locale_override,
679            localize,
680            dates,
681            titles,
682            locators,
683            page_range_format,
684            page_range_delimiter,
685            links,
686            volume_pages_delimiter,
687            locale_override,
688            strip_periods,
689            notes,
690            integral_name_memory,
691            org_abbreviation_memory,
692            custom,
693        );
694
695        self.merge_multilingual(other);
696        self.merge_punctuation(other);
697        self.messages.extend(other.messages.clone());
698
699        if let Some(other_sorting) = &other.sorting {
700            if let Some(this_sorting) = &mut self.sorting {
701                this_sorting.merge(other_sorting);
702            } else {
703                self.sorting = Some(other_sorting.clone());
704            }
705        }
706
707        if let Some(other_substitute) = &other.substitute {
708            if let Some(this_substitute) = &self.substitute {
709                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
710            } else {
711                self.substitute = Some(other_substitute.clone());
712            }
713        }
714
715        if let Some(other_contributors) = &other.contributors {
716            if let Some(this_contributors) = &mut self.contributors {
717                this_contributors.merge(other_contributors);
718            } else {
719                self.contributors = Some(other_contributors.clone());
720            }
721        }
722
723        if other.punctuation_in_quote {
724            self.punctuation_in_quote = true;
725        }
726    }
727
728    /// Create a merged config from base and override, returning a new Config.
729    ///
730    /// Convenience method that clones base, then merges override into it.
731    pub fn merged(base: &Config, override_config: &Config) -> Config {
732        let mut result = base.clone();
733        result.merge(override_config);
734        result
735    }
736}
737
738impl CitationOptions {
739    /// Convert citation-local overrides into the runtime config shape.
740    #[must_use]
741    pub fn to_config(&self) -> Config {
742        Config {
743            messages: HashMap::new(),
744            substitute: self.substitute.clone(),
745            processing: self.processing.clone(),
746            locale_override: None,
747            localize: self.localize.clone(),
748            multilingual: self.multilingual.clone(),
749            sorting: None,
750            contributors: self.contributors.clone(),
751            dates: self.dates.clone(),
752            titles: self.titles.clone(),
753            locators: self.locators.clone(),
754            page_range_format: self.page_range_format.clone(),
755            page_range_delimiter: None,
756            links: self.links.clone(),
757            punctuation_in_quote: self.punctuation_in_quote,
758            punctuation: None,
759            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
760            strip_periods: self.strip_periods,
761            notes: self.notes.clone(),
762            integral_name_memory: self.integral_name_memory.clone(),
763            org_abbreviation_memory: self.org_abbreviation_memory.clone(),
764            custom: self.custom.clone(),
765            unknown_fields: std::collections::BTreeMap::new(),
766        }
767    }
768
769    /// Merge citation-local overrides over a base config.
770    #[must_use]
771    pub fn merged_with(&self, base: &Config) -> Config {
772        Config::merged(base, &self.to_config())
773    }
774
775    /// Merge citation-local overrides over a base config, merging nested
776    /// option blocks field-by-field when a trustworthy authored scope mapping
777    /// is available (see [`cascade::ScopedRawOptions`]).
778    ///
779    /// `raw_options` is the chain-merged authored `citation.options` mapping
780    /// carried on the resolved [`crate::Style`]. When it is absent or no
781    /// longer round-trips to `self` (post-parse mutation), this behaves
782    /// exactly like [`CitationOptions::merged_with`].
783    #[must_use]
784    pub fn merged_with_raw(
785        &self,
786        base: &Config,
787        raw_options: Option<&serde_yaml::Value>,
788    ) -> Config {
789        let mut merged = self.merged_with(base);
790        if let Some(raw) = raw_options
791            && cascade::authored_matches(raw, self)
792        {
793            cascade::merge_citation_config_blocks_from_raw(&mut merged, base, raw);
794        }
795        merged
796    }
797
798    /// Merge `other` into `self`, with `other` taking precedence for each field.
799    pub fn merge(&mut self, other: &CitationOptions) {
800        crate::merge_options!(
801            self,
802            other,
803            processing,
804            localize,
805            multilingual,
806            dates,
807            titles,
808            locators,
809            page_range_format,
810            links,
811            volume_pages_delimiter,
812            strip_periods,
813            notes,
814            integral_name_memory,
815            org_abbreviation_memory,
816            label_wrap,
817            group_delimiter,
818            custom,
819        );
820
821        if let Some(other_substitute) = &other.substitute {
822            if let Some(this_substitute) = &self.substitute {
823                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
824            } else {
825                self.substitute = Some(other_substitute.clone());
826            }
827        }
828
829        if let Some(other_contributors) = &other.contributors {
830            if let Some(this_contributors) = &mut self.contributors {
831                this_contributors.merge(other_contributors);
832            } else {
833                self.contributors = Some(other_contributors.clone());
834            }
835        }
836
837        if other.punctuation_in_quote {
838            self.punctuation_in_quote = true;
839        }
840    }
841}
842
843impl BibliographyOptions {
844    /// Convert bibliography-entry overrides into bibliography-only runtime config.
845    #[must_use]
846    pub fn to_bibliography_config(&self) -> BibliographyConfig {
847        BibliographyConfig {
848            article_journal: self.article_journal.clone(),
849            subsequent_author_substitute: self.subsequent_author_substitute.clone(),
850            subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
851            hanging_indent: self.hanging_indent,
852            entry_suffix: self.entry_suffix.clone(),
853            separator: self.separator.clone(),
854            suppress_period_after_url: self.suppress_period_after_url,
855            entry_suffix_after_url: self.entry_suffix_after_url,
856            entry_suffix_after_doi: self.entry_suffix_after_doi,
857            custom: None,
858            compound_numeric: self.compound_numeric.clone(),
859            sort_partitioning: self.sort_partitioning.clone(),
860            anonymous_entries: self.anonymous_entries,
861            unknown_fields: std::collections::BTreeMap::new(),
862        }
863    }
864
865    /// Convert bibliography-local overrides into the runtime config shape.
866    #[must_use]
867    pub fn to_config(&self) -> Config {
868        Config {
869            messages: HashMap::new(),
870            substitute: self.substitute.clone(),
871            processing: self.processing.clone(),
872            locale_override: None,
873            localize: self.localize.clone(),
874            multilingual: self.multilingual.clone(),
875            sorting: self.sorting.clone(),
876            contributors: self.contributors.clone(),
877            dates: self.dates.clone(),
878            titles: self.titles.clone(),
879            locators: None,
880            page_range_format: self.page_range_format.clone(),
881            page_range_delimiter: None,
882            links: self.links.clone(),
883            punctuation_in_quote: self.punctuation_in_quote,
884            punctuation: None,
885            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
886            strip_periods: self.strip_periods,
887            notes: None,
888            integral_name_memory: None,
889            org_abbreviation_memory: None,
890            custom: self.custom.clone(),
891            unknown_fields: std::collections::BTreeMap::new(),
892        }
893    }
894
895    /// Merge bibliography-local overrides over a base config.
896    #[must_use]
897    pub fn merged_with(&self, base: &Config) -> Config {
898        Config::merged(base, &self.to_config())
899    }
900
901    /// Merge bibliography-local overrides over a base config, merging nested
902    /// option blocks field-by-field when a trustworthy authored scope mapping
903    /// is available (see [`cascade::ScopedRawOptions`]).
904    ///
905    /// `raw_options` is the chain-merged authored `bibliography.options`
906    /// mapping carried on the resolved [`crate::Style`]. When it is absent or
907    /// no longer round-trips to `self` (post-parse mutation), this behaves
908    /// exactly like [`BibliographyOptions::merged_with`].
909    #[must_use]
910    pub fn merged_with_raw(
911        &self,
912        base: &Config,
913        raw_options: Option<&serde_yaml::Value>,
914    ) -> Config {
915        let mut merged = self.merged_with(base);
916        if let Some(raw) = raw_options
917            && cascade::authored_matches(raw, self)
918        {
919            cascade::merge_bibliography_config_blocks_from_raw(&mut merged, base, raw);
920        }
921        merged
922    }
923
924    /// Merge `other` into `self`, with `other` taking precedence for each field.
925    pub fn merge(&mut self, other: &BibliographyOptions) {
926        crate::merge_options!(
927            self,
928            other,
929            processing,
930            localize,
931            multilingual,
932            dates,
933            titles,
934            page_range_format,
935            links,
936            volume_pages_delimiter,
937            strip_periods,
938            article_journal,
939            subsequent_author_substitute,
940            subsequent_author_substitute_rule,
941            hanging_indent,
942            entry_suffix,
943            separator,
944            compound_numeric,
945            sort_partitioning,
946            anonymous_entries,
947            label_mode,
948            label_wrap,
949            date_position,
950            title_terminator,
951            repeated_author_rendering,
952            custom,
953        );
954
955        self.merge_shared_fields(other);
956    }
957
958    fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
959        if let Some(other_sorting) = &other.sorting {
960            if let Some(this_sorting) = &mut self.sorting {
961                this_sorting.merge(other_sorting);
962            } else {
963                self.sorting = Some(other_sorting.clone());
964            }
965        }
966
967        if let Some(other_substitute) = &other.substitute {
968            if let Some(this_substitute) = &self.substitute {
969                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
970            } else {
971                self.substitute = Some(other_substitute.clone());
972            }
973        }
974
975        if let Some(other_contributors) = &other.contributors {
976            if let Some(this_contributors) = &mut self.contributors {
977                this_contributors.merge(other_contributors);
978            } else {
979                self.contributors = Some(other_contributors.clone());
980            }
981        }
982
983        if other.punctuation_in_quote {
984            self.punctuation_in_quote = true;
985        }
986        if other.suppress_period_after_url {
987            self.suppress_period_after_url = true;
988        }
989        if other.entry_suffix_after_url {
990            self.entry_suffix_after_url = true;
991        }
992        if other.entry_suffix_after_doi {
993            self.entry_suffix_after_doi = true;
994        }
995
996        for (key, value) in &other.unknown_fields {
997            self.unknown_fields.insert(key.clone(), value.clone());
998        }
999    }
1000}
1001
1002/// Deserialize contributor config from either a preset name or explicit config.
1003fn deserialize_contributor_config<'de, D>(
1004    deserializer: D,
1005) -> Result<Option<ContributorConfig>, D::Error>
1006where
1007    D: serde::Deserializer<'de>,
1008{
1009    let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
1010    Ok(value.map(|entry| entry.resolve()))
1011}
1012
1013/// Deserialize date config from either a preset name or explicit config.
1014fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
1015where
1016    D: serde::Deserializer<'de>,
1017{
1018    let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
1019    Ok(value.map(|entry| entry.resolve()))
1020}
1021
1022/// Deserialize titles config from either a preset name or explicit config.
1023fn deserialize_titles_config<'de, D>(
1024    deserializer: D,
1025) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
1026where
1027    D: serde::Deserializer<'de>,
1028{
1029    let value: Option<crate::options::titles::TitlesConfigEntry> =
1030        Option::deserialize(deserializer)?;
1031    Ok(value.map(|entry| entry.resolve()))
1032}
1033
1034/// Deserialize locator config from either a preset name or explicit config.
1035fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
1036where
1037    D: serde::Deserializer<'de>,
1038{
1039    let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
1040    Ok(value.map(|entry| entry.resolve()))
1041}
1042
1043/// Deserialize substitute config from either a preset name or explicit config.
1044///
1045/// Eagerly resolves a `Preset` variant to its `Explicit` form (mirroring
1046/// `deserialize_contributor_config`/`deserialize_date_config`/etc.), so the
1047/// typed value always serializes as a mapping. Without this, the `extends`
1048/// overlay's raw-YAML deep merge (`style/overlay.rs`) sees a preset-name
1049/// scalar for an authored `substitute: <preset>` override and whole-replaces
1050/// the inherited `Substitute` block instead of field-merging it, silently
1051/// dropping fields the preset doesn't set (e.g. `role-substitute`).
1052fn deserialize_substitute_config<'de, D>(
1053    deserializer: D,
1054) -> Result<Option<SubstituteConfig>, D::Error>
1055where
1056    D: serde::Deserializer<'de>,
1057{
1058    let value: Option<SubstituteConfig> = Option::deserialize(deserializer)?;
1059    Ok(value.map(|config| match config {
1060        SubstituteConfig::Preset(preset) => SubstituteConfig::Explicit(preset.config()),
1061        explicit @ SubstituteConfig::Explicit(_) => explicit,
1062    }))
1063}
1064
1065/// Deserialize multilingual config from either a preset name or an explicit block.
1066fn deserialize_multilingual_config<'de, D>(
1067    deserializer: D,
1068) -> Result<Option<MultilingualConfig>, D::Error>
1069where
1070    D: serde::Deserializer<'de>,
1071{
1072    let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
1073    Ok(value.map(|entry| entry.resolve()))
1074}
1075
1076impl<'de> Deserialize<'de> for Config {
1077    #[allow(
1078        clippy::too_many_lines,
1079        reason = "the local wire type intentionally mirrors the complete public config"
1080    )]
1081    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1082    where
1083        D: serde::Deserializer<'de>,
1084    {
1085        #[derive(Deserialize)]
1086        #[serde(rename_all = "kebab-case")]
1087        struct ConfigWire {
1088            #[serde(default)]
1089            messages: HashMap<String, String>,
1090            #[serde(
1091                skip_serializing_if = "Option::is_none",
1092                deserialize_with = "deserialize_substitute_config",
1093                default
1094            )]
1095            substitute: Option<SubstituteConfig>,
1096            #[serde(skip_serializing_if = "Option::is_none")]
1097            processing: Option<Processing>,
1098            #[serde(skip_serializing_if = "Option::is_none")]
1099            locale_override: Option<String>,
1100            #[serde(skip_serializing_if = "Option::is_none")]
1101            localize: Option<Localize>,
1102            #[serde(
1103                skip_serializing_if = "Option::is_none",
1104                deserialize_with = "deserialize_multilingual_config",
1105                default
1106            )]
1107            multilingual: Option<MultilingualConfig>,
1108            #[serde(skip_serializing_if = "Option::is_none")]
1109            sorting: Option<SortingConfig>,
1110            #[serde(
1111                skip_serializing_if = "Option::is_none",
1112                deserialize_with = "deserialize_contributor_config",
1113                default
1114            )]
1115            contributors: Option<ContributorConfig>,
1116            #[serde(
1117                skip_serializing_if = "Option::is_none",
1118                deserialize_with = "deserialize_date_config",
1119                default
1120            )]
1121            dates: Option<DateConfig>,
1122            #[serde(
1123                skip_serializing_if = "Option::is_none",
1124                deserialize_with = "deserialize_titles_config",
1125                default
1126            )]
1127            titles: Option<crate::options::titles::TitlesConfig>,
1128            #[serde(
1129                skip_serializing_if = "Option::is_none",
1130                deserialize_with = "deserialize_locator_config",
1131                default
1132            )]
1133            locators: Option<LocatorConfig>,
1134            #[serde(skip_serializing_if = "Option::is_none")]
1135            page_range_format: Option<PageRangeFormat>,
1136            #[serde(skip_serializing_if = "Option::is_none")]
1137            page_range_delimiter: Option<String>,
1138            #[serde(skip_serializing_if = "Option::is_none")]
1139            links: Option<LinksConfig>,
1140            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1141            punctuation_in_quote: bool,
1142            #[serde(skip_serializing_if = "Option::is_none")]
1143            punctuation: Option<PunctuationConfig>,
1144            #[serde(skip_serializing_if = "Option::is_none")]
1145            volume_pages_delimiter: Option<DelimiterPunctuation>,
1146            #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
1147            strip_periods: Option<bool>,
1148            #[serde(skip_serializing_if = "Option::is_none")]
1149            notes: Option<NoteConfig>,
1150            #[serde(skip_serializing_if = "Option::is_none")]
1151            integral_name_memory: Option<IntegralNameMemoryConfig>,
1152            #[serde(skip_serializing_if = "Option::is_none")]
1153            org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
1154            #[serde(default)]
1155            profile: Option<serde_yaml::Value>,
1156            #[serde(skip_serializing_if = "Option::is_none")]
1157            custom: Option<HashMap<String, serde_json::Value>>,
1158            #[serde(flatten)]
1159            unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
1160        }
1161
1162        let wire = ConfigWire::deserialize(deserializer)?;
1163        if wire.profile.is_some() {
1164            return Err(serde::de::Error::custom(
1165                "`options.profile` was removed; use `options.contributors`, `citation.options.label-wrap`, `citation.options.group-delimiter`, `bibliography.options.label-mode`, `bibliography.options.label-wrap`, `bibliography.options.date-position`, `bibliography.options.title-terminator`, `bibliography.options.repeated-author-rendering`, or `bibliography.options.volume-pages-delimiter`",
1166            ));
1167        }
1168
1169        Ok(Self {
1170            messages: wire.messages,
1171            substitute: wire.substitute,
1172            processing: wire.processing,
1173            locale_override: wire.locale_override,
1174            localize: wire.localize,
1175            multilingual: wire.multilingual,
1176            sorting: wire.sorting,
1177            contributors: wire.contributors,
1178            dates: wire.dates,
1179            titles: wire.titles,
1180            locators: wire.locators,
1181            page_range_format: wire.page_range_format,
1182            page_range_delimiter: wire.page_range_delimiter,
1183            links: wire.links,
1184            punctuation_in_quote: wire.punctuation_in_quote,
1185            punctuation: wire.punctuation,
1186            volume_pages_delimiter: wire.volume_pages_delimiter,
1187            strip_periods: wire.strip_periods,
1188            notes: wire.notes,
1189            integral_name_memory: wire.integral_name_memory,
1190            org_abbreviation_memory: wire.org_abbreviation_memory,
1191            custom: wire.custom,
1192            unknown_fields: wire.unknown_fields,
1193        })
1194    }
1195}
1196
1197#[cfg(test)]
1198#[allow(
1199    clippy::unwrap_used,
1200    clippy::expect_used,
1201    clippy::panic,
1202    clippy::indexing_slicing,
1203    clippy::todo,
1204    clippy::unimplemented,
1205    clippy::unreachable,
1206    clippy::get_unwrap,
1207    reason = "Panicking is acceptable and often desired in tests."
1208)]
1209mod tests {
1210    use super::*;
1211    use rstest::rstest;
1212
1213    #[test]
1214    fn test_config_default() {
1215        let config = Config::default();
1216        assert!(config.substitute.is_none());
1217        assert!(config.processing.is_none());
1218    }
1219
1220    #[test]
1221    fn test_author_date_processing() {
1222        let processing = Processing::AuthorDate;
1223        let config = processing.config();
1224        let disambiguate = config.disambiguate.unwrap();
1225        assert!(disambiguate.year_suffix);
1226        assert!(!disambiguate.names);
1227        assert!(!disambiguate.add_givenname);
1228        assert_eq!(
1229            processing.default_bibliography_sort(),
1230            Some(crate::presets::SortPreset::AuthorDateTitle)
1231        );
1232        assert_eq!(
1233            config.sort,
1234            Some(SortEntry::Preset(
1235                crate::presets::SortPreset::AuthorDateTitle
1236            ))
1237        );
1238    }
1239
1240    #[test]
1241    fn test_processing_default_bibliography_sorts() {
1242        assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
1243        assert_eq!(
1244            Processing::Note.default_bibliography_sort(),
1245            Some(crate::presets::SortPreset::AuthorTitleDate)
1246        );
1247        assert_eq!(
1248            Processing::Label(LabelConfig::default()).default_bibliography_sort(),
1249            Some(crate::presets::SortPreset::AuthorDateTitle)
1250        );
1251    }
1252
1253    #[test]
1254    fn test_processing_default_citation_sort_policy_is_explicit_only() {
1255        assert_eq!(
1256            Processing::AuthorDate.default_citation_sort_policy(),
1257            CitationSortPolicy::ExplicitOnly
1258        );
1259        assert_eq!(
1260            Processing::Note.default_citation_sort_policy(),
1261            CitationSortPolicy::ExplicitOnly
1262        );
1263    }
1264
1265    #[test]
1266    fn test_substitute_default() {
1267        let sub = Substitute::default();
1268        assert_eq!(sub.template.len(), 3);
1269    }
1270
1271    #[test]
1272    fn test_config_yaml_roundtrip() {
1273        let yaml = r#"
1274substitute:
1275  contributor-role-form: short
1276  template:
1277    - editor
1278    - title
1279processing: author-date
1280contributors:
1281  display-as-sort: first
1282  and: symbol
1283"#;
1284        let config: Config = serde_yaml::from_str(yaml).unwrap();
1285        assert!(config.substitute.is_some());
1286        assert_eq!(config.processing, Some(Processing::AuthorDate));
1287        assert_eq!(
1288            config.contributors.as_ref().unwrap().and,
1289            Some(AndOptions::Symbol)
1290        );
1291    }
1292
1293    #[test]
1294    fn test_sorting_config_deserializes_and_roundtrips() {
1295        let yaml = r#"
1296sorting:
1297  locale: sv-SE
1298  multilingual: romanized
1299"#;
1300        let config: Config = serde_yaml::from_str(yaml).unwrap();
1301        let sorting = config.sorting.as_ref().expect("sorting should parse");
1302
1303        assert_eq!(
1304            sorting.locale,
1305            Some(SortingLocale::Bcp47("sv-SE".to_string()))
1306        );
1307        assert_eq!(
1308            sorting.multilingual,
1309            Some(SortingMultilingualMode::Romanized)
1310        );
1311
1312        let serialized = serde_yaml::to_string(&config).unwrap();
1313        let reparsed: Config = serde_yaml::from_str(&serialized).unwrap();
1314        assert_eq!(reparsed.sorting, config.sorting);
1315    }
1316
1317    #[test]
1318    fn test_sorting_config_defaults_and_unknown_fields() {
1319        let yaml = r#"
1320sorting:
1321  future-key: true
1322"#;
1323        let config: Config = serde_yaml::from_str(yaml).unwrap();
1324        let sorting = config.sorting.as_ref().expect("sorting should parse");
1325
1326        assert_eq!(sorting.effective_locale(), SortingLocale::Auto);
1327        assert_eq!(
1328            sorting.effective_multilingual(),
1329            SortingMultilingualMode::Uniform
1330        );
1331        assert!(sorting.unknown_fields.contains_key("future-key"));
1332    }
1333
1334    #[test]
1335    fn test_bibliography_sorting_override_merges_partially() {
1336        let base: Config = serde_yaml::from_str(
1337            r#"
1338sorting:
1339  locale: de-DE
1340  multilingual: uniform
1341"#,
1342        )
1343        .unwrap();
1344        let bib: BibliographyOptions = serde_yaml::from_str(
1345            r#"
1346sorting:
1347  multilingual: romanized
1348"#,
1349        )
1350        .unwrap();
1351
1352        let merged = bib.merged_with(&base);
1353        let sorting = merged.sorting.expect("merged sorting should exist");
1354        assert_eq!(
1355            sorting.locale,
1356            Some(SortingLocale::Bcp47("de-DE".to_string()))
1357        );
1358        assert_eq!(
1359            sorting.multilingual,
1360            Some(SortingMultilingualMode::Romanized)
1361        );
1362    }
1363
1364    #[test]
1365    fn test_contributor_config_preset() {
1366        // Test that a preset name parses and resolves correctly for contributors
1367        let yaml = r#"contributors: apa"#;
1368        let config: Config = serde_yaml::from_str(yaml).unwrap();
1369        let contributors = config.contributors.unwrap();
1370        assert_eq!(contributors.and, Some(AndOptions::Symbol));
1371        assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1372    }
1373
1374    #[test]
1375    fn test_role_label_presets_parse_and_resolve_precedence() {
1376        let yaml = r#"
1377contributors:
1378  role:
1379    preset: short-suffix
1380    roles:
1381      editor:
1382        preset: long-suffix
1383"#;
1384        let config: Config = serde_yaml::from_str(yaml).unwrap();
1385        let contributors = config.contributors.unwrap();
1386
1387        assert_eq!(
1388            contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1389            Some(RoleLabelPreset::LongSuffix)
1390        );
1391        assert_eq!(
1392            contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1393            Some(RoleLabelPreset::ShortSuffix)
1394        );
1395
1396        // Scalar shorthand form — must parse identically for preset-only case
1397        let yaml_scalar = r#"
1398contributors:
1399  role: short-suffix
1400"#;
1401        let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1402        let contributors2 = config2.contributors.unwrap();
1403
1404        assert_eq!(
1405            contributors2
1406                .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1407            Some(RoleLabelPreset::ShortSuffix)
1408        );
1409    }
1410
1411    #[test]
1412    fn test_role_specific_name_order_override_is_available() {
1413        let yaml = r#"
1414contributors:
1415  role:
1416    roles:
1417      translator:
1418        name-order: given-first
1419"#;
1420        let config: Config = serde_yaml::from_str(yaml).unwrap();
1421        let contributors = config.contributors.unwrap();
1422
1423        assert_eq!(
1424            contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1425            Some(&crate::template::NameOrder::GivenFirst)
1426        );
1427    }
1428
1429    #[test]
1430    fn test_date_config_preset() {
1431        // Test that a preset name parses and resolves correctly for dates
1432        let yaml = r#"dates: long"#;
1433        let config: Config = serde_yaml::from_str(yaml).unwrap();
1434        let dates = config.dates.unwrap();
1435        assert_eq!(dates.month, MonthFormat::Long);
1436    }
1437
1438    #[test]
1439    fn test_titles_config_preset() {
1440        // Test that a preset name parses and resolves correctly for titles
1441        let yaml = r#"titles: chicago"#;
1442        let config: Config = serde_yaml::from_str(yaml).unwrap();
1443        let titles = config.titles.unwrap();
1444        assert_eq!(titles.component.unwrap().quote, Some(true));
1445        assert_eq!(titles.monograph.unwrap().emph, Some(true));
1446    }
1447
1448    #[test]
1449    fn test_substitute_config_preset() {
1450        // Test that a preset name parses correctly
1451        let yaml = r#"substitute: standard"#;
1452        let config: Config = serde_yaml::from_str(yaml).unwrap();
1453        assert!(config.substitute.is_some());
1454        let resolved = config.substitute.unwrap().resolve();
1455        assert_eq!(resolved.template.len(), 3);
1456        assert_eq!(resolved.template[0], SubstituteKey::Editor);
1457    }
1458
1459    #[test]
1460    fn test_substitute_config_explicit() {
1461        // Test that explicit config still works
1462        let yaml = r#"
1463substitute:
1464  template:
1465    - title
1466    - editor
1467"#;
1468        let config: Config = serde_yaml::from_str(yaml).unwrap();
1469        let resolved = config.substitute.unwrap().resolve();
1470        assert_eq!(resolved.template[0], SubstituteKey::Title);
1471        assert_eq!(resolved.template[1], SubstituteKey::Editor);
1472    }
1473
1474    #[test]
1475    fn test_config_merge_precedence() {
1476        // Base config with global options
1477        let base_yaml = r#"
1478processing: author-date
1479locale-override: en-US-base
1480contributors:
1481  display-as-sort: first
1482  and: symbol
1483"#;
1484        let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1485
1486        // Override config (e.g., citation-specific options)
1487        let override_yaml = r#"
1488contributors:
1489  and: text
1490locale-override: en-US-chicago
1491"#;
1492        let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1493
1494        // Merge: override takes precedence
1495        base.merge(&override_config);
1496
1497        // Processing should remain from base (not overridden)
1498        assert_eq!(base.processing, Some(Processing::AuthorDate));
1499        assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1500
1501        // Contributors should be merged with override values taking precedence
1502        assert_eq!(
1503            base.contributors.as_ref().unwrap().and,
1504            Some(AndOptions::Text)
1505        );
1506    }
1507
1508    #[test]
1509    fn test_config_deserializes_locale_override() {
1510        let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1511        assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1512    }
1513
1514    #[test]
1515    fn test_config_merged_convenience() {
1516        let base = Config {
1517            processing: Some(Processing::AuthorDate),
1518            ..Default::default()
1519        };
1520        let override_config = Config {
1521            punctuation_in_quote: true,
1522            ..Default::default()
1523        };
1524
1525        let merged = Config::merged(&base, &override_config);
1526
1527        // Both fields preserved
1528        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1529        assert!(merged.punctuation_in_quote);
1530    }
1531
1532    #[test]
1533    fn test_citation_options_merge_overrides_citation_fields_only() {
1534        let base = Config {
1535            processing: Some(Processing::AuthorDate),
1536            ..Default::default()
1537        };
1538
1539        let overrides = CitationOptions {
1540            strip_periods: Some(true),
1541            locators: Some(LocatorConfig::default()),
1542            ..Default::default()
1543        };
1544
1545        let merged = overrides.merged_with(&base);
1546        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1547        assert!(merged.strip_periods.unwrap_or(false));
1548        assert!(merged.locators.is_some());
1549    }
1550
1551    #[test]
1552    fn test_punctuation_config_deserializes_and_merges_field_by_field() {
1553        let yaml = r#"
1554punctuation:
1555  strong-terminal-comma-policy: keep-terminal
1556  delimiter-suppressing-terminal-marks: "?!…"
1557"#;
1558        let config: Config = serde_yaml::from_str(yaml).unwrap();
1559        let punctuation = config.punctuation.as_ref().unwrap();
1560        assert_eq!(
1561            punctuation.strong_terminal_comma_policy,
1562            Some(StrongTerminalCommaPolicy::KeepTerminal)
1563        );
1564        assert_eq!(
1565            punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1566            Some("?!…")
1567        );
1568
1569        let override_config = Config {
1570            punctuation: Some(PunctuationConfig {
1571                strong_terminal_comma_policy: Some(StrongTerminalCommaPolicy::KeepBoth),
1572                delimiter_suppressing_terminal_marks: None,
1573            }),
1574            ..Default::default()
1575        };
1576        let merged = Config::merged(&config, &override_config);
1577        let punctuation = merged.punctuation.as_ref().unwrap();
1578        assert_eq!(
1579            punctuation.strong_terminal_comma_policy,
1580            Some(StrongTerminalCommaPolicy::KeepBoth)
1581        );
1582        assert_eq!(
1583            punctuation.delimiter_suppressing_terminal_marks.as_deref(),
1584            Some("?!…")
1585        );
1586    }
1587
1588    #[test]
1589    fn test_bibliography_options_merge_projects_shared_fields_only() {
1590        let base = Config {
1591            processing: Some(Processing::AuthorDate),
1592            ..Default::default()
1593        };
1594
1595        let overrides = BibliographyOptions {
1596            entry_suffix: Some(".".into()),
1597            separator: Some(", ".into()),
1598            suppress_period_after_url: true,
1599            ..Default::default()
1600        };
1601
1602        let merged = overrides.merged_with(&base);
1603        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1604        assert!(merged.locators.is_none());
1605        assert!(merged.notes.is_none());
1606        let bibliography = overrides.to_bibliography_config();
1607        assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1608        assert_eq!(bibliography.separator.as_deref(), Some(", "));
1609        assert!(bibliography.suppress_period_after_url);
1610    }
1611
1612    #[test]
1613    fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1614        let base = Config {
1615            processing: Some(Processing::AuthorDate),
1616            ..Default::default()
1617        };
1618
1619        let overrides = BibliographyOptions {
1620            contributors: Some(ContributorConfig::default()),
1621            ..Default::default()
1622        };
1623
1624        let merged = overrides.merged_with(&base);
1625        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1626        assert!(merged.contributors.is_some());
1627    }
1628
1629    #[test]
1630    fn citation_options_captures_unknown_fields_for_forward_compat() {
1631        let yaml = "future-key: true\n";
1632        let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1633        assert!(opts.unknown_fields.contains_key("future-key"));
1634    }
1635
1636    #[test]
1637    fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1638        let yaml = "future-key: true\n";
1639        let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1640        assert!(opts.unknown_fields.contains_key("future-key"));
1641    }
1642
1643    #[test]
1644    fn note_config_captures_unknown_fields_for_forward_compat() {
1645        let yaml = "punctuation: inside\nfuture-key: true\n";
1646        let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1647        assert!(cfg.unknown_fields.contains_key("future-key"));
1648        assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1649    }
1650
1651    #[test]
1652    fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1653        // `romanized-translated` resolves to Combined title mode (romanized [translated])
1654        let yaml = r#"multilingual: romanized-translated"#;
1655        let config: Config = serde_yaml::from_str(yaml).unwrap();
1656        let ml = config.multilingual.unwrap();
1657        assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1658        assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1659        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1660    }
1661
1662    #[test]
1663    fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1664        // `romanized-only` resolves to Transliterated title mode (no translation)
1665        let yaml = r#"multilingual: romanized-only"#;
1666        let config: Config = serde_yaml::from_str(yaml).unwrap();
1667        let ml = config.multilingual.unwrap();
1668        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1669        assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1670        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1671    }
1672
1673    #[test]
1674    fn test_multilingual_preset_romanized_script_translated_parses_and_resolves() {
1675        // `romanized-script-translated` resolves to a Pattern title mode (romanized original-script [translated])
1676        // and Pattern name mode (romanized original-script), with Latn script and CJK native ordering.
1677        use crate::options::multilingual::{MultilingualSegment, MultilingualView, SegmentWrap};
1678        let yaml = r#"multilingual: romanized-script-translated"#;
1679        let config: Config = serde_yaml::from_str(yaml).unwrap();
1680        let ml = config.multilingual.unwrap();
1681        assert_eq!(
1682            ml.title_mode,
1683            Some(MultilingualMode::Pattern(vec![
1684                MultilingualSegment {
1685                    view: MultilingualView::Transliterated,
1686                    wrap: SegmentWrap::None,
1687                },
1688                MultilingualSegment {
1689                    view: MultilingualView::OriginalScript,
1690                    wrap: SegmentWrap::None,
1691                },
1692                MultilingualSegment {
1693                    view: MultilingualView::Translated,
1694                    wrap: SegmentWrap::Brackets,
1695                },
1696            ]))
1697        );
1698        assert_eq!(
1699            ml.name_mode,
1700            Some(MultilingualMode::Pattern(vec![
1701                MultilingualSegment {
1702                    view: MultilingualView::Transliterated,
1703                    wrap: SegmentWrap::None,
1704                },
1705                MultilingualSegment {
1706                    view: MultilingualView::OriginalScript,
1707                    wrap: SegmentWrap::None,
1708                },
1709            ]))
1710        );
1711        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1712        assert!(ml.scripts.get("Han").is_some_and(|s| s.use_native_ordering));
1713        assert!(
1714            ml.scripts
1715                .get("Hangul")
1716                .is_some_and(|s| s.use_native_ordering)
1717        );
1718    }
1719
1720    #[test]
1721    fn test_multilingual_explicit_block_transliterated_roundtrips() {
1722        // Verify a Transliterated explicit block survives YAML serialize→deserialize.
1723        let yaml = r#"
1724multilingual:
1725  title-mode: transliterated
1726  preferred-script: Latn
1727"#;
1728        let config: Config = serde_yaml::from_str(yaml).unwrap();
1729        let ml = config.multilingual.clone().unwrap();
1730        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1731        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1732
1733        let yaml2 = serde_yaml::to_string(&config).unwrap();
1734        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1735        assert_eq!(config2.multilingual, config.multilingual);
1736    }
1737
1738    #[test]
1739    fn test_multilingual_pattern_block_roundtrips() {
1740        // Exercises the externally-tagged `{pattern: [...]}` YAML path — the case that
1741        // breaks under serde_yaml's untagged+enum limitation without the custom Deserialize.
1742        let yaml = r#"
1743multilingual:
1744  title-mode:
1745    pattern:
1746      - view: original-script
1747      - view: translated
1748        wrap: brackets
1749"#;
1750        let config: Config = serde_yaml::from_str(yaml).unwrap();
1751        let ml = config.multilingual.clone().unwrap();
1752        assert!(
1753            matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1754            "expected Pattern mode, got {:?}",
1755            ml.title_mode
1756        );
1757
1758        let yaml2 = serde_yaml::to_string(&config).unwrap();
1759        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1760        assert_eq!(config2.multilingual, config.multilingual);
1761    }
1762
1763    /// An overlay that names one multilingual key must not discard the rest of
1764    /// the inherited block. Regression for bean `csl26-p7kj`: `multilingual`
1765    /// was merged whole-value, so a style extending `gb-t-7714-2025-numeric`
1766    /// and adding an unrelated `scripts` entry silently dropped the inherited
1767    /// `punctuation-width: mixed`, reverting Chinese punctuation to half-width.
1768    #[rstest]
1769    #[case::scripts_overlay(
1770        "multilingual:\n  scripts:\n    Hang:\n      use-native-ordering: true\n",
1771        "Hang"
1772    )]
1773    #[case::term_locale_overlay("multilingual:\n  term-locale: item\n", "Hani")]
1774    fn given_partial_multilingual_overlay_when_merging_then_inherited_fields_survive(
1775        #[case] overlay_yaml: &str,
1776        #[case] expected_script_key: &str,
1777    ) {
1778        // given: a parent declaring several multilingual fields at once
1779        let base_yaml = "\
1780multilingual:
1781  punctuation-width: mixed
1782  preferred-script: Latn
1783  scripts:
1784    Hani:
1785      use-native-ordering: true
1786";
1787        let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1788
1789        // when: an overlay names only one of them
1790        let overlay: Config = serde_yaml::from_str(overlay_yaml).unwrap();
1791        base.merge(&overlay);
1792
1793        // then: the inherited fields the overlay never mentioned are preserved
1794        let merged = base.multilingual.expect("merged multilingual block");
1795        assert_eq!(
1796            merged.punctuation_width,
1797            Some(PunctuationWidth::Mixed),
1798            "inherited punctuation-width must survive a partial overlay"
1799        );
1800        assert_eq!(merged.preferred_script.as_deref(), Some("Latn"));
1801        assert!(
1802            merged.scripts.contains_key(expected_script_key),
1803            "expected scripts key {expected_script_key:?}, got {:?}",
1804            merged.scripts.keys().collect::<Vec<_>>()
1805        );
1806    }
1807
1808    /// An overlay that does name a field still wins over the inherited value.
1809    #[test]
1810    fn given_multilingual_overlay_setting_a_field_when_merging_then_overlay_wins() {
1811        let mut base: Config =
1812            serde_yaml::from_str("multilingual:\n  punctuation-width: mixed\n").unwrap();
1813        let overlay: Config =
1814            serde_yaml::from_str("multilingual:\n  punctuation-width: bylan\n").unwrap();
1815
1816        base.merge(&overlay);
1817
1818        assert_eq!(
1819            base.multilingual.unwrap().punctuation_width,
1820            Some(PunctuationWidth::Bylan)
1821        );
1822    }
1823}