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