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 contributors;
10pub mod dates;
11pub mod integral_name_memory;
12pub mod localization;
13pub mod locators;
14pub mod multilingual;
15pub mod processing;
16pub mod scoped;
17pub mod substitute;
18pub mod title_class;
19
20pub use crate::presets::{MultilingualConfigEntry, MultilingualPreset};
21pub use bibliography::{
22    AnonymousEntriesMode, ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback,
23    BibliographyConfig, BibliographyPartitionHeading, BibliographyPartitionKind,
24    BibliographyPartitionMode, BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
25};
26pub use contributors::{
27    AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry, DelimiterPrecedesLast,
28    DemoteNonDroppingParticle, DisplayAsSort, NameForm, RoleLabelDefaults, RoleLabelPreset,
29    RoleOptions, RoleOptionsEntry, RoleRendering, ShortenListOptions,
30};
31pub use dates::{DateConfig, DateConfigEntry, NoDateForm};
32pub use integral_name_memory::{
33    IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, OrgAbbreviationMemoryConfig,
34    ResolvedIntegralNameMemoryConfig, ResolvedOrgAbbreviationMemoryConfig, ShortNameDisplay,
35    SubsequentNameForm,
36};
37pub use localization::{Localize, MonthFormat, Scope};
38pub use locators::{
39    LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
40    LocatorPreset, TypeClass,
41};
42pub use multilingual::{
43    MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView, ScriptConfig,
44    SegmentWrap,
45};
46pub use processing::{
47    CitationSortPolicy, Disambiguation, GivennameRule, Group, LabelConfig, LabelParams,
48    LabelPreset, Processing, ProcessingBase, ProcessingCustom, RegimeFamily, Sort, SortEntry,
49    SortKey, SortSpec,
50};
51pub use scoped::{
52    BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, DatePosition, LabelWrap,
53    RepeatedAuthorRendering, TitleTerminator,
54};
55pub use substitute::{Substitute, SubstituteConfig, SubstituteKey, SubstituteTitleQuoteMode};
56
57use crate::template::DelimiterPunctuation;
58#[cfg(feature = "schema")]
59use schemars::JsonSchema;
60use serde::{Deserialize, Serialize};
61use std::collections::HashMap;
62
63/// Top-level style configuration.
64#[derive(Debug, Default, PartialEq, Clone, Serialize)]
65#[cfg_attr(feature = "schema", derive(JsonSchema))]
66#[serde(rename_all = "kebab-case")]
67pub struct Config {
68    /// Substitution rules for missing data.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub substitute: Option<SubstituteConfig>,
71    /// Processing mode (author-date, numeric, etc.).
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub processing: Option<Processing>,
74    /// Style-level locale override ID loaded from `locales/overrides/<id>.*`.
75    ///
76    /// This patches the locale selected by `StyleInfo.default_locale` without
77    /// duplicating the full base locale. Runtime loading is limited to the
78    /// style-global config; nested citation or bibliography configs are ignored.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub locale_override: Option<String>,
81    /// Localization settings.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub localize: Option<Localize>,
84    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
85    /// `"romanized-only"`) or an explicit configuration block.
86    #[serde(
87        skip_serializing_if = "Option::is_none",
88        deserialize_with = "deserialize_multilingual_config",
89        default
90    )]
91    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
92    pub multilingual: Option<MultilingualConfig>,
93    /// Contributor formatting defaults. Accepts a preset name (e.g., "apa")
94    /// or explicit configuration.
95    #[serde(
96        skip_serializing_if = "Option::is_none",
97        deserialize_with = "deserialize_contributor_config",
98        default
99    )]
100    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
101    pub contributors: Option<ContributorConfig>,
102    /// Date formatting defaults. Accepts a preset name (e.g., "long")
103    /// or explicit configuration.
104    #[serde(
105        skip_serializing_if = "Option::is_none",
106        deserialize_with = "deserialize_date_config",
107        default
108    )]
109    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
110    pub dates: Option<DateConfig>,
111    /// Title formatting defaults. Accepts a preset name (e.g., "apa")
112    /// or explicit configuration.
113    #[serde(
114        skip_serializing_if = "Option::is_none",
115        deserialize_with = "deserialize_titles_config",
116        default
117    )]
118    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
119    pub titles: Option<crate::options::titles::TitlesConfig>,
120    /// Locator rendering configuration. Accepts a preset name (e.g., "note")
121    /// or explicit configuration.
122    #[serde(
123        skip_serializing_if = "Option::is_none",
124        deserialize_with = "deserialize_locator_config",
125        default
126    )]
127    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
128    pub locators: Option<LocatorConfig>,
129    /// Page range formatting (expanded, minimal, chicago).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub page_range_format: Option<PageRangeFormat>,
132    /// Separator between page-range endpoints. Overrides the locale's
133    /// `page-range-delimiter` (en-dash by default); AMA and similar use `-`.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub page_range_delimiter: Option<String>,
136    /// Hyperlink configuration.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub links: Option<LinksConfig>,
139    /// Whether to place periods/commas inside quotation marks.
140    /// true = American style ("text."), false = British style ("text".)
141    /// Defaults to false; en-US locale typically sets this to true.
142    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143    pub punctuation_in_quote: bool,
144    /// Delimiter between volume/issue and pages for serial sources.
145    /// Processor adds trailing space when rendering.
146    /// Examples: Comma (APA ", "), Colon (Chicago ": ").
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
149    /// Strip trailing periods from terms, labels, and abbreviated dates.
150    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
151    pub strip_periods: Option<bool>,
152    /// Document-level note marker placement and punctuation movement rules.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub notes: Option<NoteConfig>,
155    /// Integral citation name-memory behavior.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
158    /// Organizational name abbreviation expansion policy.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
161    /// Custom user-defined fields for extensions.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub custom: Option<HashMap<String, serde_json::Value>>,
164    /// Forward-compat: captures unknown keys when an older engine reads a
165    /// style produced by a newer schema. Empty by default; treated as a
166    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
167    #[serde(
168        flatten,
169        default,
170        skip_serializing_if = "std::collections::BTreeMap::is_empty"
171    )]
172    #[cfg_attr(feature = "schema", schemars(skip))]
173    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
174}
175
176/// Citation-local option overrides.
177#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(JsonSchema))]
179#[serde(rename_all = "kebab-case")]
180pub struct CitationOptions {
181    /// Substitution rules for missing data.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub substitute: Option<SubstituteConfig>,
184    /// Processing mode (author-date, numeric, etc.).
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub processing: Option<Processing>,
187    /// Localization settings.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub localize: Option<Localize>,
190    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
191    /// `"romanized-only"`) or an explicit configuration block.
192    #[serde(
193        skip_serializing_if = "Option::is_none",
194        deserialize_with = "deserialize_multilingual_config",
195        default
196    )]
197    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
198    pub multilingual: Option<MultilingualConfig>,
199    /// Contributor formatting defaults.
200    #[serde(
201        skip_serializing_if = "Option::is_none",
202        deserialize_with = "deserialize_contributor_config",
203        default
204    )]
205    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
206    pub contributors: Option<ContributorConfig>,
207    /// Date formatting defaults.
208    #[serde(
209        skip_serializing_if = "Option::is_none",
210        deserialize_with = "deserialize_date_config",
211        default
212    )]
213    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
214    pub dates: Option<DateConfig>,
215    /// Title formatting defaults.
216    #[serde(
217        skip_serializing_if = "Option::is_none",
218        deserialize_with = "deserialize_titles_config",
219        default
220    )]
221    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
222    pub titles: Option<crate::options::titles::TitlesConfig>,
223    /// Locator rendering configuration.
224    #[serde(
225        skip_serializing_if = "Option::is_none",
226        deserialize_with = "deserialize_locator_config",
227        default
228    )]
229    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
230    pub locators: Option<LocatorConfig>,
231    /// Page range formatting (expanded, minimal, chicago).
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub page_range_format: Option<PageRangeFormat>,
234    /// Hyperlink configuration.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub links: Option<LinksConfig>,
237    /// Whether to place periods/commas inside quotation marks.
238    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
239    pub punctuation_in_quote: bool,
240    /// Delimiter between volume/issue and pages for serial sources.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
243    /// Strip trailing periods from terms, labels, and abbreviated dates.
244    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
245    pub strip_periods: Option<bool>,
246    /// Document-level note marker placement and punctuation movement rules.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub notes: Option<NoteConfig>,
249    /// Integral citation name-memory behavior.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
252    /// Organizational name abbreviation expansion policy.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
255    /// Label wrap policy applied to citation labels.
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub label_wrap: Option<LabelWrap>,
258    /// Delimiter between grouped citation items.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub group_delimiter: Option<CitationGroupDelimiter>,
261    /// Custom user-defined fields for extensions.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub custom: Option<HashMap<String, serde_json::Value>>,
264    /// Forward-compat: captures unknown keys when an older engine reads a
265    /// style produced by a newer schema. Empty by default; treated as a
266    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
267    #[serde(
268        flatten,
269        default,
270        skip_serializing_if = "std::collections::BTreeMap::is_empty"
271    )]
272    #[cfg_attr(feature = "schema", schemars(skip))]
273    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
274}
275
276/// Bibliography-local option overrides.
277#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
278#[cfg_attr(feature = "schema", derive(JsonSchema))]
279#[serde(rename_all = "kebab-case")]
280pub struct BibliographyOptions {
281    /// Substitution rules for missing data.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub substitute: Option<SubstituteConfig>,
284    /// Processing mode (author-date, numeric, etc.).
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub processing: Option<Processing>,
287    /// Localization settings.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub localize: Option<Localize>,
290    /// Multilingual rendering defaults. Accepts a preset name (e.g., `"romanized-translated"`,
291    /// `"romanized-only"`) or an explicit configuration block.
292    #[serde(
293        skip_serializing_if = "Option::is_none",
294        deserialize_with = "deserialize_multilingual_config",
295        default
296    )]
297    #[cfg_attr(feature = "schema", schemars(with = "Option<MultilingualConfigEntry>"))]
298    pub multilingual: Option<MultilingualConfig>,
299    /// Contributor formatting defaults.
300    #[serde(
301        skip_serializing_if = "Option::is_none",
302        deserialize_with = "deserialize_contributor_config",
303        default
304    )]
305    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
306    pub contributors: Option<ContributorConfig>,
307    /// Date formatting defaults.
308    #[serde(
309        skip_serializing_if = "Option::is_none",
310        deserialize_with = "deserialize_date_config",
311        default
312    )]
313    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
314    pub dates: Option<DateConfig>,
315    /// Title formatting defaults.
316    #[serde(
317        skip_serializing_if = "Option::is_none",
318        deserialize_with = "deserialize_titles_config",
319        default
320    )]
321    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
322    pub titles: Option<crate::options::titles::TitlesConfig>,
323    /// Page range formatting (expanded, minimal, chicago).
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub page_range_format: Option<PageRangeFormat>,
326    /// Article-journal-specific bibliography policies.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub article_journal: Option<ArticleJournalBibliographyConfig>,
329    /// String to substitute for repeating authors.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub subsequent_author_substitute: Option<String>,
332    /// Rule for when to apply the substitute.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
335    /// Whether to use a hanging indent.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub hanging_indent: Option<bool>,
338    /// Suffix appended to each bibliography entry.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub entry_suffix: Option<String>,
341    /// Separator between bibliography components.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub separator: Option<String>,
344    /// Whether to suppress the trailing period after URLs/DOIs.
345    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
346    pub suppress_period_after_url: bool,
347    /// Force `entry-suffix` even when the entry ends in a URL (MLA).
348    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
349    pub entry_suffix_after_url: bool,
350    /// Force `entry-suffix` even when the entry ends in a DOI (IEEE).
351    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
352    pub entry_suffix_after_doi: bool,
353    /// Configuration for compound numeric bibliography entries.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
356    /// Partitioning policy for multilingual bibliography sorting and sections.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
359    /// Policy for reference-work entries (dictionary/encyclopedia and
360    /// dictionary-shaped chapters) with no visible author. See
361    /// [`AnonymousEntriesMode`].
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub anonymous_entries: Option<AnonymousEntriesMode>,
364    /// Hyperlink configuration.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub links: Option<LinksConfig>,
367    /// Whether to place periods/commas inside quotation marks.
368    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
369    pub punctuation_in_quote: bool,
370    /// Delimiter between volume/issue and pages for serial sources.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
373    /// Bibliography label mode for label-bearing styles.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub label_mode: Option<BibliographyLabelMode>,
376    /// Label wrap policy applied to bibliography labels.
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub label_wrap: Option<BibliographyLabelWrap>,
379    /// Placement of issued dates within bibliography entries.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub date_position: Option<DatePosition>,
382    /// Terminator applied to primary-title bibliography components.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub title_terminator: Option<TitleTerminator>,
385    /// Repeated-author rendering mode for bibliography entries.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
388    /// Strip trailing periods from terms, labels, and abbreviated dates.
389    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
390    pub strip_periods: Option<bool>,
391    /// Custom user-defined fields for extensions.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub custom: Option<HashMap<String, serde_json::Value>>,
394    /// Forward-compat: captures unknown keys when an older engine reads a
395    /// style produced by a newer schema. Empty by default; treated as a
396    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
397    #[serde(
398        flatten,
399        default,
400        skip_serializing_if = "std::collections::BTreeMap::is_empty"
401    )]
402    #[cfg_attr(feature = "schema", schemars(skip))]
403    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
404}
405
406/// Document-level note marker placement rules.
407#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
408#[cfg_attr(feature = "schema", derive(JsonSchema))]
409#[serde(rename_all = "kebab-case")]
410pub struct NoteConfig {
411    /// Desired location of movable punctuation relative to closing quotation
412    /// marks when note markers are introduced.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub punctuation: Option<NoteQuotePlacement>,
415    /// Desired location of the note marker relative to closing quotation marks.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub number: Option<NoteNumberPlacement>,
418    /// Whether the note marker appears before or after the closest movable
419    /// punctuation mark.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub order: Option<NoteMarkerOrder>,
422    /// Forward-compat: captures unknown keys when an older engine reads a
423    /// style produced by a newer schema. Empty by default; treated as a
424    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
425    #[serde(
426        flatten,
427        default,
428        skip_serializing_if = "std::collections::BTreeMap::is_empty"
429    )]
430    #[cfg_attr(feature = "schema", schemars(skip))]
431    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
432}
433
434/// Controls where movable punctuation is placed relative to closing quotation marks.
435#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
436#[cfg_attr(feature = "schema", derive(JsonSchema))]
437#[serde(rename_all = "kebab-case")]
438pub enum NoteQuotePlacement {
439    /// Keep movable punctuation inside the closing quotation mark.
440    Inside,
441    /// Keep movable punctuation outside the closing quotation mark.
442    Outside,
443    /// Follow org-cite-style adaptive behavior: punctuation stays inside when
444    /// it is already flush with the closing quote, otherwise it is placed
445    /// outside.
446    #[default]
447    Adaptive,
448}
449
450/// Controls where a footnote number marker is placed relative to closing quotation marks.
451#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
452#[cfg_attr(feature = "schema", derive(JsonSchema))]
453#[serde(rename_all = "kebab-case")]
454pub enum NoteNumberPlacement {
455    /// Place the note marker inside the closing quotation mark.
456    Inside,
457    /// Place the note marker outside the closing quotation mark.
458    #[default]
459    Outside,
460    /// Place the note marker on the same side as the movable punctuation when
461    /// only one side has punctuation; otherwise default to outside.
462    Same,
463}
464
465/// Controls whether a note marker appears before or after adjacent movable punctuation.
466#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
467#[cfg_attr(feature = "schema", derive(JsonSchema))]
468#[serde(rename_all = "kebab-case")]
469pub enum NoteMarkerOrder {
470    /// Place the note marker before the closest movable punctuation mark.
471    Before,
472    /// Place the note marker after the closest movable punctuation mark.
473    #[default]
474    After,
475}
476
477/// Page range formatting options.
478#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
479#[cfg_attr(feature = "schema", derive(JsonSchema))]
480#[serde(rename_all = "kebab-case")]
481#[non_exhaustive]
482pub enum PageRangeFormat {
483    /// Full expansion: 321-328 → 321–328
484    #[default]
485    Expanded,
486    /// Minimal digits: 321-328 → 321–8
487    Minimal,
488    /// Minimal two digits: 321-328 → 321–28
489    MinimalTwo,
490    /// Chicago Manual of Style 15th ed rules
491    Chicago,
492    /// Chicago Manual of Style 16th/17th ed rules
493    Chicago16,
494}
495
496pub mod titles;
497
498pub use title_class::{
499    TitleCategory, classified_ref_types, container_title_category, parent_serial_title_category,
500    title_category,
501};
502pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
503
504/// Structured link options.
505#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "schema", derive(JsonSchema))]
507#[serde(rename_all = "kebab-case")]
508pub struct LinksConfig {
509    /// Link value to the item's DOI.
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub doi: Option<bool>,
512    /// Link value to the item's URL.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub url: Option<bool>,
515    /// The target for the link (url, doi, etc.).
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub target: Option<LinkTarget>,
518    /// What text should be hyperlinked (title, url, etc.).
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub anchor: Option<LinkAnchor>,
521}
522
523/// Link target options.
524#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
525#[cfg_attr(feature = "schema", derive(JsonSchema))]
526#[serde(rename_all = "kebab-case")]
527pub enum LinkTarget {
528    Url,
529    Doi,
530    UrlOrDoi,
531    Pubmed,
532    Pmcid,
533}
534
535/// Link anchor options.
536#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
537#[cfg_attr(feature = "schema", derive(JsonSchema))]
538#[serde(rename_all = "kebab-case")]
539pub enum LinkAnchor {
540    /// Link the title component.
541    Title,
542    /// Link the URL component itself.
543    Url,
544    /// Link the DOI component itself.
545    Doi,
546    /// Link the specific component this config is attached to.
547    Component,
548    /// Link the entire bibliography entry.
549    Entry,
550}
551
552impl Config {
553    /// Effective processing mode, falling back to the default when unset.
554    ///
555    /// Centralizes the `processing: None` fallback so every consumer resolves
556    /// the same default (`Processing::default()`) instead of hardcoding it.
557    pub fn effective_processing(&self) -> Processing {
558        self.processing.clone().unwrap_or_default()
559    }
560
561    /// Merge another config into this one, with `other` taking precedence.
562    ///
563    /// Used for combining global options with context-specific (citation/bibliography) options.
564    /// Only non-None fields from `other` override fields in `self`.
565    pub fn merge(&mut self, other: &Config) {
566        crate::merge_options!(
567            self,
568            other,
569            processing,
570            locale_override,
571            localize,
572            multilingual,
573            dates,
574            titles,
575            locators,
576            page_range_format,
577            page_range_delimiter,
578            links,
579            volume_pages_delimiter,
580            locale_override,
581            strip_periods,
582            notes,
583            integral_name_memory,
584            org_abbreviation_memory,
585            custom,
586        );
587
588        if let Some(other_substitute) = &other.substitute {
589            if let Some(this_substitute) = &self.substitute {
590                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
591            } else {
592                self.substitute = Some(other_substitute.clone());
593            }
594        }
595
596        if let Some(other_contributors) = &other.contributors {
597            if let Some(this_contributors) = &mut self.contributors {
598                this_contributors.merge(other_contributors);
599            } else {
600                self.contributors = Some(other_contributors.clone());
601            }
602        }
603
604        if other.punctuation_in_quote {
605            self.punctuation_in_quote = true;
606        }
607    }
608
609    /// Create a merged config from base and override, returning a new Config.
610    ///
611    /// Convenience method that clones base, then merges override into it.
612    pub fn merged(base: &Config, override_config: &Config) -> Config {
613        let mut result = base.clone();
614        result.merge(override_config);
615        result
616    }
617}
618
619impl CitationOptions {
620    /// Convert citation-local overrides into the runtime config shape.
621    #[must_use]
622    pub fn to_config(&self) -> Config {
623        Config {
624            substitute: self.substitute.clone(),
625            processing: self.processing.clone(),
626            locale_override: None,
627            localize: self.localize.clone(),
628            multilingual: self.multilingual.clone(),
629            contributors: self.contributors.clone(),
630            dates: self.dates.clone(),
631            titles: self.titles.clone(),
632            locators: self.locators.clone(),
633            page_range_format: self.page_range_format.clone(),
634            page_range_delimiter: None,
635            links: self.links.clone(),
636            punctuation_in_quote: self.punctuation_in_quote,
637            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
638            strip_periods: self.strip_periods,
639            notes: self.notes.clone(),
640            integral_name_memory: self.integral_name_memory.clone(),
641            org_abbreviation_memory: self.org_abbreviation_memory.clone(),
642            custom: self.custom.clone(),
643            unknown_fields: std::collections::BTreeMap::new(),
644        }
645    }
646
647    /// Merge citation-local overrides over a base config.
648    #[must_use]
649    pub fn merged_with(&self, base: &Config) -> Config {
650        Config::merged(base, &self.to_config())
651    }
652
653    /// Merge `other` into `self`, with `other` taking precedence for each field.
654    pub fn merge(&mut self, other: &CitationOptions) {
655        crate::merge_options!(
656            self,
657            other,
658            processing,
659            localize,
660            multilingual,
661            dates,
662            titles,
663            locators,
664            page_range_format,
665            links,
666            volume_pages_delimiter,
667            strip_periods,
668            notes,
669            integral_name_memory,
670            org_abbreviation_memory,
671            label_wrap,
672            group_delimiter,
673            custom,
674        );
675
676        if let Some(other_substitute) = &other.substitute {
677            if let Some(this_substitute) = &self.substitute {
678                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
679            } else {
680                self.substitute = Some(other_substitute.clone());
681            }
682        }
683
684        if let Some(other_contributors) = &other.contributors {
685            if let Some(this_contributors) = &mut self.contributors {
686                this_contributors.merge(other_contributors);
687            } else {
688                self.contributors = Some(other_contributors.clone());
689            }
690        }
691
692        if other.punctuation_in_quote {
693            self.punctuation_in_quote = true;
694        }
695    }
696}
697
698impl BibliographyOptions {
699    /// Convert bibliography-entry overrides into bibliography-only runtime config.
700    #[must_use]
701    pub fn to_bibliography_config(&self) -> BibliographyConfig {
702        BibliographyConfig {
703            article_journal: self.article_journal.clone(),
704            subsequent_author_substitute: self.subsequent_author_substitute.clone(),
705            subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
706            hanging_indent: self.hanging_indent,
707            entry_suffix: self.entry_suffix.clone(),
708            separator: self.separator.clone(),
709            suppress_period_after_url: self.suppress_period_after_url,
710            entry_suffix_after_url: self.entry_suffix_after_url,
711            entry_suffix_after_doi: self.entry_suffix_after_doi,
712            custom: None,
713            compound_numeric: self.compound_numeric.clone(),
714            sort_partitioning: self.sort_partitioning.clone(),
715            anonymous_entries: self.anonymous_entries,
716            unknown_fields: std::collections::BTreeMap::new(),
717        }
718    }
719
720    /// Convert bibliography-local overrides into the runtime config shape.
721    #[must_use]
722    pub fn to_config(&self) -> Config {
723        Config {
724            substitute: self.substitute.clone(),
725            processing: self.processing.clone(),
726            locale_override: None,
727            localize: self.localize.clone(),
728            multilingual: self.multilingual.clone(),
729            contributors: self.contributors.clone(),
730            dates: self.dates.clone(),
731            titles: self.titles.clone(),
732            locators: None,
733            page_range_format: self.page_range_format.clone(),
734            page_range_delimiter: None,
735            links: self.links.clone(),
736            punctuation_in_quote: self.punctuation_in_quote,
737            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
738            strip_periods: self.strip_periods,
739            notes: None,
740            integral_name_memory: None,
741            org_abbreviation_memory: None,
742            custom: self.custom.clone(),
743            unknown_fields: std::collections::BTreeMap::new(),
744        }
745    }
746
747    /// Merge bibliography-local overrides over a base config.
748    #[must_use]
749    pub fn merged_with(&self, base: &Config) -> Config {
750        Config::merged(base, &self.to_config())
751    }
752
753    /// Merge `other` into `self`, with `other` taking precedence for each field.
754    pub fn merge(&mut self, other: &BibliographyOptions) {
755        crate::merge_options!(
756            self,
757            other,
758            processing,
759            localize,
760            multilingual,
761            dates,
762            titles,
763            page_range_format,
764            links,
765            volume_pages_delimiter,
766            strip_periods,
767            article_journal,
768            subsequent_author_substitute,
769            subsequent_author_substitute_rule,
770            hanging_indent,
771            entry_suffix,
772            separator,
773            compound_numeric,
774            sort_partitioning,
775            anonymous_entries,
776            label_mode,
777            label_wrap,
778            date_position,
779            title_terminator,
780            repeated_author_rendering,
781            custom,
782        );
783
784        self.merge_shared_fields(other);
785    }
786
787    fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
788        if let Some(other_substitute) = &other.substitute {
789            if let Some(this_substitute) = &self.substitute {
790                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
791            } else {
792                self.substitute = Some(other_substitute.clone());
793            }
794        }
795
796        if let Some(other_contributors) = &other.contributors {
797            if let Some(this_contributors) = &mut self.contributors {
798                this_contributors.merge(other_contributors);
799            } else {
800                self.contributors = Some(other_contributors.clone());
801            }
802        }
803
804        if other.punctuation_in_quote {
805            self.punctuation_in_quote = true;
806        }
807        if other.suppress_period_after_url {
808            self.suppress_period_after_url = true;
809        }
810        if other.entry_suffix_after_url {
811            self.entry_suffix_after_url = true;
812        }
813        if other.entry_suffix_after_doi {
814            self.entry_suffix_after_doi = true;
815        }
816
817        for (key, value) in &other.unknown_fields {
818            self.unknown_fields.insert(key.clone(), value.clone());
819        }
820    }
821}
822
823/// Deserialize contributor config from either a preset name or explicit config.
824fn deserialize_contributor_config<'de, D>(
825    deserializer: D,
826) -> Result<Option<ContributorConfig>, D::Error>
827where
828    D: serde::Deserializer<'de>,
829{
830    let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
831    Ok(value.map(|entry| entry.resolve()))
832}
833
834/// Deserialize date config from either a preset name or explicit config.
835fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
836where
837    D: serde::Deserializer<'de>,
838{
839    let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
840    Ok(value.map(|entry| entry.resolve()))
841}
842
843/// Deserialize titles config from either a preset name or explicit config.
844fn deserialize_titles_config<'de, D>(
845    deserializer: D,
846) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
847where
848    D: serde::Deserializer<'de>,
849{
850    let value: Option<crate::options::titles::TitlesConfigEntry> =
851        Option::deserialize(deserializer)?;
852    Ok(value.map(|entry| entry.resolve()))
853}
854
855/// Deserialize locator config from either a preset name or explicit config.
856fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
857where
858    D: serde::Deserializer<'de>,
859{
860    let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
861    Ok(value.map(|entry| entry.resolve()))
862}
863
864/// Deserialize multilingual config from either a preset name or an explicit block.
865fn deserialize_multilingual_config<'de, D>(
866    deserializer: D,
867) -> Result<Option<MultilingualConfig>, D::Error>
868where
869    D: serde::Deserializer<'de>,
870{
871    let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
872    Ok(value.map(|entry| entry.resolve()))
873}
874
875impl<'de> Deserialize<'de> for Config {
876    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
877    where
878        D: serde::Deserializer<'de>,
879    {
880        #[derive(Deserialize)]
881        #[serde(rename_all = "kebab-case")]
882        struct ConfigWire {
883            #[serde(skip_serializing_if = "Option::is_none")]
884            substitute: Option<SubstituteConfig>,
885            #[serde(skip_serializing_if = "Option::is_none")]
886            processing: Option<Processing>,
887            #[serde(skip_serializing_if = "Option::is_none")]
888            locale_override: Option<String>,
889            #[serde(skip_serializing_if = "Option::is_none")]
890            localize: Option<Localize>,
891            #[serde(
892                skip_serializing_if = "Option::is_none",
893                deserialize_with = "deserialize_multilingual_config",
894                default
895            )]
896            multilingual: Option<MultilingualConfig>,
897            #[serde(
898                skip_serializing_if = "Option::is_none",
899                deserialize_with = "deserialize_contributor_config",
900                default
901            )]
902            contributors: Option<ContributorConfig>,
903            #[serde(
904                skip_serializing_if = "Option::is_none",
905                deserialize_with = "deserialize_date_config",
906                default
907            )]
908            dates: Option<DateConfig>,
909            #[serde(
910                skip_serializing_if = "Option::is_none",
911                deserialize_with = "deserialize_titles_config",
912                default
913            )]
914            titles: Option<crate::options::titles::TitlesConfig>,
915            #[serde(
916                skip_serializing_if = "Option::is_none",
917                deserialize_with = "deserialize_locator_config",
918                default
919            )]
920            locators: Option<LocatorConfig>,
921            #[serde(skip_serializing_if = "Option::is_none")]
922            page_range_format: Option<PageRangeFormat>,
923            #[serde(skip_serializing_if = "Option::is_none")]
924            page_range_delimiter: Option<String>,
925            #[serde(skip_serializing_if = "Option::is_none")]
926            links: Option<LinksConfig>,
927            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
928            punctuation_in_quote: bool,
929            #[serde(skip_serializing_if = "Option::is_none")]
930            volume_pages_delimiter: Option<DelimiterPunctuation>,
931            #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
932            strip_periods: Option<bool>,
933            #[serde(skip_serializing_if = "Option::is_none")]
934            notes: Option<NoteConfig>,
935            #[serde(skip_serializing_if = "Option::is_none")]
936            integral_name_memory: Option<IntegralNameMemoryConfig>,
937            #[serde(skip_serializing_if = "Option::is_none")]
938            org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
939            #[serde(default)]
940            profile: Option<serde_yaml::Value>,
941            #[serde(skip_serializing_if = "Option::is_none")]
942            custom: Option<HashMap<String, serde_json::Value>>,
943            #[serde(flatten)]
944            unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
945        }
946
947        let wire = ConfigWire::deserialize(deserializer)?;
948        if wire.profile.is_some() {
949            return Err(serde::de::Error::custom(
950                "`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`",
951            ));
952        }
953
954        Ok(Self {
955            substitute: wire.substitute,
956            processing: wire.processing,
957            locale_override: wire.locale_override,
958            localize: wire.localize,
959            multilingual: wire.multilingual,
960            contributors: wire.contributors,
961            dates: wire.dates,
962            titles: wire.titles,
963            locators: wire.locators,
964            page_range_format: wire.page_range_format,
965            page_range_delimiter: wire.page_range_delimiter,
966            links: wire.links,
967            punctuation_in_quote: wire.punctuation_in_quote,
968            volume_pages_delimiter: wire.volume_pages_delimiter,
969            strip_periods: wire.strip_periods,
970            notes: wire.notes,
971            integral_name_memory: wire.integral_name_memory,
972            org_abbreviation_memory: wire.org_abbreviation_memory,
973            custom: wire.custom,
974            unknown_fields: wire.unknown_fields,
975        })
976    }
977}
978
979#[cfg(test)]
980#[allow(
981    clippy::unwrap_used,
982    clippy::expect_used,
983    clippy::panic,
984    clippy::indexing_slicing,
985    clippy::todo,
986    clippy::unimplemented,
987    clippy::unreachable,
988    clippy::get_unwrap,
989    reason = "Panicking is acceptable and often desired in tests."
990)]
991mod tests {
992    use super::*;
993
994    #[test]
995    fn test_config_default() {
996        let config = Config::default();
997        assert!(config.substitute.is_none());
998        assert!(config.processing.is_none());
999    }
1000
1001    #[test]
1002    fn test_author_date_processing() {
1003        let processing = Processing::AuthorDate;
1004        let config = processing.config();
1005        let disambiguate = config.disambiguate.unwrap();
1006        assert!(disambiguate.year_suffix);
1007        assert!(!disambiguate.names);
1008        assert!(!disambiguate.add_givenname);
1009        assert_eq!(
1010            processing.default_bibliography_sort(),
1011            Some(crate::presets::SortPreset::AuthorDateTitle)
1012        );
1013        assert_eq!(
1014            config.sort,
1015            Some(SortEntry::Preset(
1016                crate::presets::SortPreset::AuthorDateTitle
1017            ))
1018        );
1019    }
1020
1021    #[test]
1022    fn test_processing_default_bibliography_sorts() {
1023        assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
1024        assert_eq!(
1025            Processing::Note.default_bibliography_sort(),
1026            Some(crate::presets::SortPreset::AuthorTitleDate)
1027        );
1028        assert_eq!(
1029            Processing::Label(LabelConfig::default()).default_bibliography_sort(),
1030            Some(crate::presets::SortPreset::AuthorDateTitle)
1031        );
1032    }
1033
1034    #[test]
1035    fn test_processing_default_citation_sort_policy_is_explicit_only() {
1036        assert_eq!(
1037            Processing::AuthorDate.default_citation_sort_policy(),
1038            CitationSortPolicy::ExplicitOnly
1039        );
1040        assert_eq!(
1041            Processing::Note.default_citation_sort_policy(),
1042            CitationSortPolicy::ExplicitOnly
1043        );
1044    }
1045
1046    #[test]
1047    fn test_substitute_default() {
1048        let sub = Substitute::default();
1049        assert_eq!(sub.template.len(), 3);
1050    }
1051
1052    #[test]
1053    fn test_config_yaml_roundtrip() {
1054        let yaml = r#"
1055substitute:
1056  contributor-role-form: short
1057  template:
1058    - editor
1059    - title
1060processing: author-date
1061contributors:
1062  display-as-sort: first
1063  and: symbol
1064"#;
1065        let config: Config = serde_yaml::from_str(yaml).unwrap();
1066        assert!(config.substitute.is_some());
1067        assert_eq!(config.processing, Some(Processing::AuthorDate));
1068        assert_eq!(
1069            config.contributors.as_ref().unwrap().and,
1070            Some(AndOptions::Symbol)
1071        );
1072    }
1073
1074    #[test]
1075    fn test_contributor_config_preset() {
1076        // Test that a preset name parses and resolves correctly for contributors
1077        let yaml = r#"contributors: apa"#;
1078        let config: Config = serde_yaml::from_str(yaml).unwrap();
1079        let contributors = config.contributors.unwrap();
1080        assert_eq!(contributors.and, Some(AndOptions::Symbol));
1081        assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1082    }
1083
1084    #[test]
1085    fn test_role_label_presets_parse_and_resolve_precedence() {
1086        let yaml = r#"
1087contributors:
1088  role:
1089    preset: short-suffix
1090    roles:
1091      editor:
1092        preset: long-suffix
1093"#;
1094        let config: Config = serde_yaml::from_str(yaml).unwrap();
1095        let contributors = config.contributors.unwrap();
1096
1097        assert_eq!(
1098            contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1099            Some(RoleLabelPreset::LongSuffix)
1100        );
1101        assert_eq!(
1102            contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1103            Some(RoleLabelPreset::ShortSuffix)
1104        );
1105
1106        // Scalar shorthand form — must parse identically for preset-only case
1107        let yaml_scalar = r#"
1108contributors:
1109  role: short-suffix
1110"#;
1111        let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1112        let contributors2 = config2.contributors.unwrap();
1113
1114        assert_eq!(
1115            contributors2
1116                .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1117            Some(RoleLabelPreset::ShortSuffix)
1118        );
1119    }
1120
1121    #[test]
1122    fn test_role_specific_name_order_override_is_available() {
1123        let yaml = r#"
1124contributors:
1125  role:
1126    roles:
1127      translator:
1128        name-order: given-first
1129"#;
1130        let config: Config = serde_yaml::from_str(yaml).unwrap();
1131        let contributors = config.contributors.unwrap();
1132
1133        assert_eq!(
1134            contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1135            Some(&crate::template::NameOrder::GivenFirst)
1136        );
1137    }
1138
1139    #[test]
1140    fn test_date_config_preset() {
1141        // Test that a preset name parses and resolves correctly for dates
1142        let yaml = r#"dates: long"#;
1143        let config: Config = serde_yaml::from_str(yaml).unwrap();
1144        let dates = config.dates.unwrap();
1145        assert_eq!(dates.month, MonthFormat::Long);
1146    }
1147
1148    #[test]
1149    fn test_titles_config_preset() {
1150        // Test that a preset name parses and resolves correctly for titles
1151        let yaml = r#"titles: chicago"#;
1152        let config: Config = serde_yaml::from_str(yaml).unwrap();
1153        let titles = config.titles.unwrap();
1154        assert_eq!(titles.component.unwrap().quote, Some(true));
1155        assert_eq!(titles.monograph.unwrap().emph, Some(true));
1156    }
1157
1158    #[test]
1159    fn test_substitute_config_preset() {
1160        // Test that a preset name parses correctly
1161        let yaml = r#"substitute: standard"#;
1162        let config: Config = serde_yaml::from_str(yaml).unwrap();
1163        assert!(config.substitute.is_some());
1164        let resolved = config.substitute.unwrap().resolve();
1165        assert_eq!(resolved.template.len(), 3);
1166        assert_eq!(resolved.template[0], SubstituteKey::Editor);
1167    }
1168
1169    #[test]
1170    fn test_substitute_config_explicit() {
1171        // Test that explicit config still works
1172        let yaml = r#"
1173substitute:
1174  template:
1175    - title
1176    - editor
1177"#;
1178        let config: Config = serde_yaml::from_str(yaml).unwrap();
1179        let resolved = config.substitute.unwrap().resolve();
1180        assert_eq!(resolved.template[0], SubstituteKey::Title);
1181        assert_eq!(resolved.template[1], SubstituteKey::Editor);
1182    }
1183
1184    #[test]
1185    fn test_config_merge_precedence() {
1186        // Base config with global options
1187        let base_yaml = r#"
1188processing: author-date
1189locale-override: en-US-base
1190contributors:
1191  display-as-sort: first
1192  and: symbol
1193"#;
1194        let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1195
1196        // Override config (e.g., citation-specific options)
1197        let override_yaml = r#"
1198contributors:
1199  and: text
1200locale-override: en-US-chicago
1201"#;
1202        let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1203
1204        // Merge: override takes precedence
1205        base.merge(&override_config);
1206
1207        // Processing should remain from base (not overridden)
1208        assert_eq!(base.processing, Some(Processing::AuthorDate));
1209        assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1210
1211        // Contributors should be merged with override values taking precedence
1212        assert_eq!(
1213            base.contributors.as_ref().unwrap().and,
1214            Some(AndOptions::Text)
1215        );
1216    }
1217
1218    #[test]
1219    fn test_config_deserializes_locale_override() {
1220        let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1221        assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1222    }
1223
1224    #[test]
1225    fn test_config_merged_convenience() {
1226        let base = Config {
1227            processing: Some(Processing::AuthorDate),
1228            ..Default::default()
1229        };
1230        let override_config = Config {
1231            punctuation_in_quote: true,
1232            ..Default::default()
1233        };
1234
1235        let merged = Config::merged(&base, &override_config);
1236
1237        // Both fields preserved
1238        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1239        assert!(merged.punctuation_in_quote);
1240    }
1241
1242    #[test]
1243    fn test_citation_options_merge_overrides_citation_fields_only() {
1244        let base = Config {
1245            processing: Some(Processing::AuthorDate),
1246            ..Default::default()
1247        };
1248
1249        let overrides = CitationOptions {
1250            strip_periods: Some(true),
1251            locators: Some(LocatorConfig::default()),
1252            ..Default::default()
1253        };
1254
1255        let merged = overrides.merged_with(&base);
1256        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1257        assert!(merged.strip_periods.unwrap_or(false));
1258        assert!(merged.locators.is_some());
1259    }
1260
1261    #[test]
1262    fn test_bibliography_options_merge_projects_shared_fields_only() {
1263        let base = Config {
1264            processing: Some(Processing::AuthorDate),
1265            ..Default::default()
1266        };
1267
1268        let overrides = BibliographyOptions {
1269            entry_suffix: Some(".".to_string()),
1270            separator: Some(", ".to_string()),
1271            suppress_period_after_url: true,
1272            ..Default::default()
1273        };
1274
1275        let merged = overrides.merged_with(&base);
1276        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1277        assert!(merged.locators.is_none());
1278        assert!(merged.notes.is_none());
1279        let bibliography = overrides.to_bibliography_config();
1280        assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1281        assert_eq!(bibliography.separator.as_deref(), Some(", "));
1282        assert!(bibliography.suppress_period_after_url);
1283    }
1284
1285    #[test]
1286    fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1287        let base = Config {
1288            processing: Some(Processing::AuthorDate),
1289            ..Default::default()
1290        };
1291
1292        let overrides = BibliographyOptions {
1293            contributors: Some(ContributorConfig::default()),
1294            ..Default::default()
1295        };
1296
1297        let merged = overrides.merged_with(&base);
1298        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1299        assert!(merged.contributors.is_some());
1300    }
1301
1302    #[test]
1303    fn citation_options_captures_unknown_fields_for_forward_compat() {
1304        let yaml = "future-key: true\n";
1305        let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1306        assert!(opts.unknown_fields.contains_key("future-key"));
1307    }
1308
1309    #[test]
1310    fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1311        let yaml = "future-key: true\n";
1312        let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1313        assert!(opts.unknown_fields.contains_key("future-key"));
1314    }
1315
1316    #[test]
1317    fn note_config_captures_unknown_fields_for_forward_compat() {
1318        let yaml = "punctuation: inside\nfuture-key: true\n";
1319        let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1320        assert!(cfg.unknown_fields.contains_key("future-key"));
1321        assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1322    }
1323
1324    #[test]
1325    fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1326        // `romanized-translated` resolves to Combined title mode (romanized [translated])
1327        let yaml = r#"multilingual: romanized-translated"#;
1328        let config: Config = serde_yaml::from_str(yaml).unwrap();
1329        let ml = config.multilingual.unwrap();
1330        assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1331        assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1332        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1333    }
1334
1335    #[test]
1336    fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1337        // `romanized-only` resolves to Transliterated title mode (no translation)
1338        let yaml = r#"multilingual: romanized-only"#;
1339        let config: Config = serde_yaml::from_str(yaml).unwrap();
1340        let ml = config.multilingual.unwrap();
1341        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1342        assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1343        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1344    }
1345
1346    #[test]
1347    fn test_multilingual_preset_romanized_script_translated_parses_and_resolves() {
1348        // `romanized-script-translated` resolves to a Pattern title mode (romanized original-script [translated])
1349        // and Pattern name mode (romanized original-script), with Latn script and CJK native ordering.
1350        use crate::options::multilingual::{MultilingualSegment, MultilingualView, SegmentWrap};
1351        let yaml = r#"multilingual: romanized-script-translated"#;
1352        let config: Config = serde_yaml::from_str(yaml).unwrap();
1353        let ml = config.multilingual.unwrap();
1354        assert_eq!(
1355            ml.title_mode,
1356            Some(MultilingualMode::Pattern(vec![
1357                MultilingualSegment {
1358                    view: MultilingualView::Transliterated,
1359                    wrap: SegmentWrap::None,
1360                },
1361                MultilingualSegment {
1362                    view: MultilingualView::OriginalScript,
1363                    wrap: SegmentWrap::None,
1364                },
1365                MultilingualSegment {
1366                    view: MultilingualView::Translated,
1367                    wrap: SegmentWrap::Brackets,
1368                },
1369            ]))
1370        );
1371        assert_eq!(
1372            ml.name_mode,
1373            Some(MultilingualMode::Pattern(vec![
1374                MultilingualSegment {
1375                    view: MultilingualView::Transliterated,
1376                    wrap: SegmentWrap::None,
1377                },
1378                MultilingualSegment {
1379                    view: MultilingualView::OriginalScript,
1380                    wrap: SegmentWrap::None,
1381                },
1382            ]))
1383        );
1384        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1385        assert!(ml.scripts.get("Han").is_some_and(|s| s.use_native_ordering));
1386        assert!(
1387            ml.scripts
1388                .get("Hangul")
1389                .is_some_and(|s| s.use_native_ordering)
1390        );
1391    }
1392
1393    #[test]
1394    fn test_multilingual_explicit_block_transliterated_roundtrips() {
1395        // Verify a Transliterated explicit block survives YAML serialize→deserialize.
1396        let yaml = r#"
1397multilingual:
1398  title-mode: transliterated
1399  preferred-script: Latn
1400"#;
1401        let config: Config = serde_yaml::from_str(yaml).unwrap();
1402        let ml = config.multilingual.clone().unwrap();
1403        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1404        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1405
1406        let yaml2 = serde_yaml::to_string(&config).unwrap();
1407        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1408        assert_eq!(config2.multilingual, config.multilingual);
1409    }
1410
1411    #[test]
1412    fn test_multilingual_pattern_block_roundtrips() {
1413        // Exercises the externally-tagged `{pattern: [...]}` YAML path — the case that
1414        // breaks under serde_yaml's untagged+enum limitation without the custom Deserialize.
1415        let yaml = r#"
1416multilingual:
1417  title-mode:
1418    pattern:
1419      - view: original-script
1420      - view: translated
1421        wrap: brackets
1422"#;
1423        let config: Config = serde_yaml::from_str(yaml).unwrap();
1424        let ml = config.multilingual.clone().unwrap();
1425        assert!(
1426            matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1427            "expected Pattern mode, got {:?}",
1428            ml.title_mode
1429        );
1430
1431        let yaml2 = serde_yaml::to_string(&config).unwrap();
1432        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1433        assert_eq!(config2.multilingual, config.multilingual);
1434    }
1435}