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