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