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 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/// Top-level style configuration.
78#[derive(Debug, Default, PartialEq, Clone, Serialize)]
79#[cfg_attr(feature = "schema", derive(JsonSchema))]
80#[serde(rename_all = "kebab-case")]
81pub struct Config {
82    /// Style-owned MF2 messages, inherited and merged by message ID.
83    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
84    pub messages: HashMap<String, String>,
85    /// Substitution rules for missing data. Accepts a preset name (e.g.
86    /// "standard") or explicit configuration.
87    #[serde(
88        skip_serializing_if = "Option::is_none",
89        deserialize_with = "deserialize_substitute_config",
90        default
91    )]
92    pub substitute: Option<SubstituteConfig>,
93    /// Identity-date substitution policy. Omission preserves inline or
94    /// implicit date fallback behavior; it does not inject `standard`.
95    #[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    /// Processing mode (author-date, numeric, etc.).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub processing: Option<Processing>,
105    /// Style-level locale override ID loaded from `locales/overrides/<id>.*`.
106    ///
107    /// This patches the locale selected by `StyleInfo.default_locale` without
108    /// duplicating the full base locale. Runtime loading is limited to the
109    /// style-global config; nested citation or bibliography configs are ignored.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub locale_override: Option<String>,
112    /// Localization settings.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub localize: Option<Localize>,
115    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
116    /// `"romanized-only"`) or an explicit configuration block.
117    #[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    /// Bibliography sorting policy.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub sorting: Option<SortingConfig>,
127    /// Contributor formatting defaults. Accepts a preset name (e.g., "apa")
128    /// or explicit configuration.
129    #[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    /// Date formatting defaults. Accepts a preset name (e.g., "long")
137    /// or explicit configuration.
138    #[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    /// Title formatting defaults. Accepts a preset name (e.g., "apa")
146    /// or explicit configuration.
147    #[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    /// Locator rendering configuration. Accepts a preset name (e.g., "note")
155    /// or explicit configuration.
156    #[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    /// Page range formatting (expanded, minimal, chicago).
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub page_range_format: Option<PageRangeFormat>,
166    /// Separator between page-range endpoints. Overrides the locale's
167    /// `page-range-delimiter` (en-dash by default); AMA and similar use `-`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub page_range_delimiter: Option<String>,
170    /// Hyperlink configuration.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub links: Option<LinksConfig>,
173    /// Whether to place periods/commas inside quotation marks.
174    /// true = American style ("text."), false = British style ("text".)
175    /// Defaults to false; a style that sets it explicitly always wins. When
176    /// left unset, the engine fills it from the active locale's
177    /// `grammar-options.punctuation-in-quote` (`en-US` sets `true`; most
178    /// other bundled locales set `false`) — unless that locale was itself
179    /// substituted for one that could not be resolved, in which case no
180    /// locale default is applied. See
181    /// `Processor::resolve_punctuation_defaults`.
182    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183    pub punctuation_in_quote: bool,
184    /// Locale-sensitive punctuation-collision overrides.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub punctuation: Option<PunctuationConfig>,
187    /// Delimiter between volume/issue and pages for serial sources.
188    /// Processor adds trailing space when rendering.
189    /// Examples: Comma (APA ", "), Colon (Chicago ": ").
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
192    /// Strip trailing periods from terms, labels, and abbreviated dates.
193    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
194    pub strip_periods: Option<bool>,
195    /// Document-level note marker placement and punctuation movement rules.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub notes: Option<NoteConfig>,
198    /// Integral citation name-memory behavior.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
201    /// Organizational name abbreviation expansion policy.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
204    /// Custom user-defined fields for extensions.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub custom: Option<HashMap<String, serde_json::Value>>,
207    /// Forward-compat: captures unknown keys when an older engine reads a
208    /// style produced by a newer schema. Empty by default; treated as a
209    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
210    #[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/// Citation-local option overrides.
220#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
221#[cfg_attr(feature = "schema", derive(JsonSchema))]
222#[serde(rename_all = "kebab-case")]
223pub struct CitationOptions {
224    /// Substitution rules for missing data. Accepts a preset name (e.g.
225    /// "standard") or explicit configuration.
226    #[serde(
227        skip_serializing_if = "Option::is_none",
228        deserialize_with = "deserialize_substitute_config",
229        default
230    )]
231    pub substitute: Option<SubstituteConfig>,
232    /// Citation-local identity-date substitution policy.
233    #[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    /// Processing mode (author-date, numeric, etc.).
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub processing: Option<Processing>,
243    /// Localization settings.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub localize: Option<Localize>,
246    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
247    /// `"romanized-only"`) or an explicit configuration block.
248    #[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    /// Contributor formatting defaults.
256    #[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    /// Date formatting defaults.
264    #[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    /// Title formatting defaults.
272    #[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    /// Locator rendering configuration.
280    #[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    /// Page range formatting (expanded, minimal, chicago).
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub page_range_format: Option<PageRangeFormat>,
290    /// Hyperlink configuration.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub links: Option<LinksConfig>,
293    /// Whether to place periods/commas inside quotation marks.
294    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
295    pub punctuation_in_quote: bool,
296    /// Delimiter between volume/issue and pages for serial sources.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
299    /// Strip trailing periods from terms, labels, and abbreviated dates.
300    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
301    pub strip_periods: Option<bool>,
302    /// Document-level note marker placement and punctuation movement rules.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub notes: Option<NoteConfig>,
305    /// Integral citation name-memory behavior.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
308    /// Organizational name abbreviation expansion policy.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
311    /// Declarative mode for the processor-generated reference marker this
312    /// citation renders — numeric (`[1]`), alphabetic (`[Kuh62]`), or none.
313    /// See `docs/specs/REFERENCE_MARKERS.md`.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub label_mode: Option<CitationLabelMode>,
316    /// Label wrap policy applied to the reference marker alone.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub label_wrap: Option<LabelWrap>,
319    /// Wrap policy applied to one citation item as a whole — the reference
320    /// marker together with the item body, such as a locator.
321    ///
322    /// Distinct from [`Self::label_wrap`], which encloses only the marker, and
323    /// from `CitationSpec::wrap`, which encloses the whole assembled citation.
324    /// IEEE renders `[1, p. 737]` with `item-wrap`; the American Medical
325    /// Association style renders `[1](p737)` with `label-wrap`.
326    /// See `docs/specs/REFERENCE_MARKERS.md`.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub item_wrap: Option<LabelWrap>,
329    /// Delimiter between grouped citation items.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub group_delimiter: Option<CitationGroupDelimiter>,
332    /// Custom user-defined fields for extensions.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub custom: Option<HashMap<String, serde_json::Value>>,
335    /// Forward-compat: captures unknown keys when an older engine reads a
336    /// style produced by a newer schema. Empty by default; treated as a
337    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
338    #[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/// Bibliography-local option overrides.
348#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
349#[cfg_attr(feature = "schema", derive(JsonSchema))]
350#[serde(rename_all = "kebab-case")]
351pub struct BibliographyOptions {
352    /// Substitution rules for missing data. Accepts a preset name (e.g.
353    /// "standard") or explicit configuration.
354    #[serde(
355        skip_serializing_if = "Option::is_none",
356        deserialize_with = "deserialize_substitute_config",
357        default
358    )]
359    pub substitute: Option<SubstituteConfig>,
360    /// Bibliography-local identity-date substitution policy.
361    #[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    /// Processing mode (author-date, numeric, etc.).
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub processing: Option<Processing>,
371    /// Localization settings.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub localize: Option<Localize>,
374    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
375    /// `"romanized-only"`) or an explicit configuration block.
376    #[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    /// Bibliography sorting policy.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub sorting: Option<SortingConfig>,
386    /// Contributor formatting defaults.
387    #[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    /// Date formatting defaults.
395    #[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    /// Title formatting defaults.
403    #[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    /// Page range formatting (expanded, minimal, chicago).
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub page_range_format: Option<PageRangeFormat>,
413    /// Article-journal-specific bibliography policies.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub article_journal: Option<ArticleJournalBibliographyConfig>,
416    /// String to substitute for repeating authors.
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub subsequent_author_substitute: Option<String>,
419    /// Rule for when to apply the substitute.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
422    /// Whether to use a hanging indent.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub hanging_indent: Option<bool>,
425    /// Suffix appended to each bibliography entry. Accepts a semantic mark
426    /// (`{ mark: period }`) or a literal string.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub entry_suffix: Option<DelimiterPunctuation>,
429    /// Separator between bibliography components. Accepts a semantic mark
430    /// (`{ mark: period }`) or a literal string.
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub separator: Option<DelimiterPunctuation>,
433    /// Whether to suppress the trailing period after URLs/DOIs.
434    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
435    pub suppress_period_after_url: bool,
436    /// Force `entry-suffix` even when the entry ends in a URL (MLA).
437    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438    pub entry_suffix_after_url: bool,
439    /// Force `entry-suffix` even when the entry ends in a DOI (IEEE).
440    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
441    pub entry_suffix_after_doi: bool,
442    /// Configuration for compound numeric bibliography entries.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
445    /// Partitioning policy for multilingual bibliography sorting and sections.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
448    /// Policy for reference-work entries (dictionary/encyclopedia and
449    /// dictionary-shaped chapters) with no visible author. See
450    /// [`AnonymousEntriesMode`].
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub anonymous_entries: Option<AnonymousEntriesMode>,
453    /// Hyperlink configuration.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub links: Option<LinksConfig>,
456    /// Whether to place periods/commas inside quotation marks.
457    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
458    pub punctuation_in_quote: bool,
459    /// Delimiter between volume/issue and pages for serial sources.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
462    /// Declarative mode for the processor-generated reference marker each
463    /// entry leads with — numeric (`[1]`), alphabetic (`[Kuh62]`), author-date,
464    /// or none. See `docs/specs/REFERENCE_MARKERS.md`.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub label_mode: Option<BibliographyLabelMode>,
467    /// Wrap policy applied to the bibliography reference marker.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub label_wrap: Option<BibliographyLabelWrap>,
470    /// Text between the reference marker and the entry body.
471    ///
472    /// Defaults to empty, which renders flush (`[1]J. Smith`) and matches
473    /// citeproc-js `second-field-align` output flattened to text. A style that
474    /// wants a gap declares it. See `docs/specs/REFERENCE_MARKERS.md`.
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub label_separator: Option<String>,
477    /// Placement of issued dates within bibliography entries.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub date_position: Option<DatePosition>,
480    /// Terminator applied to primary-title bibliography components.
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub title_terminator: Option<TitleTerminator>,
483    /// Repeated-author rendering mode for bibliography entries.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
486    /// Strip trailing periods from terms, labels, and abbreviated dates.
487    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
488    pub strip_periods: Option<bool>,
489    /// Custom user-defined fields for extensions.
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub custom: Option<HashMap<String, serde_json::Value>>,
492    /// Forward-compat: captures unknown keys when an older engine reads a
493    /// style produced by a newer schema. Empty by default; treated as a
494    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
495    #[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/// Document-level note marker placement rules.
505#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "schema", derive(JsonSchema))]
507#[serde(rename_all = "kebab-case")]
508pub struct NoteConfig {
509    /// Desired location of movable punctuation relative to closing quotation
510    /// marks when note markers are introduced.
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub punctuation: Option<NoteQuotePlacement>,
513    /// Desired location of the note marker relative to closing quotation marks.
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub number: Option<NoteNumberPlacement>,
516    /// Whether the note marker appears before or after the closest movable
517    /// punctuation mark.
518    #[serde(skip_serializing_if = "Option::is_none")]
519    pub order: Option<NoteMarkerOrder>,
520    /// Forward-compat: captures unknown keys when an older engine reads a
521    /// style produced by a newer schema. Empty by default; treated as a
522    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
523    #[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/// Style-level overrides for locale punctuation-collision defaults.
533#[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    /// Policy for a strong terminal mark followed by a style-supplied comma.
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub strong_terminal_comma_policy: Option<StrongTerminalCommaPolicy>,
540    /// Terminal marks that suppress a following delimiter's punctuation core.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub delimiter_suppressing_terminal_marks: Option<String>,
543}
544
545impl PunctuationConfig {
546    /// Merge `other` over this configuration field by field.
547    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/// Controls how a strong terminal mark collides with a following comma.
558#[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    /// Preserve both the terminal mark and the following comma.
563    #[default]
564    KeepBoth,
565    /// Preserve the terminal mark and suppress the following comma.
566    KeepTerminal,
567}
568
569/// Controls where movable punctuation is placed relative to closing quotation marks.
570#[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    /// Keep movable punctuation inside the closing quotation mark.
575    Inside,
576    /// Keep movable punctuation outside the closing quotation mark.
577    Outside,
578    /// Follow org-cite-style adaptive behavior: punctuation stays inside when
579    /// it is already flush with the closing quote, otherwise it is placed
580    /// outside.
581    #[default]
582    Adaptive,
583}
584
585/// Controls where a footnote number marker is placed relative to closing quotation marks.
586#[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    /// Place the note marker inside the closing quotation mark.
591    Inside,
592    /// Place the note marker outside the closing quotation mark.
593    #[default]
594    Outside,
595    /// Place the note marker on the same side as the movable punctuation when
596    /// only one side has punctuation; otherwise default to outside.
597    Same,
598}
599
600/// Controls whether a note marker appears before or after adjacent movable punctuation.
601#[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    /// Place the note marker before the closest movable punctuation mark.
606    Before,
607    /// Place the note marker after the closest movable punctuation mark.
608    #[default]
609    After,
610}
611
612/// Page range formatting options.
613#[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    /// Full expansion: 321-328 → 321–328
619    #[default]
620    Expanded,
621    /// Minimal digits: 321-328 → 321–8
622    Minimal,
623    /// Minimal two digits: 321-328 → 321–28
624    MinimalTwo,
625    /// Chicago Manual of Style 15th ed rules
626    Chicago,
627    /// Chicago Manual of Style 16th/17th ed rules
628    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/// Structured link options.
651#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
652#[cfg_attr(feature = "schema", derive(JsonSchema))]
653#[serde(rename_all = "kebab-case")]
654pub struct LinksConfig {
655    /// Link value to the item's DOI.
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub doi: Option<bool>,
658    /// Link value to the item's URL.
659    #[serde(skip_serializing_if = "Option::is_none")]
660    pub url: Option<bool>,
661    /// The target for the link (url, doi, etc.).
662    #[serde(skip_serializing_if = "Option::is_none")]
663    pub target: Option<LinkTarget>,
664    /// What text should be hyperlinked (title, url, etc.).
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub anchor: Option<LinkAnchor>,
667    /// Omit the URL scheme (e.g. `http://`, `https://`) when rendering a link.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub strip_protocol: Option<bool>,
670}
671
672/// Link target options.
673#[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/// Link anchor options.
685#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
686#[cfg_attr(feature = "schema", derive(JsonSchema))]
687#[serde(rename_all = "kebab-case")]
688pub enum LinkAnchor {
689    /// Link the title component.
690    Title,
691    /// Link the URL component itself.
692    Url,
693    /// Link the DOI component itself.
694    Doi,
695    /// Link the specific component this config is attached to.
696    Component,
697    /// Link the entire bibliography entry.
698    Entry,
699}
700
701impl Config {
702    /// Merge `other`'s multilingual block field-wise rather than replacing it.
703    ///
704    /// Replacing the whole block means a style that extends a parent and sets
705    /// any single multilingual key silently discards every inherited one — see
706    /// [`MultilingualConfig::merge`] and bean `csl26-p7kj`.
707    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    /// Effective processing mode, falling back to the default when unset.
730    ///
731    /// Centralizes the `processing: None` fallback so every consumer resolves
732    /// the same default (`Processing::default()`) instead of hardcoding it.
733    pub fn effective_processing(&self) -> Processing {
734        self.processing.clone().unwrap_or_default()
735    }
736
737    /// Merge another config into this one, with `other` taking precedence.
738    ///
739    /// Used for combining global options with context-specific (citation/bibliography) options.
740    /// Only non-None fields from `other` override fields in `self`.
741    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    /// Create a merged config from base and override, returning a new Config.
799    ///
800    /// Convenience method that clones base, then merges override into it.
801    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    /// Convert citation-local overrides into the runtime config shape.
810    #[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    /// Merge citation-local overrides over a base config.
841    #[must_use]
842    pub fn merged_with(&self, base: &Config) -> Config {
843        Config::merged(base, &self.to_config())
844    }
845
846    /// Merge citation-local overrides over a base config, merging nested
847    /// option blocks field-by-field when a trustworthy authored scope mapping
848    /// is available (see [`cascade::ScopedRawOptions`]).
849    ///
850    /// `raw_options` is the chain-merged authored `citation.options` mapping
851    /// carried on the resolved [`crate::Style`]. When it is absent or no
852    /// longer round-trips to `self` (post-parse mutation), this behaves
853    /// exactly like [`CitationOptions::merged_with`].
854    #[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    /// Merge `other` into `self`, with `other` taking precedence for each field.
870    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    /// Convert bibliography-entry overrides into bibliography-only runtime config.
920    #[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    /// Convert bibliography-local overrides into the runtime config shape.
944    #[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    /// Merge bibliography-local overrides over a base config.
975    #[must_use]
976    pub fn merged_with(&self, base: &Config) -> Config {
977        Config::merged(base, &self.to_config())
978    }
979
980    /// Merge bibliography-local overrides over a base config, merging nested
981    /// option blocks field-by-field when a trustworthy authored scope mapping
982    /// is available (see [`cascade::ScopedRawOptions`]).
983    ///
984    /// `raw_options` is the chain-merged authored `bibliography.options`
985    /// mapping carried on the resolved [`crate::Style`]. When it is absent or
986    /// no longer round-trips to `self` (post-parse mutation), this behaves
987    /// exactly like [`BibliographyOptions::merged_with`].
988    #[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    /// Merge `other` into `self`, with `other` taking precedence for each field.
1004    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    /// Merge the reference-marker fields, with `other` taking precedence.
1037    ///
1038    /// Split from [`Self::merge`] to keep that function under the cognitive
1039    /// complexity limit. See `docs/specs/REFERENCE_MARKERS.md`.
1040    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
1090/// Deserialize contributor config from either a preset name or explicit config.
1091fn 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
1101/// Deserialize date config from either a preset name or explicit config.
1102fn 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
1110/// Deserialize and eagerly expand a named or explicit date-substitution policy.
1111fn 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
1119/// Deserialize titles config from either a preset name or explicit config.
1120fn 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
1131/// Deserialize locator config from either a preset name or explicit config.
1132fn 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
1140/// Deserialize substitute config from either a preset name or explicit config.
1141///
1142/// Eagerly resolves a `Preset` variant to its `Explicit` form (mirroring
1143/// `deserialize_contributor_config`/`deserialize_date_config`/etc.), so the
1144/// typed value always serializes as a mapping. Without this, the `extends`
1145/// overlay's raw-YAML deep merge (`style/overlay.rs`) sees a preset-name
1146/// scalar for an authored `substitute: <preset>` override and whole-replaces
1147/// the inherited `Substitute` block instead of field-merging it, silently
1148/// dropping fields the preset doesn't set (e.g. `role-substitute`).
1149fn 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
1162/// Deserialize multilingual config from either a preset name or an explicit block.
1163fn 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        // Test that a preset name parses and resolves correctly for contributors
1524        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        // Scalar shorthand form — must parse identically for preset-only case
1554        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        // Test that a preset name parses and resolves correctly for dates
1589        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        // Test that a preset name parses and resolves correctly for titles
1598        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        // Test that a preset name parses correctly
1608        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        // Test that explicit config still works
1619        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        // Base config with global options
1634        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        // Override config (e.g., citation-specific options)
1644        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        // Merge: override takes precedence
1652        base.merge(&override_config);
1653
1654        // Processing should remain from base (not overridden)
1655        assert_eq!(base.processing, Some(Processing::AuthorDate));
1656        assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1657
1658        // Contributors should be merged with override values taking precedence
1659        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        // Both fields preserved
1685        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        // `romanized-translated` resolves to Combined title mode (romanized [translated])
1811        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        // `romanized-only` resolves to Transliterated title mode (no translation)
1822        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        // `romanized-script-translated` resolves to a Pattern title mode (romanized original-script [translated])
1833        // and Pattern name mode (romanized original-script), with Latn script and CJK native ordering.
1834        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        // Verify a Transliterated explicit block survives YAML serialize→deserialize.
1880        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        // Exercises the externally-tagged `{pattern: [...]}` YAML path — the case that
1898        // breaks under serde_yaml's untagged+enum limitation without the custom Deserialize.
1899        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    /// An overlay that names one multilingual key must not discard the rest of
1921    /// the inherited block. Regression for bean `csl26-p7kj`: `multilingual`
1922    /// was merged whole-value, so a style extending `gb-t-7714-2025-numeric`
1923    /// and adding an unrelated `scripts` entry silently dropped the inherited
1924    /// `punctuation-width: mixed`, reverting Chinese punctuation to half-width.
1925    #[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        // given: a parent declaring several multilingual fields at once
1936        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        // when: an overlay names only one of them
1947        let overlay: Config = serde_yaml::from_str(overlay_yaml).unwrap();
1948        base.merge(&overlay);
1949
1950        // then: the inherited fields the overlay never mentioned are preserved
1951        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    /// An overlay that does name a field still wins over the inherited value.
1966    #[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}