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