Skip to main content

citum_schema_style/options/
mod.rs

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