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 substitute;
18
19pub use bibliography::{
20    ArticleJournalBibliographyConfig, ArticleJournalNoPageFallback, BibliographyConfig,
21    BibliographyPartitionHeading, BibliographyPartitionKind, BibliographyPartitionMode,
22    BibliographySortPartitioning, SubsequentAuthorSubstituteRule,
23};
24pub use contributors::{
25    AndOptions, AndOtherOptions, ContributorConfig, ContributorConfigEntry, DelimiterPrecedesLast,
26    DemoteNonDroppingParticle, DisplayAsSort, NameForm, RoleLabelPreset, RoleOptions,
27    RoleOptionsEntry, RoleRendering, ShortenListOptions,
28};
29pub use dates::{DateConfig, DateConfigEntry};
30pub use integral_name_memory::{
31    IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope,
32    ResolvedIntegralNameMemoryConfig, ShortNameDisplay, SubsequentNameForm,
33};
34pub use localization::{Localize, MonthFormat, Scope};
35pub use locators::{
36    LabelForm, LabelRepeat, LocatorConfig, LocatorConfigEntry, LocatorKindConfig, LocatorPattern,
37    LocatorPreset, TypeClass,
38};
39pub use multilingual::{MultilingualConfig, MultilingualMode, ScriptConfig};
40pub use processing::{
41    CitationSortPolicy, Disambiguation, Group, LabelConfig, LabelParams, LabelPreset, Processing,
42    ProcessingCustom, Sort, SortEntry, SortKey, SortSpec,
43};
44pub use scoped::{
45    BibliographyLabelMode, BibliographyLabelWrap, CitationGroupDelimiter, DatePosition, LabelWrap,
46    RepeatedAuthorRendering, TitleTerminator,
47};
48pub use substitute::{Substitute, SubstituteConfig, SubstituteKey};
49
50use crate::template::DelimiterPunctuation;
51#[cfg(feature = "schema")]
52use schemars::JsonSchema;
53use serde::{Deserialize, Serialize};
54use std::collections::HashMap;
55
56/// Top-level style configuration.
57#[derive(Debug, Default, PartialEq, Clone, Serialize)]
58#[cfg_attr(feature = "schema", derive(JsonSchema))]
59#[serde(rename_all = "kebab-case")]
60pub struct Config {
61    /// Substitution rules for missing data.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub substitute: Option<SubstituteConfig>,
64    /// Processing mode (author-date, numeric, etc.).
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub processing: Option<Processing>,
67    /// Style-level locale override ID loaded from `locales/overrides/<id>.*`.
68    ///
69    /// This patches the locale selected by `StyleInfo.default_locale` without
70    /// duplicating the full base locale. Runtime loading is limited to the
71    /// style-global config; nested citation or bibliography configs are ignored.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub locale_override: Option<String>,
74    /// Localization settings.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub localize: Option<Localize>,
77    /// Multilingual rendering defaults.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub multilingual: Option<MultilingualConfig>,
80    /// Contributor formatting defaults. Accepts a preset name (e.g., "apa")
81    /// or explicit configuration.
82    #[serde(
83        skip_serializing_if = "Option::is_none",
84        deserialize_with = "deserialize_contributor_config",
85        default
86    )]
87    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
88    pub contributors: Option<ContributorConfig>,
89    /// Date formatting defaults. Accepts a preset name (e.g., "long")
90    /// or explicit configuration.
91    #[serde(
92        skip_serializing_if = "Option::is_none",
93        deserialize_with = "deserialize_date_config",
94        default
95    )]
96    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
97    pub dates: Option<DateConfig>,
98    /// Title 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_titles_config",
103        default
104    )]
105    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
106    pub titles: Option<crate::options::titles::TitlesConfig>,
107    /// Locator rendering configuration. Accepts a preset name (e.g., "note")
108    /// or explicit configuration.
109    #[serde(
110        skip_serializing_if = "Option::is_none",
111        deserialize_with = "deserialize_locator_config",
112        default
113    )]
114    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
115    pub locators: Option<LocatorConfig>,
116    /// Page range formatting (expanded, minimal, chicago).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub page_range_format: Option<PageRangeFormat>,
119    /// Hyperlink configuration.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub links: Option<LinksConfig>,
122    /// Whether to place periods/commas inside quotation marks.
123    /// true = American style ("text."), false = British style ("text".)
124    /// Defaults to false; en-US locale typically sets this to true.
125    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
126    pub punctuation_in_quote: bool,
127    /// Delimiter between volume/issue and pages for serial sources.
128    /// Processor adds trailing space when rendering.
129    /// Examples: Comma (APA ", "), Colon (Chicago ": ").
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
132    /// Strip trailing periods from terms, labels, and abbreviated dates.
133    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
134    pub strip_periods: Option<bool>,
135    /// Document-level note marker placement and punctuation movement rules.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub notes: Option<NoteConfig>,
138    /// Integral citation name-memory behavior.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
141    /// Custom user-defined fields for extensions.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub custom: Option<HashMap<String, serde_json::Value>>,
144    /// Forward-compat: captures unknown keys when an older engine reads a
145    /// style produced by a newer schema. Empty by default; treated as a
146    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
147    #[serde(
148        flatten,
149        default,
150        skip_serializing_if = "std::collections::BTreeMap::is_empty"
151    )]
152    #[cfg_attr(feature = "schema", schemars(skip))]
153    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
154}
155
156/// Citation-local option overrides.
157#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(JsonSchema))]
159#[serde(rename_all = "kebab-case")]
160pub struct CitationOptions {
161    /// Substitution rules for missing data.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub substitute: Option<SubstituteConfig>,
164    /// Processing mode (author-date, numeric, etc.).
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub processing: Option<Processing>,
167    /// Localization settings.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub localize: Option<Localize>,
170    /// Multilingual rendering defaults.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub multilingual: Option<MultilingualConfig>,
173    /// Contributor formatting defaults.
174    #[serde(
175        skip_serializing_if = "Option::is_none",
176        deserialize_with = "deserialize_contributor_config",
177        default
178    )]
179    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
180    pub contributors: Option<ContributorConfig>,
181    /// Date formatting defaults.
182    #[serde(
183        skip_serializing_if = "Option::is_none",
184        deserialize_with = "deserialize_date_config",
185        default
186    )]
187    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
188    pub dates: Option<DateConfig>,
189    /// Title formatting defaults.
190    #[serde(
191        skip_serializing_if = "Option::is_none",
192        deserialize_with = "deserialize_titles_config",
193        default
194    )]
195    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
196    pub titles: Option<crate::options::titles::TitlesConfig>,
197    /// Locator rendering configuration.
198    #[serde(
199        skip_serializing_if = "Option::is_none",
200        deserialize_with = "deserialize_locator_config",
201        default
202    )]
203    #[cfg_attr(feature = "schema", schemars(with = "Option<LocatorConfigEntry>"))]
204    pub locators: Option<LocatorConfig>,
205    /// Page range formatting (expanded, minimal, chicago).
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub page_range_format: Option<PageRangeFormat>,
208    /// Hyperlink configuration.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub links: Option<LinksConfig>,
211    /// Whether to place periods/commas inside quotation marks.
212    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
213    pub punctuation_in_quote: bool,
214    /// Delimiter between volume/issue and pages for serial sources.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
217    /// Strip trailing periods from terms, labels, and abbreviated dates.
218    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
219    pub strip_periods: Option<bool>,
220    /// Document-level note marker placement and punctuation movement rules.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub notes: Option<NoteConfig>,
223    /// Integral citation name-memory behavior.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub integral_name_memory: Option<IntegralNameMemoryConfig>,
226    /// Label wrap policy applied to citation labels.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub label_wrap: Option<LabelWrap>,
229    /// Delimiter between grouped citation items.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub group_delimiter: Option<CitationGroupDelimiter>,
232    /// Custom user-defined fields for extensions.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub custom: Option<HashMap<String, serde_json::Value>>,
235    /// Forward-compat: captures unknown keys when an older engine reads a
236    /// style produced by a newer schema. Empty by default; treated as a
237    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
238    #[serde(
239        flatten,
240        default,
241        skip_serializing_if = "std::collections::BTreeMap::is_empty"
242    )]
243    #[cfg_attr(feature = "schema", schemars(skip))]
244    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
245}
246
247/// Bibliography-local option overrides.
248#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
249#[cfg_attr(feature = "schema", derive(JsonSchema))]
250#[serde(rename_all = "kebab-case")]
251pub struct BibliographyOptions {
252    /// Substitution rules for missing data.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub substitute: Option<SubstituteConfig>,
255    /// Processing mode (author-date, numeric, etc.).
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub processing: Option<Processing>,
258    /// Localization settings.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub localize: Option<Localize>,
261    /// Multilingual rendering defaults.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub multilingual: Option<MultilingualConfig>,
264    /// Contributor formatting defaults.
265    #[serde(
266        skip_serializing_if = "Option::is_none",
267        deserialize_with = "deserialize_contributor_config",
268        default
269    )]
270    #[cfg_attr(feature = "schema", schemars(with = "Option<ContributorConfigEntry>"))]
271    pub contributors: Option<ContributorConfig>,
272    /// Date formatting defaults.
273    #[serde(
274        skip_serializing_if = "Option::is_none",
275        deserialize_with = "deserialize_date_config",
276        default
277    )]
278    #[cfg_attr(feature = "schema", schemars(with = "Option<DateConfigEntry>"))]
279    pub dates: Option<DateConfig>,
280    /// Title formatting defaults.
281    #[serde(
282        skip_serializing_if = "Option::is_none",
283        deserialize_with = "deserialize_titles_config",
284        default
285    )]
286    #[cfg_attr(feature = "schema", schemars(with = "Option<TitlesConfigEntry>"))]
287    pub titles: Option<crate::options::titles::TitlesConfig>,
288    /// Page range formatting (expanded, minimal, chicago).
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub page_range_format: Option<PageRangeFormat>,
291    /// Article-journal-specific bibliography policies.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub article_journal: Option<ArticleJournalBibliographyConfig>,
294    /// String to substitute for repeating authors.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub subsequent_author_substitute: Option<String>,
297    /// Rule for when to apply the substitute.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub subsequent_author_substitute_rule: Option<SubsequentAuthorSubstituteRule>,
300    /// Whether to use a hanging indent.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub hanging_indent: Option<bool>,
303    /// Suffix appended to each bibliography entry.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub entry_suffix: Option<String>,
306    /// Separator between bibliography components.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub separator: Option<String>,
309    /// Whether to suppress the trailing period after URLs/DOIs.
310    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
311    pub suppress_period_after_url: bool,
312    /// Configuration for compound numeric bibliography entries.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub compound_numeric: Option<bibliography::CompoundNumericConfig>,
315    /// Partitioning policy for multilingual bibliography sorting and sections.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub sort_partitioning: Option<bibliography::BibliographySortPartitioning>,
318    /// Hyperlink configuration.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub links: Option<LinksConfig>,
321    /// Whether to place periods/commas inside quotation marks.
322    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
323    pub punctuation_in_quote: bool,
324    /// Delimiter between volume/issue and pages for serial sources.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub volume_pages_delimiter: Option<DelimiterPunctuation>,
327    /// Bibliography label mode for label-bearing styles.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub label_mode: Option<BibliographyLabelMode>,
330    /// Label wrap policy applied to bibliography labels.
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub label_wrap: Option<BibliographyLabelWrap>,
333    /// Placement of issued dates within bibliography entries.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub date_position: Option<DatePosition>,
336    /// Terminator applied to primary-title bibliography components.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub title_terminator: Option<TitleTerminator>,
339    /// Repeated-author rendering mode for bibliography entries.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub repeated_author_rendering: Option<RepeatedAuthorRendering>,
342    /// Strip trailing periods from terms, labels, and abbreviated dates.
343    #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
344    pub strip_periods: Option<bool>,
345    /// Custom user-defined fields for extensions.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub custom: Option<HashMap<String, serde_json::Value>>,
348    /// Forward-compat: captures unknown keys when an older engine reads a
349    /// style produced by a newer schema. Empty by default; treated as a
350    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
351    #[serde(
352        flatten,
353        default,
354        skip_serializing_if = "std::collections::BTreeMap::is_empty"
355    )]
356    #[cfg_attr(feature = "schema", schemars(skip))]
357    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
358}
359
360/// Document-level note marker placement rules.
361#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
362#[cfg_attr(feature = "schema", derive(JsonSchema))]
363#[serde(rename_all = "kebab-case")]
364pub struct NoteConfig {
365    /// Desired location of movable punctuation relative to closing quotation
366    /// marks when note markers are introduced.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub punctuation: Option<NoteQuotePlacement>,
369    /// Desired location of the note marker relative to closing quotation marks.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub number: Option<NoteNumberPlacement>,
372    /// Whether the note marker appears before or after the closest movable
373    /// punctuation mark.
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub order: Option<NoteMarkerOrder>,
376    /// Forward-compat: captures unknown keys when an older engine reads a
377    /// style produced by a newer schema. Empty by default; treated as a
378    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
379    #[serde(
380        flatten,
381        default,
382        skip_serializing_if = "std::collections::BTreeMap::is_empty"
383    )]
384    #[cfg_attr(feature = "schema", schemars(skip))]
385    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
386}
387
388/// Controls where movable punctuation is placed relative to closing quotation marks.
389#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
390#[cfg_attr(feature = "schema", derive(JsonSchema))]
391#[serde(rename_all = "kebab-case")]
392pub enum NoteQuotePlacement {
393    /// Keep movable punctuation inside the closing quotation mark.
394    Inside,
395    /// Keep movable punctuation outside the closing quotation mark.
396    Outside,
397    /// Follow org-cite-style adaptive behavior: punctuation stays inside when
398    /// it is already flush with the closing quote, otherwise it is placed
399    /// outside.
400    Adaptive,
401}
402
403/// Controls where a footnote number marker is placed relative to closing quotation marks.
404#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
405#[cfg_attr(feature = "schema", derive(JsonSchema))]
406#[serde(rename_all = "kebab-case")]
407pub enum NoteNumberPlacement {
408    /// Place the note marker inside the closing quotation mark.
409    Inside,
410    /// Place the note marker outside the closing quotation mark.
411    Outside,
412    /// Place the note marker on the same side as the movable punctuation when
413    /// only one side has punctuation; otherwise default to outside.
414    Same,
415}
416
417/// Controls whether a note marker appears before or after adjacent movable punctuation.
418#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
419#[cfg_attr(feature = "schema", derive(JsonSchema))]
420#[serde(rename_all = "kebab-case")]
421pub enum NoteMarkerOrder {
422    /// Place the note marker before the closest movable punctuation mark.
423    Before,
424    /// Place the note marker after the closest movable punctuation mark.
425    After,
426}
427
428/// Page range formatting options.
429#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
430#[cfg_attr(feature = "schema", derive(JsonSchema))]
431#[serde(rename_all = "kebab-case")]
432#[non_exhaustive]
433pub enum PageRangeFormat {
434    /// Full expansion: 321-328 → 321–328
435    #[default]
436    Expanded,
437    /// Minimal digits: 321-328 → 321–8
438    Minimal,
439    /// Minimal two digits: 321-328 → 321–28
440    MinimalTwo,
441    /// Chicago Manual of Style 15th ed rules
442    Chicago,
443    /// Chicago Manual of Style 16th/17th ed rules
444    Chicago16,
445}
446
447pub mod titles;
448
449pub use titles::{TextCase, TitleRendering, TitlesConfig, TitlesConfigEntry};
450
451/// Structured link options.
452#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
453#[cfg_attr(feature = "schema", derive(JsonSchema))]
454#[serde(rename_all = "kebab-case")]
455pub struct LinksConfig {
456    /// Link value to the item's DOI.
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub doi: Option<bool>,
459    /// Link value to the item's URL.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub url: Option<bool>,
462    /// The target for the link (url, doi, etc.).
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub target: Option<LinkTarget>,
465    /// What text should be hyperlinked (title, url, etc.).
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub anchor: Option<LinkAnchor>,
468}
469
470/// Link target options.
471#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
472#[cfg_attr(feature = "schema", derive(JsonSchema))]
473#[serde(rename_all = "kebab-case")]
474pub enum LinkTarget {
475    Url,
476    Doi,
477    UrlOrDoi,
478    Pubmed,
479    Pmcid,
480}
481
482/// Link anchor options.
483#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "schema", derive(JsonSchema))]
485#[serde(rename_all = "kebab-case")]
486pub enum LinkAnchor {
487    /// Link the title component.
488    Title,
489    /// Link the URL component itself.
490    Url,
491    /// Link the DOI component itself.
492    Doi,
493    /// Link the specific component this config is attached to.
494    Component,
495    /// Link the entire bibliography entry.
496    Entry,
497}
498
499impl Config {
500    /// Merge another config into this one, with `other` taking precedence.
501    ///
502    /// Used for combining global options with context-specific (citation/bibliography) options.
503    /// Only non-None fields from `other` override fields in `self`.
504    pub fn merge(&mut self, other: &Config) {
505        crate::merge_options!(
506            self,
507            other,
508            processing,
509            locale_override,
510            localize,
511            multilingual,
512            dates,
513            titles,
514            locators,
515            page_range_format,
516            links,
517            volume_pages_delimiter,
518            locale_override,
519            strip_periods,
520            notes,
521            integral_name_memory,
522            custom,
523        );
524
525        if let Some(other_substitute) = &other.substitute {
526            if let Some(this_substitute) = &self.substitute {
527                self.substitute = Some(SubstituteConfig::merged(this_substitute, other_substitute));
528            } else {
529                self.substitute = Some(other_substitute.clone());
530            }
531        }
532
533        if let Some(other_contributors) = &other.contributors {
534            if let Some(this_contributors) = &mut self.contributors {
535                this_contributors.merge(other_contributors);
536            } else {
537                self.contributors = Some(other_contributors.clone());
538            }
539        }
540
541        if other.punctuation_in_quote {
542            self.punctuation_in_quote = true;
543        }
544    }
545
546    /// Create a merged config from base and override, returning a new Config.
547    ///
548    /// Convenience method that clones base, then merges override into it.
549    pub fn merged(base: &Config, override_config: &Config) -> Config {
550        let mut result = base.clone();
551        result.merge(override_config);
552        result
553    }
554}
555
556impl CitationOptions {
557    /// Convert citation-local overrides into the runtime config shape.
558    #[must_use]
559    pub fn to_config(&self) -> Config {
560        Config {
561            substitute: self.substitute.clone(),
562            processing: self.processing.clone(),
563            locale_override: None,
564            localize: self.localize.clone(),
565            multilingual: self.multilingual.clone(),
566            contributors: self.contributors.clone(),
567            dates: self.dates.clone(),
568            titles: self.titles.clone(),
569            locators: self.locators.clone(),
570            page_range_format: self.page_range_format.clone(),
571            links: self.links.clone(),
572            punctuation_in_quote: self.punctuation_in_quote,
573            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
574            strip_periods: self.strip_periods,
575            notes: self.notes.clone(),
576            integral_name_memory: self.integral_name_memory.clone(),
577            custom: self.custom.clone(),
578            unknown_fields: std::collections::BTreeMap::new(),
579        }
580    }
581
582    /// Merge citation-local overrides over a base config.
583    #[must_use]
584    pub fn merged_with(&self, base: &Config) -> Config {
585        Config::merged(base, &self.to_config())
586    }
587}
588
589impl BibliographyOptions {
590    /// Convert bibliography-entry overrides into bibliography-only runtime config.
591    #[must_use]
592    pub fn to_bibliography_config(&self) -> BibliographyConfig {
593        BibliographyConfig {
594            article_journal: self.article_journal.clone(),
595            subsequent_author_substitute: self.subsequent_author_substitute.clone(),
596            subsequent_author_substitute_rule: self.subsequent_author_substitute_rule.clone(),
597            hanging_indent: self.hanging_indent,
598            entry_suffix: self.entry_suffix.clone(),
599            separator: self.separator.clone(),
600            suppress_period_after_url: self.suppress_period_after_url,
601            custom: None,
602            compound_numeric: self.compound_numeric.clone(),
603            sort_partitioning: self.sort_partitioning.clone(),
604            unknown_fields: std::collections::BTreeMap::new(),
605        }
606    }
607
608    /// Convert bibliography-local overrides into the runtime config shape.
609    #[must_use]
610    pub fn to_config(&self) -> Config {
611        Config {
612            substitute: self.substitute.clone(),
613            processing: self.processing.clone(),
614            locale_override: None,
615            localize: self.localize.clone(),
616            multilingual: self.multilingual.clone(),
617            contributors: self.contributors.clone(),
618            dates: self.dates.clone(),
619            titles: self.titles.clone(),
620            locators: None,
621            page_range_format: self.page_range_format.clone(),
622            links: self.links.clone(),
623            punctuation_in_quote: self.punctuation_in_quote,
624            volume_pages_delimiter: self.volume_pages_delimiter.clone(),
625            strip_periods: self.strip_periods,
626            notes: None,
627            integral_name_memory: None,
628            custom: self.custom.clone(),
629            unknown_fields: std::collections::BTreeMap::new(),
630        }
631    }
632
633    /// Merge bibliography-local overrides over a base config.
634    #[must_use]
635    pub fn merged_with(&self, base: &Config) -> Config {
636        Config::merged(base, &self.to_config())
637    }
638}
639
640/// Deserialize contributor config from either a preset name or explicit config.
641fn deserialize_contributor_config<'de, D>(
642    deserializer: D,
643) -> Result<Option<ContributorConfig>, D::Error>
644where
645    D: serde::Deserializer<'de>,
646{
647    let value: Option<ContributorConfigEntry> = Option::deserialize(deserializer)?;
648    Ok(value.map(|entry| entry.resolve()))
649}
650
651/// Deserialize date config from either a preset name or explicit config.
652fn deserialize_date_config<'de, D>(deserializer: D) -> Result<Option<DateConfig>, D::Error>
653where
654    D: serde::Deserializer<'de>,
655{
656    let value: Option<DateConfigEntry> = Option::deserialize(deserializer)?;
657    Ok(value.map(|entry| entry.resolve()))
658}
659
660/// Deserialize titles config from either a preset name or explicit config.
661fn deserialize_titles_config<'de, D>(
662    deserializer: D,
663) -> Result<Option<crate::options::titles::TitlesConfig>, D::Error>
664where
665    D: serde::Deserializer<'de>,
666{
667    let value: Option<crate::options::titles::TitlesConfigEntry> =
668        Option::deserialize(deserializer)?;
669    Ok(value.map(|entry| entry.resolve()))
670}
671
672/// Deserialize locator config from either a preset name or explicit config.
673fn deserialize_locator_config<'de, D>(deserializer: D) -> Result<Option<LocatorConfig>, D::Error>
674where
675    D: serde::Deserializer<'de>,
676{
677    let value: Option<LocatorConfigEntry> = Option::deserialize(deserializer)?;
678    Ok(value.map(|entry| entry.resolve()))
679}
680
681impl<'de> Deserialize<'de> for Config {
682    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
683    where
684        D: serde::Deserializer<'de>,
685    {
686        #[derive(Deserialize)]
687        #[serde(rename_all = "kebab-case")]
688        struct ConfigWire {
689            #[serde(skip_serializing_if = "Option::is_none")]
690            substitute: Option<SubstituteConfig>,
691            #[serde(skip_serializing_if = "Option::is_none")]
692            processing: Option<Processing>,
693            #[serde(skip_serializing_if = "Option::is_none")]
694            locale_override: Option<String>,
695            #[serde(skip_serializing_if = "Option::is_none")]
696            localize: Option<Localize>,
697            #[serde(skip_serializing_if = "Option::is_none")]
698            multilingual: Option<MultilingualConfig>,
699            #[serde(
700                skip_serializing_if = "Option::is_none",
701                deserialize_with = "deserialize_contributor_config",
702                default
703            )]
704            contributors: Option<ContributorConfig>,
705            #[serde(
706                skip_serializing_if = "Option::is_none",
707                deserialize_with = "deserialize_date_config",
708                default
709            )]
710            dates: Option<DateConfig>,
711            #[serde(
712                skip_serializing_if = "Option::is_none",
713                deserialize_with = "deserialize_titles_config",
714                default
715            )]
716            titles: Option<crate::options::titles::TitlesConfig>,
717            #[serde(
718                skip_serializing_if = "Option::is_none",
719                deserialize_with = "deserialize_locator_config",
720                default
721            )]
722            locators: Option<LocatorConfig>,
723            #[serde(skip_serializing_if = "Option::is_none")]
724            page_range_format: Option<PageRangeFormat>,
725            #[serde(skip_serializing_if = "Option::is_none")]
726            links: Option<LinksConfig>,
727            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
728            punctuation_in_quote: bool,
729            #[serde(skip_serializing_if = "Option::is_none")]
730            volume_pages_delimiter: Option<DelimiterPunctuation>,
731            #[serde(skip_serializing_if = "Option::is_none", rename = "strip-periods")]
732            strip_periods: Option<bool>,
733            #[serde(skip_serializing_if = "Option::is_none")]
734            notes: Option<NoteConfig>,
735            #[serde(skip_serializing_if = "Option::is_none")]
736            integral_name_memory: Option<IntegralNameMemoryConfig>,
737            #[serde(default)]
738            profile: Option<serde_yaml::Value>,
739            #[serde(skip_serializing_if = "Option::is_none")]
740            custom: Option<HashMap<String, serde_json::Value>>,
741            #[serde(flatten)]
742            unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
743        }
744
745        let wire = ConfigWire::deserialize(deserializer)?;
746        if wire.profile.is_some() {
747            return Err(serde::de::Error::custom(
748                "`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`",
749            ));
750        }
751
752        Ok(Self {
753            substitute: wire.substitute,
754            processing: wire.processing,
755            locale_override: wire.locale_override,
756            localize: wire.localize,
757            multilingual: wire.multilingual,
758            contributors: wire.contributors,
759            dates: wire.dates,
760            titles: wire.titles,
761            locators: wire.locators,
762            page_range_format: wire.page_range_format,
763            links: wire.links,
764            punctuation_in_quote: wire.punctuation_in_quote,
765            volume_pages_delimiter: wire.volume_pages_delimiter,
766            strip_periods: wire.strip_periods,
767            notes: wire.notes,
768            integral_name_memory: wire.integral_name_memory,
769            custom: wire.custom,
770            unknown_fields: wire.unknown_fields,
771        })
772    }
773}
774
775#[cfg(test)]
776#[allow(
777    clippy::unwrap_used,
778    clippy::expect_used,
779    clippy::panic,
780    clippy::indexing_slicing,
781    clippy::todo,
782    clippy::unimplemented,
783    clippy::unreachable,
784    clippy::get_unwrap,
785    reason = "Panicking is acceptable and often desired in tests."
786)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn test_config_default() {
792        let config = Config::default();
793        assert!(config.substitute.is_none());
794        assert!(config.processing.is_none());
795    }
796
797    #[test]
798    fn test_author_date_processing() {
799        let processing = Processing::AuthorDate;
800        let config = processing.config();
801        assert!(config.disambiguate.unwrap().year_suffix);
802        assert_eq!(
803            processing.default_bibliography_sort(),
804            Some(crate::presets::SortPreset::AuthorDateTitle)
805        );
806        assert_eq!(
807            config.sort,
808            Some(SortEntry::Preset(
809                crate::presets::SortPreset::AuthorDateTitle
810            ))
811        );
812    }
813
814    #[test]
815    fn test_processing_default_bibliography_sorts() {
816        assert_eq!(Processing::Numeric.default_bibliography_sort(), None);
817        assert_eq!(
818            Processing::Note.default_bibliography_sort(),
819            Some(crate::presets::SortPreset::AuthorTitleDate)
820        );
821        assert_eq!(
822            Processing::Label(LabelConfig::default()).default_bibliography_sort(),
823            Some(crate::presets::SortPreset::AuthorDateTitle)
824        );
825    }
826
827    #[test]
828    fn test_processing_default_citation_sort_policy_is_explicit_only() {
829        assert_eq!(
830            Processing::AuthorDate.default_citation_sort_policy(),
831            CitationSortPolicy::ExplicitOnly
832        );
833        assert_eq!(
834            Processing::Note.default_citation_sort_policy(),
835            CitationSortPolicy::ExplicitOnly
836        );
837    }
838
839    #[test]
840    fn test_substitute_default() {
841        let sub = Substitute::default();
842        assert_eq!(sub.template.len(), 3);
843    }
844
845    #[test]
846    fn test_config_yaml_roundtrip() {
847        let yaml = r#"
848substitute:
849  contributor-role-form: short
850  template:
851    - editor
852    - title
853processing: author-date
854contributors:
855  display-as-sort: first
856  and: symbol
857"#;
858        let config: Config = serde_yaml::from_str(yaml).unwrap();
859        assert!(config.substitute.is_some());
860        assert_eq!(config.processing, Some(Processing::AuthorDate));
861        assert_eq!(
862            config.contributors.as_ref().unwrap().and,
863            Some(AndOptions::Symbol)
864        );
865    }
866
867    #[test]
868    fn test_contributor_config_preset() {
869        // Test that a preset name parses and resolves correctly for contributors
870        let yaml = r#"contributors: apa"#;
871        let config: Config = serde_yaml::from_str(yaml).unwrap();
872        let contributors = config.contributors.unwrap();
873        assert_eq!(contributors.and, Some(AndOptions::Symbol));
874        assert_eq!(contributors.display_as_sort, Some(DisplayAsSort::First));
875    }
876
877    #[test]
878    fn test_role_label_presets_parse_and_resolve_precedence() {
879        let yaml = r#"
880contributors:
881  role:
882    preset: short-suffix
883    roles:
884      editor:
885        preset: long-suffix
886"#;
887        let config: Config = serde_yaml::from_str(yaml).unwrap();
888        let contributors = config.contributors.unwrap();
889
890        assert_eq!(
891            contributors.effective_role_label_preset(&crate::template::ContributorRole::Editor),
892            Some(RoleLabelPreset::LongSuffix)
893        );
894        assert_eq!(
895            contributors.effective_role_label_preset(&crate::template::ContributorRole::Translator),
896            Some(RoleLabelPreset::ShortSuffix)
897        );
898
899        // Scalar shorthand form — must parse identically for preset-only case
900        let yaml_scalar = r#"
901contributors:
902  role: short-suffix
903"#;
904        let config2: Config = serde_yaml::from_str(yaml_scalar).unwrap();
905        let contributors2 = config2.contributors.unwrap();
906
907        assert_eq!(
908            contributors2
909                .effective_role_label_preset(&crate::template::ContributorRole::Translator),
910            Some(RoleLabelPreset::ShortSuffix)
911        );
912    }
913
914    #[test]
915    fn test_role_specific_name_order_override_is_available() {
916        let yaml = r#"
917contributors:
918  role:
919    roles:
920      translator:
921        name-order: given-first
922"#;
923        let config: Config = serde_yaml::from_str(yaml).unwrap();
924        let contributors = config.contributors.unwrap();
925
926        assert_eq!(
927            contributors.effective_role_name_order(&crate::template::ContributorRole::Translator),
928            Some(&crate::template::NameOrder::GivenFirst)
929        );
930    }
931
932    #[test]
933    fn test_date_config_preset() {
934        // Test that a preset name parses and resolves correctly for dates
935        let yaml = r#"dates: long"#;
936        let config: Config = serde_yaml::from_str(yaml).unwrap();
937        let dates = config.dates.unwrap();
938        assert_eq!(dates.month, MonthFormat::Long);
939    }
940
941    #[test]
942    fn test_titles_config_preset() {
943        // Test that a preset name parses and resolves correctly for titles
944        let yaml = r#"titles: chicago"#;
945        let config: Config = serde_yaml::from_str(yaml).unwrap();
946        let titles = config.titles.unwrap();
947        assert_eq!(titles.component.unwrap().quote, Some(true));
948        assert_eq!(titles.monograph.unwrap().emph, Some(true));
949    }
950
951    #[test]
952    fn test_substitute_config_preset() {
953        // Test that a preset name parses correctly
954        let yaml = r#"substitute: standard"#;
955        let config: Config = serde_yaml::from_str(yaml).unwrap();
956        assert!(config.substitute.is_some());
957        let resolved = config.substitute.unwrap().resolve();
958        assert_eq!(resolved.template.len(), 3);
959        assert_eq!(resolved.template[0], SubstituteKey::Editor);
960    }
961
962    #[test]
963    fn test_substitute_config_explicit() {
964        // Test that explicit config still works
965        let yaml = r#"
966substitute:
967  template:
968    - title
969    - editor
970"#;
971        let config: Config = serde_yaml::from_str(yaml).unwrap();
972        let resolved = config.substitute.unwrap().resolve();
973        assert_eq!(resolved.template[0], SubstituteKey::Title);
974        assert_eq!(resolved.template[1], SubstituteKey::Editor);
975    }
976
977    #[test]
978    fn test_config_merge_precedence() {
979        // Base config with global options
980        let base_yaml = r#"
981processing: author-date
982locale-override: en-US-base
983contributors:
984  display-as-sort: first
985  and: symbol
986"#;
987        let mut base: Config = serde_yaml::from_str(base_yaml).unwrap();
988
989        // Override config (e.g., citation-specific options)
990        let override_yaml = r#"
991contributors:
992  and: text
993locale-override: en-US-chicago
994"#;
995        let override_config: Config = serde_yaml::from_str(override_yaml).unwrap();
996
997        // Merge: override takes precedence
998        base.merge(&override_config);
999
1000        // Processing should remain from base (not overridden)
1001        assert_eq!(base.processing, Some(Processing::AuthorDate));
1002        assert_eq!(base.locale_override.as_deref(), Some("en-US-chicago"));
1003
1004        // Contributors should be merged with override values taking precedence
1005        assert_eq!(
1006            base.contributors.as_ref().unwrap().and,
1007            Some(AndOptions::Text)
1008        );
1009    }
1010
1011    #[test]
1012    fn test_config_deserializes_locale_override() {
1013        let config: Config = serde_yaml::from_str("locale-override: en-US-chicago").unwrap();
1014        assert_eq!(config.locale_override.as_deref(), Some("en-US-chicago"));
1015    }
1016
1017    #[test]
1018    fn test_config_merged_convenience() {
1019        let base = Config {
1020            processing: Some(Processing::AuthorDate),
1021            ..Default::default()
1022        };
1023        let override_config = Config {
1024            punctuation_in_quote: true,
1025            ..Default::default()
1026        };
1027
1028        let merged = Config::merged(&base, &override_config);
1029
1030        // Both fields preserved
1031        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1032        assert!(merged.punctuation_in_quote);
1033    }
1034
1035    #[test]
1036    fn test_citation_options_merge_overrides_citation_fields_only() {
1037        let base = Config {
1038            processing: Some(Processing::AuthorDate),
1039            ..Default::default()
1040        };
1041
1042        let overrides = CitationOptions {
1043            strip_periods: Some(true),
1044            locators: Some(LocatorConfig::default()),
1045            ..Default::default()
1046        };
1047
1048        let merged = overrides.merged_with(&base);
1049        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1050        assert!(merged.strip_periods.unwrap_or(false));
1051        assert!(merged.locators.is_some());
1052    }
1053
1054    #[test]
1055    fn test_bibliography_options_merge_projects_shared_fields_only() {
1056        let base = Config {
1057            processing: Some(Processing::AuthorDate),
1058            ..Default::default()
1059        };
1060
1061        let overrides = BibliographyOptions {
1062            entry_suffix: Some(".".to_string()),
1063            separator: Some(", ".to_string()),
1064            suppress_period_after_url: true,
1065            ..Default::default()
1066        };
1067
1068        let merged = overrides.merged_with(&base);
1069        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1070        assert!(merged.locators.is_none());
1071        assert!(merged.notes.is_none());
1072        let bibliography = overrides.to_bibliography_config();
1073        assert_eq!(bibliography.entry_suffix.as_deref(), Some("."));
1074        assert_eq!(bibliography.separator.as_deref(), Some(", "));
1075        assert!(bibliography.suppress_period_after_url);
1076    }
1077
1078    #[test]
1079    fn test_bibliography_options_merge_leaves_shared_base_when_only_shared_overrides_exist() {
1080        let base = Config {
1081            processing: Some(Processing::AuthorDate),
1082            ..Default::default()
1083        };
1084
1085        let overrides = BibliographyOptions {
1086            contributors: Some(ContributorConfig::default()),
1087            ..Default::default()
1088        };
1089
1090        let merged = overrides.merged_with(&base);
1091        assert_eq!(merged.processing, Some(Processing::AuthorDate));
1092        assert!(merged.contributors.is_some());
1093    }
1094
1095    #[test]
1096    fn citation_options_captures_unknown_fields_for_forward_compat() {
1097        let yaml = "future-key: true\n";
1098        let opts: CitationOptions = serde_yaml::from_str(yaml).unwrap();
1099        assert!(opts.unknown_fields.contains_key("future-key"));
1100    }
1101
1102    #[test]
1103    fn bibliography_options_captures_unknown_fields_for_forward_compat() {
1104        let yaml = "future-key: true\n";
1105        let opts: BibliographyOptions = serde_yaml::from_str(yaml).unwrap();
1106        assert!(opts.unknown_fields.contains_key("future-key"));
1107    }
1108
1109    #[test]
1110    fn note_config_captures_unknown_fields_for_forward_compat() {
1111        let yaml = "punctuation: inside\nfuture-key: true\n";
1112        let cfg: NoteConfig = serde_yaml::from_str(yaml).unwrap();
1113        assert!(cfg.unknown_fields.contains_key("future-key"));
1114        assert_eq!(cfg.punctuation, Some(NoteQuotePlacement::Inside));
1115    }
1116}