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, 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    /// Merge another config into this one, with `other` taking precedence.
530    ///
531    /// Used for combining global options with context-specific (citation/bibliography) options.
532    /// Only non-None fields from `other` override fields in `self`.
533    pub fn merge(&mut self, other: &Config) {
534        crate::merge_options!(
535            self,
536            other,
537            processing,
538            locale_override,
539            localize,
540            multilingual,
541            dates,
542            titles,
543            locators,
544            page_range_format,
545            links,
546            volume_pages_delimiter,
547            locale_override,
548            strip_periods,
549            notes,
550            integral_name_memory,
551            org_abbreviation_memory,
552            custom,
553        );
554
555        if let Some(other_substitute) = &other.substitute {
556            if let Some(this_substitute) = &self.substitute {
557                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
558            } else {
559                self.substitute = Some(other_substitute.clone());
560            }
561        }
562
563        if let Some(other_contributors) = &other.contributors {
564            if let Some(this_contributors) = &mut self.contributors {
565                this_contributors.merge(other_contributors);
566            } else {
567                self.contributors = Some(other_contributors.clone());
568            }
569        }
570
571        if other.punctuation_in_quote {
572            self.punctuation_in_quote = true;
573        }
574    }
575
576    /// Create a merged config from base and override, returning a new Config.
577    ///
578    /// Convenience method that clones base, then merges override into it.
579    pub fn merged(base: &Config, override_config: &Config) -> Config {
580        let mut result = base.clone();
581        result.merge(override_config);
582        result
583    }
584}
585
586impl CitationOptions {
587    /// Convert citation-local overrides into the runtime config shape.
588    #[must_use]
589    pub fn to_config(&self) -> Config {
590        Config {
591            substitute: self.substitute.clone(),
592            processing: self.processing.clone(),
593            locale_override: None,
594            localize: self.localize.clone(),
595            multilingual: self.multilingual.clone(),
596            contributors: self.contributors.clone(),
597            dates: self.dates.clone(),
598            titles: self.titles.clone(),
599            locators: self.locators.clone(),
600            page_range_format: self.page_range_format.clone(),
601            links: self.links.clone(),
602            punctuation_in_quote: self.punctuation_in_quote,
603            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
604            strip_periods: self.strip_periods,
605            notes: self.notes.clone(),
606            integral_name_memory: self.integral_name_memory.clone(),
607            org_abbreviation_memory: self.org_abbreviation_memory.clone(),
608            custom: self.custom.clone(),
609            unknown_fields: std::collections::BTreeMap::new(),
610        }
611    }
612
613    /// Merge citation-local overrides over a base config.
614    #[must_use]
615    pub fn merged_with(&self, base: &Config) -> Config {
616        Config::merged(base, &self.to_config())
617    }
618
619    /// Merge `other` into `self`, with `other` taking precedence for each field.
620    pub fn merge(&mut self, other: &CitationOptions) {
621        crate::merge_options!(
622            self,
623            other,
624            processing,
625            localize,
626            multilingual,
627            dates,
628            titles,
629            locators,
630            page_range_format,
631            links,
632            volume_pages_delimiter,
633            strip_periods,
634            notes,
635            integral_name_memory,
636            org_abbreviation_memory,
637            label_wrap,
638            group_delimiter,
639            custom,
640        );
641
642        if let Some(other_substitute) = &other.substitute {
643            if let Some(this_substitute) = &self.substitute {
644                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
645            } else {
646                self.substitute = Some(other_substitute.clone());
647            }
648        }
649
650        if let Some(other_contributors) = &other.contributors {
651            if let Some(this_contributors) = &mut self.contributors {
652                this_contributors.merge(other_contributors);
653            } else {
654                self.contributors = Some(other_contributors.clone());
655            }
656        }
657
658        if other.punctuation_in_quote {
659            self.punctuation_in_quote = true;
660        }
661    }
662}
663
664impl BibliographyOptions {
665    /// Convert bibliography-entry overrides into bibliography-only runtime config.
666    #[must_use]
667    pub fn to_bibliography_config(&self) -> BibliographyConfig {
668        BibliographyConfig {
669            article_journal: self.article_journal.clone(),
670            subsequent_author_substitute: self.subsequent_author_substitute.clone(),
671            subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
672            hanging_indent: self.hanging_indent,
673            entry_suffix: self.entry_suffix.clone(),
674            separator: self.separator.clone(),
675            suppress_period_after_url: self.suppress_period_after_url,
676            custom: None,
677            compound_numeric: self.compound_numeric.clone(),
678            sort_partitioning: self.sort_partitioning.clone(),
679            unknown_fields: std::collections::BTreeMap::new(),
680        }
681    }
682
683    /// Convert bibliography-local overrides into the runtime config shape.
684    #[must_use]
685    pub fn to_config(&self) -> Config {
686        Config {
687            substitute: self.substitute.clone(),
688            processing: self.processing.clone(),
689            locale_override: None,
690            localize: self.localize.clone(),
691            multilingual: self.multilingual.clone(),
692            contributors: self.contributors.clone(),
693            dates: self.dates.clone(),
694            titles: self.titles.clone(),
695            locators: None,
696            page_range_format: self.page_range_format.clone(),
697            links: self.links.clone(),
698            punctuation_in_quote: self.punctuation_in_quote,
699            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
700            strip_periods: self.strip_periods,
701            notes: None,
702            integral_name_memory: None,
703            org_abbreviation_memory: None,
704            custom: self.custom.clone(),
705            unknown_fields: std::collections::BTreeMap::new(),
706        }
707    }
708
709    /// Merge bibliography-local overrides over a base config.
710    #[must_use]
711    pub fn merged_with(&self, base: &Config) -> Config {
712        Config::merged(base, &self.to_config())
713    }
714
715    /// Merge `other` into `self`, with `other` taking precedence for each field.
716    pub fn merge(&mut self, other: &BibliographyOptions) {
717        crate::merge_options!(
718            self,
719            other,
720            processing,
721            localize,
722            multilingual,
723            dates,
724            titles,
725            page_range_format,
726            links,
727            volume_pages_delimiter,
728            strip_periods,
729            article_journal,
730            subsequent_author_substitute,
731            subsequent_author_substitute_rule,
732            hanging_indent,
733            entry_suffix,
734            separator,
735            compound_numeric,
736            sort_partitioning,
737            label_mode,
738            label_wrap,
739            date_position,
740            title_terminator,
741            repeated_author_rendering,
742            custom,
743        );
744
745        self.merge_shared_fields(other);
746    }
747
748    fn merge_shared_fields(&mut self, other: &BibliographyOptions) {
749        if let Some(other_substitute) = &other.substitute {
750            if let Some(this_substitute) = &self.substitute {
751                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
752            } else {
753                self.substitute = Some(other_substitute.clone());
754            }
755        }
756
757        if let Some(other_contributors) = &other.contributors {
758            if let Some(this_contributors) = &mut self.contributors {
759                this_contributors.merge(other_contributors);
760            } else {
761                self.contributors = Some(other_contributors.clone());
762            }
763        }
764
765        if other.punctuation_in_quote {
766            self.punctuation_in_quote = true;
767        }
768        if other.suppress_period_after_url {
769            self.suppress_period_after_url = true;
770        }
771
772        for (key, value) in &other.unknown_fields {
773            self.unknown_fields.insert(key.clone(), value.clone());
774        }
775    }
776}
777
778/// Deserialize contributor config from either a preset name or explicit config.
779fn deserialize_contributor_config<'de, D>(
780    deserializer: D,
781) -> Result<Option<ContributorConfig>, D::Error>
782where
783    D: serde::Deserializer<'de>,
784{
785    let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
786    Ok(value.map(|entry| entry.resolve()))
787}
788
789/// Deserialize date config from either a preset name or explicit config.
790fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
791where
792    D: serde::Deserializer<'de>,
793{
794    let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
795    Ok(value.map(|entry| entry.resolve()))
796}
797
798/// Deserialize titles config from either a preset name or explicit config.
799fn deserialize_titles_config<'de, D>(
800    deserializer: D,
801) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
802where
803    D: serde::Deserializer<'de>,
804{
805    let value: Option<crate::options::titles::TitlesConfigEntry> =
806        Option::deserialize(deserializer)?;
807    Ok(value.map(|entry| entry.resolve()))
808}
809
810/// Deserialize locator config from either a preset name or explicit config.
811fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
812where
813    D: serde::Deserializer<'de>,
814{
815    let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
816    Ok(value.map(|entry| entry.resolve()))
817}
818
819/// Deserialize multilingual config from either a preset name or an explicit block.
820fn deserialize_multilingual_config<'de, D>(
821    deserializer: D,
822) -> Result<Option<MultilingualConfig>, D::Error>
823where
824    D: serde::Deserializer<'de>,
825{
826    let value: Option<crate::presets::MultilingualConfigEntry> = Option::deserialize(deserializer)?;
827    Ok(value.map(|entry| entry.resolve()))
828}
829
830impl<'de> Deserialize<'de> for Config {
831    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
832    where
833        D: serde::Deserializer<'de>,
834    {
835        #[derive(Deserialize)]
836        #[serde(rename_all = "kebab-case")]
837        struct ConfigWire {
838            #[serde(skip_serializing_if = "Option::is_none")]
839            substitute: Option<SubstituteConfig>,
840            #[serde(skip_serializing_if = "Option::is_none")]
841            processing: Option<Processing>,
842            #[serde(skip_serializing_if = "Option::is_none")]
843            locale_override: Option<String>,
844            #[serde(skip_serializing_if = "Option::is_none")]
845            localize: Option<Localize>,
846            #[serde(
847                skip_serializing_if = "Option::is_none",
848                deserialize_with = "deserialize_multilingual_config",
849                default
850            )]
851            multilingual: Option<MultilingualConfig>,
852            #[serde(
853                skip_serializing_if = "Option::is_none",
854                deserialize_with = "deserialize_contributor_config",
855                default
856            )]
857            contributors: Option<ContributorConfig>,
858            #[serde(
859                skip_serializing_if = "Option::is_none",
860                deserialize_with = "deserialize_date_config",
861                default
862            )]
863            dates: Option<DateConfig>,
864            #[serde(
865                skip_serializing_if = "Option::is_none",
866                deserialize_with = "deserialize_titles_config",
867                default
868            )]
869            titles: Option<crate::options::titles::TitlesConfig>,
870            #[serde(
871                skip_serializing_if = "Option::is_none",
872                deserialize_with = "deserialize_locator_config",
873                default
874            )]
875            locators: Option<LocatorConfig>,
876            #[serde(skip_serializing_if = "Option::is_none")]
877            page_range_format: Option<PageRangeFormat>,
878            #[serde(skip_serializing_if = "Option::is_none")]
879            links: Option<LinksConfig>,
880            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
881            punctuation_in_quote: bool,
882            #[serde(skip_serializing_if = "Option::is_none")]
883            volume_pages_delimiter: Option<DelimiterPunctuation>,
884            #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
885            strip_periods: Option<bool>,
886            #[serde(skip_serializing_if = "Option::is_none")]
887            notes: Option<NoteConfig>,
888            #[serde(skip_serializing_if = "Option::is_none")]
889            integral_name_memory: Option<IntegralNameMemoryConfig>,
890            #[serde(skip_serializing_if = "Option::is_none")]
891            org_abbreviation_memory: Option<OrgAbbreviationMemoryConfig>,
892            #[serde(default)]
893            profile: Option<serde_yaml::Value>,
894            #[serde(skip_serializing_if = "Option::is_none")]
895            custom: Option<HashMap<String, serde_json::Value>>,
896            #[serde(flatten)]
897            unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
898        }
899
900        let wire = ConfigWire::deserialize(deserializer)?;
901        if wire.profile.is_some() {
902            return Err(serde::de::Error::custom(
903                "`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`",
904            ));
905        }
906
907        Ok(Self {
908            substitute: wire.substitute,
909            processing: wire.processing,
910            locale_override: wire.locale_override,
911            localize: wire.localize,
912            multilingual: wire.multilingual,
913            contributors: wire.contributors,
914            dates: wire.dates,
915            titles: wire.titles,
916            locators: wire.locators,
917            page_range_format: wire.page_range_format,
918            links: wire.links,
919            punctuation_in_quote: wire.punctuation_in_quote,
920            volume_pages_delimiter: wire.volume_pages_delimiter,
921            strip_periods: wire.strip_periods,
922            notes: wire.notes,
923            integral_name_memory: wire.integral_name_memory,
924            org_abbreviation_memory: wire.org_abbreviation_memory,
925            custom: wire.custom,
926            unknown_fields: wire.unknown_fields,
927        })
928    }
929}
930
931#[cfg(test)]
932#[allow(
933    clippy::unwrap_used,
934    clippy::expect_used,
935    clippy::panic,
936    clippy::indexing_slicing,
937    clippy::todo,
938    clippy::unimplemented,
939    clippy::unreachable,
940    clippy::get_unwrap,
941    reason = "Panicking is acceptable and often desired in tests."
942)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn test_config_default() {
948        let config = Config::default();
949        assert!(config.substitute.is_none());
950        assert!(config.processing.is_none());
951    }
952
953    #[test]
954    fn test_author_date_processing() {
955        let processing = Processing::AuthorDate;
956        let config = processing.config();
957        assert!(config.disambiguate.unwrap().year_suffix);
958        assert_eq!(
959            processing.default_bibliography_sort(),
960            Some(crate::presets::SortPreset::AuthorDateTitle)
961        );
962        assert_eq!(
963            config.sort,
964            Some(SortEntry::Preset(
965                crate::presets::SortPreset::AuthorDateTitle
966            ))
967        );
968    }
969
970    #[test]
971    fn test_processing_default_bibliography_sorts() {
972        assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
973        assert_eq!(
974            Processing::Note.default_bibliography_sort(),
975            Some(crate::presets::SortPreset::AuthorTitleDate)
976        );
977        assert_eq!(
978            Processing::Label(LabelConfig::default()).default_bibliography_sort(),
979            Some(crate::presets::SortPreset::AuthorDateTitle)
980        );
981    }
982
983    #[test]
984    fn test_processing_default_citation_sort_policy_is_explicit_only() {
985        assert_eq!(
986            Processing::AuthorDate.default_citation_sort_policy(),
987            CitationSortPolicy::ExplicitOnly
988        );
989        assert_eq!(
990            Processing::Note.default_citation_sort_policy(),
991            CitationSortPolicy::ExplicitOnly
992        );
993    }
994
995    #[test]
996    fn test_substitute_default() {
997        let sub = Substitute::default();
998        assert_eq!(sub.template.len(), 3);
999    }
1000
1001    #[test]
1002    fn test_config_yaml_roundtrip() {
1003        let yaml = r#"
1004substitute:
1005  contributor-role-form: short
1006  template:
1007    - editor
1008    - title
1009processing: author-date
1010contributors:
1011  display-as-sort: first
1012  and: symbol
1013"#;
1014        let config: Config = serde_yaml::from_str(yaml).unwrap();
1015        assert!(config.substitute.is_some());
1016        assert_eq!(config.processing, Some(Processing::AuthorDate));
1017        assert_eq!(
1018            config.contributors.as_ref().unwrap().and,
1019            Some(AndOptions::Symbol)
1020        );
1021    }
1022
1023    #[test]
1024    fn test_contributor_config_preset() {
1025        // Test that a preset name parses and resolves correctly for contributors
1026        let yaml = r#"contributors: apa"#;
1027        let config: Config = serde_yaml::from_str(yaml).unwrap();
1028        let contributors = config.contributors.unwrap();
1029        assert_eq!(contributors.and, Some(AndOptions::Symbol));
1030        assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
1031    }
1032
1033    #[test]
1034    fn test_role_label_presets_parse_and_resolve_precedence() {
1035        let yaml = r#"
1036contributors:
1037  role:
1038    preset: short-suffix
1039    roles:
1040      editor:
1041        preset: long-suffix
1042"#;
1043        let config: Config = serde_yaml::from_str(yaml).unwrap();
1044        let contributors = config.contributors.unwrap();
1045
1046        assert_eq!(
1047            contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
1048            Some(RoleLabelPreset::LongSuffix)
1049        );
1050        assert_eq!(
1051            contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
1052            Some(RoleLabelPreset::ShortSuffix)
1053        );
1054
1055        // Scalar shorthand form — must parse identically for preset-only case
1056        let yaml_scalar = r#"
1057contributors:
1058  role: short-suffix
1059"#;
1060        let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
1061        let contributors2 = config2.contributors.unwrap();
1062
1063        assert_eq!(
1064            contributors2
1065                .effective_role_label_preset(&crate::template::ContributorRole::Translator),
1066            Some(RoleLabelPreset::ShortSuffix)
1067        );
1068    }
1069
1070    #[test]
1071    fn test_role_specific_name_order_override_is_available() {
1072        let yaml = r#"
1073contributors:
1074  role:
1075    roles:
1076      translator:
1077        name-order: given-first
1078"#;
1079        let config: Config = serde_yaml::from_str(yaml).unwrap();
1080        let contributors = config.contributors.unwrap();
1081
1082        assert_eq!(
1083            contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
1084            Some(&crate::template::NameOrder::GivenFirst)
1085        );
1086    }
1087
1088    #[test]
1089    fn test_date_config_preset() {
1090        // Test that a preset name parses and resolves correctly for dates
1091        let yaml = r#"dates: long"#;
1092        let config: Config = serde_yaml::from_str(yaml).unwrap();
1093        let dates = config.dates.unwrap();
1094        assert_eq!(dates.month, MonthFormat::Long);
1095    }
1096
1097    #[test]
1098    fn test_titles_config_preset() {
1099        // Test that a preset name parses and resolves correctly for titles
1100        let yaml = r#"titles: chicago"#;
1101        let config: Config = serde_yaml::from_str(yaml).unwrap();
1102        let titles = config.titles.unwrap();
1103        assert_eq!(titles.component.unwrap().quote, Some(true));
1104        assert_eq!(titles.monograph.unwrap().emph, Some(true));
1105    }
1106
1107    #[test]
1108    fn test_substitute_config_preset() {
1109        // Test that a preset name parses correctly
1110        let yaml = r#"substitute: standard"#;
1111        let config: Config = serde_yaml::from_str(yaml).unwrap();
1112        assert!(config.substitute.is_some());
1113        let resolved = config.substitute.unwrap().resolve();
1114        assert_eq!(resolved.template.len(), 3);
1115        assert_eq!(resolved.template[0], SubstituteKey::Editor);
1116    }
1117
1118    #[test]
1119    fn test_substitute_config_explicit() {
1120        // Test that explicit config still works
1121        let yaml = r#"
1122substitute:
1123  template:
1124    - title
1125    - editor
1126"#;
1127        let config: Config = serde_yaml::from_str(yaml).unwrap();
1128        let resolved = config.substitute.unwrap().resolve();
1129        assert_eq!(resolved.template[0], SubstituteKey::Title);
1130        assert_eq!(resolved.template[1], SubstituteKey::Editor);
1131    }
1132
1133    #[test]
1134    fn test_config_merge_precedence() {
1135        // Base config with global options
1136        let base_yaml = r#"
1137processing: author-date
1138locale-override: en-US-base
1139contributors:
1140  display-as-sort: first
1141  and: symbol
1142"#;
1143        let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
1144
1145        // Override config (e.g., citation-specific options)
1146        let override_yaml = r#"
1147contributors:
1148  and: text
1149locale-override: en-US-chicago
1150"#;
1151        let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
1152
1153        // Merge: override takes precedence
1154        base.merge(&override_config);
1155
1156        // Processing should remain from base (not overridden)
1157        assert_eq!(base.processing, Some(Processing::AuthorDate));
1158        assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1159
1160        // Contributors should be merged with override values taking precedence
1161        assert_eq!(
1162            base.contributors.as_ref().unwrap().and,
1163            Some(AndOptions::Text)
1164        );
1165    }
1166
1167    #[test]
1168    fn test_config_deserializes_locale_override() {
1169        let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1170        assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1171    }
1172
1173    #[test]
1174    fn test_config_merged_convenience() {
1175        let base = Config {
1176            processing: Some(Processing::AuthorDate),
1177            ..Default::default()
1178        };
1179        let override_config = Config {
1180            punctuation_in_quote: true,
1181            ..Default::default()
1182        };
1183
1184        let merged = Config::merged(&base, &override_config);
1185
1186        // Both fields preserved
1187        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1188        assert!(merged.punctuation_in_quote);
1189    }
1190
1191    #[test]
1192    fn test_citation_options_merge_overrides_citation_fields_only() {
1193        let base = Config {
1194            processing: Some(Processing::AuthorDate),
1195            ..Default::default()
1196        };
1197
1198        let overrides = CitationOptions {
1199            strip_periods: Some(true),
1200            locators: Some(LocatorConfig::default()),
1201            ..Default::default()
1202        };
1203
1204        let merged = overrides.merged_with(&base);
1205        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1206        assert!(merged.strip_periods.unwrap_or(false));
1207        assert!(merged.locators.is_some());
1208    }
1209
1210    #[test]
1211    fn test_bibliography_options_merge_projects_shared_fields_only() {
1212        let base = Config {
1213            processing: Some(Processing::AuthorDate),
1214            ..Default::default()
1215        };
1216
1217        let overrides = BibliographyOptions {
1218            entry_suffix: Some(".".to_string()),
1219            separator: Some(", ".to_string()),
1220            suppress_period_after_url: true,
1221            ..Default::default()
1222        };
1223
1224        let merged = overrides.merged_with(&base);
1225        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1226        assert!(merged.locators.is_none());
1227        assert!(merged.notes.is_none());
1228        let bibliography = overrides.to_bibliography_config();
1229        assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1230        assert_eq!(bibliography.separator.as_deref(), Some(", "));
1231        assert!(bibliography.suppress_period_after_url);
1232    }
1233
1234    #[test]
1235    fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1236        let base = Config {
1237            processing: Some(Processing::AuthorDate),
1238            ..Default::default()
1239        };
1240
1241        let overrides = BibliographyOptions {
1242            contributors: Some(ContributorConfig::default()),
1243            ..Default::default()
1244        };
1245
1246        let merged = overrides.merged_with(&base);
1247        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1248        assert!(merged.contributors.is_some());
1249    }
1250
1251    #[test]
1252    fn citation_options_captures_unknown_fields_for_forward_compat() {
1253        let yaml = "future-key: true\n";
1254        let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1255        assert!(opts.unknown_fields.contains_key("future-key"));
1256    }
1257
1258    #[test]
1259    fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1260        let yaml = "future-key: true\n";
1261        let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1262        assert!(opts.unknown_fields.contains_key("future-key"));
1263    }
1264
1265    #[test]
1266    fn note_config_captures_unknown_fields_for_forward_compat() {
1267        let yaml = "punctuation: inside\nfuture-key: true\n";
1268        let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1269        assert!(cfg.unknown_fields.contains_key("future-key"));
1270        assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1271    }
1272
1273    #[test]
1274    fn test_multilingual_preset_romanized_translated_parses_and_resolves() {
1275        // `romanized-translated` resolves to Combined title mode (romanized [translated])
1276        let yaml = r#"multilingual: romanized-translated"#;
1277        let config: Config = serde_yaml::from_str(yaml).unwrap();
1278        let ml = config.multilingual.unwrap();
1279        assert_eq!(ml.title_mode, Some(MultilingualMode::Combined));
1280        assert_eq!(ml.name_mode, Some(MultilingualMode::Transliterated));
1281        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1282    }
1283
1284    #[test]
1285    fn test_multilingual_preset_romanized_only_parses_and_resolves() {
1286        // `romanized-only` resolves to Transliterated title mode (no translation)
1287        let yaml = r#"multilingual: romanized-only"#;
1288        let config: Config = serde_yaml::from_str(yaml).unwrap();
1289        let ml = config.multilingual.unwrap();
1290        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
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_explicit_block_transliterated_roundtrips() {
1297        // Verify a Transliterated explicit block survives YAML serialize→deserialize.
1298        let yaml = r#"
1299multilingual:
1300  title-mode: transliterated
1301  preferred-script: Latn
1302"#;
1303        let config: Config = serde_yaml::from_str(yaml).unwrap();
1304        let ml = config.multilingual.clone().unwrap();
1305        assert_eq!(ml.title_mode, Some(MultilingualMode::Transliterated));
1306        assert_eq!(ml.preferred_script.as_deref(), Some("Latn"));
1307
1308        let yaml2 = serde_yaml::to_string(&config).unwrap();
1309        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1310        assert_eq!(config2.multilingual, config.multilingual);
1311    }
1312
1313    #[test]
1314    fn test_multilingual_pattern_block_roundtrips() {
1315        // Exercises the externally-tagged `{pattern: [...]}` YAML path — the case that
1316        // breaks under serde_yaml's untagged+enum limitation without the custom Deserialize.
1317        let yaml = r#"
1318multilingual:
1319  title-mode:
1320    pattern:
1321      - view: original
1322      - view: translated
1323        wrap: brackets
1324"#;
1325        let config: Config = serde_yaml::from_str(yaml).unwrap();
1326        let ml = config.multilingual.clone().unwrap();
1327        assert!(
1328            matches!(ml.title_mode, Some(MultilingualMode::Pattern(_))),
1329            "expected Pattern mode, got {:?}",
1330            ml.title_mode
1331        );
1332
1333        let yaml2 = serde_yaml::to_string(&config).unwrap();
1334        let config2: Config = serde_yaml::from_str(&yaml2).unwrap();
1335        assert_eq!(config2.multilingual, config.multilingual);
1336    }
1337}