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