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