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