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