Skip to main content

citum_schema_style/
presets.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Style presets for common formatting patterns.
7//!
8//! Presets are named bundles of configuration that encode common patterns from major
9//! citation styles. Instead of inheriting from a parent style, styles can compose
10//! presets for different concerns (contributors, dates, titles).
11//!
12//! ## Usage
13//!
14//! Style authors can use preset names for defaults and override individual settings:
15//!
16//! ```yaml
17//! options:
18//!   contributors: apa
19//!   dates: year-only
20//!   titles: apa
21//! ```
22//!
23//! ## Preset Expansion
24//!
25//! Each preset expands to concrete `Config` values. The style author can:
26//! 1. Use a preset name for defaults
27//! 2. Override individual fields as needed
28//! 3. Skip presets entirely and specify everything explicitly
29
30use crate::grouping::{GroupSort, GroupSortKey, SortKey as GroupSortKey_};
31use crate::options::multilingual::{
32    MultilingualConfig, MultilingualMode, MultilingualSegment, MultilingualView, ScriptConfig,
33    SegmentWrap,
34};
35use crate::options::{
36    AndOptions, ContributorConfig, DateConfig, DelimiterPrecedesLast, DemoteNonDroppingParticle,
37    DisplayAsSort, MonthFormat, NameForm, ShortenListOptions, Sort, SortKey, SortSpec, Substitute,
38    SubstituteKey, TitleRendering, TitlesConfig,
39};
40#[cfg(feature = "schema")]
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43use std::collections::HashMap;
44
45/// Contributor formatting presets.
46///
47/// Each preset encodes the contributor formatting conventions for a major citation
48/// style or style family. Use doc comments to describe the visual behavior so
49/// style authors can choose the right preset without knowing style guide names.
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
51#[cfg_attr(feature = "schema", derive(JsonSchema))]
52#[serde(rename_all = "kebab-case")]
53#[non_exhaustive]
54pub enum ContributorPreset {
55    /// First author family-first, "&" symbol, et al. after 20 authors,
56    /// initials with period-space, comma before "&".
57    /// Example: "Smith, J. D., & Jones, M. K."
58    Apa,
59    /// First author family-first, "and" text, contextual serial comma,
60    /// full given names (no initials).
61    /// Example: "Smith, John D., and Mary K. Jones"
62    Chicago,
63    /// All authors family-first, no conjunction, compact initials (no
64    /// period/space), et al. after 6 of 7+.
65    /// Example: "Smith JD, Jones MK, Brown AB"
66    Vancouver,
67    /// Given-first format, "and" text, initials with period-space,
68    /// comma before "and".
69    /// Example: "J. D. Smith, M. K. Jones, and A. B. Brown"
70    Ieee,
71    /// All authors family-first, "and" text, compact initials (period,
72    /// no space), comma before "and".
73    /// Example: "Smith, J.D., Jones, M.K., and Brown, A.B."
74    Harvard,
75    /// All authors family-first, no conjunction, compact initials (no
76    /// period/space), space sort-separator, et al. after 3 of 5+.
77    /// Example: "Smith JD, Jones MK, Brown AB"
78    Springer,
79    /// Numeric compact author list for journal-heavy corpora:
80    /// all family-first, no conjunction, sort-only particle demotion,
81    /// space sort-separator, et al. after 6 of 7+.
82    NumericCompact,
83    /// Numeric medium author list variant:
84    /// same as `numeric-compact`, but et al. after 3 of 4+.
85    NumericMedium,
86    /// Numeric tight: all family-first, no initials, et al. after 3 of 7+.
87    /// Tighter than `numeric-compact` (use-first: 3 vs 6).
88    /// Example: "Smith J, Jones M, Brown A, et al."
89    NumericTight,
90    /// Numeric large: all family-first, no initials, et al. after 10 of 11+.
91    /// For biomedical journals that show nearly all authors.
92    /// Example: "Smith J, Jones M, Brown A, [10 authors], et al."
93    NumericLarge,
94    /// Numeric all-authors: all family-first, no conjunction, compact initials,
95    /// no list shortening, particle demotion disabled.
96    /// Example: "Smith JD, Jones MK, Brown AB"
97    NumericAllAuthors,
98    /// Numeric given-first with period-only initials (no space), no conjunction,
99    /// and comma delimiters.
100    /// Example: "J.D. Smith, M.K. Jones, A.B. Brown"
101    NumericGivenDot,
102    /// Annual Reviews style: all family-first, no initials, et al. after 5 of 7+,
103    /// particle demotion never. Distinguishable by "never" demote policy.
104    /// Example: "van der Berg J, Smith M, Jones A, Brown B, White C, et al."
105    AnnualReviews,
106    /// Math/physics author-date: all family-first, period initial (no trailing
107    /// space), comma sort-separator, no conjunction. Used across Springer
108    /// math/physics and related author-date journals.
109    /// Example: "Smith, J., Jones, M., Brown, A."
110    MathPhys,
111    /// Social science first-author inversion: first author family-first,
112    /// remaining authors given-first, period-space initials, comma
113    /// sort-separator, no conjunction. Common in sociology and civil engineering.
114    /// Example: "Smith, J. D., M. K. Jones, A. B. Brown"
115    SocSciFirst,
116    /// Physics numeric given-first: no author inversion, period-space initial,
117    /// no conjunction, sort-only particle demotion. Used by numeric physics
118    /// journals like APS.
119    /// Example: "J. Smith, M. Jones, A. Brown"
120    PhysicsNumeric,
121}
122
123impl ContributorPreset {
124    /// Convert named-style presets to config.
125    fn config_named_presets(&self) -> ContributorConfig {
126        match self {
127            ContributorPreset::Apa => ContributorConfig {
128                display_as_sort: Some(DisplayAsSort::First),
129                and: Some(AndOptions::Symbol),
130                delimiter: Some(", ".to_string()),
131                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
132                initialize_with: Some(". ".to_string()),
133                name_form: Some(NameForm::Initials),
134                shorten: Some(ShortenListOptions {
135                    min: 21,
136                    use_first: 19,
137                    ..Default::default()
138                }),
139                ..Default::default()
140            },
141            ContributorPreset::Chicago => ContributorConfig {
142                display_as_sort: Some(DisplayAsSort::First),
143                and: Some(AndOptions::Text),
144                delimiter: Some(", ".to_string()),
145                delimiter_precedes_last: Some(DelimiterPrecedesLast::Contextual),
146                ..Default::default()
147            },
148            ContributorPreset::Vancouver => ContributorConfig {
149                display_as_sort: Some(DisplayAsSort::All),
150                and: Some(AndOptions::None),
151                delimiter: Some(", ".to_string()),
152                initialize_with: Some("".to_string()),
153                name_form: Some(NameForm::Initials),
154                shorten: Some(ShortenListOptions {
155                    min: 7,
156                    use_first: 6,
157                    ..Default::default()
158                }),
159                ..Default::default()
160            },
161            ContributorPreset::Ieee => ContributorConfig {
162                display_as_sort: Some(DisplayAsSort::None),
163                and: Some(AndOptions::Text),
164                delimiter: Some(", ".to_string()),
165                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
166                initialize_with: Some(". ".to_string()),
167                name_form: Some(NameForm::Initials),
168                ..Default::default()
169            },
170            ContributorPreset::Harvard => ContributorConfig {
171                display_as_sort: Some(DisplayAsSort::All),
172                and: Some(AndOptions::Text),
173                delimiter: Some(", ".to_string()),
174                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
175                initialize_with: Some(".".to_string()),
176                name_form: Some(NameForm::Initials),
177                ..Default::default()
178            },
179            ContributorPreset::Springer => ContributorConfig {
180                display_as_sort: Some(DisplayAsSort::All),
181                and: Some(AndOptions::None),
182                delimiter: Some(", ".to_string()),
183                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
184                initialize_with: Some("".to_string()),
185                name_form: Some(NameForm::Initials),
186                sort_separator: Some(" ".to_string()),
187                shorten: Some(ShortenListOptions {
188                    min: 5,
189                    use_first: 3,
190                    ..Default::default()
191                }),
192                ..Default::default()
193            },
194            #[allow(clippy::unreachable, reason = "Subset of variants handled here")]
195            _ => unreachable!(),
196        }
197    }
198
199    /// Convert numeric-style presets to config.
200    fn config_numeric_presets(&self) -> ContributorConfig {
201        match self {
202            ContributorPreset::NumericCompact => ContributorConfig {
203                display_as_sort: Some(DisplayAsSort::All),
204                and: Some(AndOptions::None),
205                delimiter: Some(", ".to_string()),
206                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
207                initialize_with: Some("".to_string()),
208                name_form: Some(NameForm::Initials),
209                sort_separator: Some(" ".to_string()),
210                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
211                shorten: Some(ShortenListOptions {
212                    min: 7,
213                    use_first: 6,
214                    ..Default::default()
215                }),
216                ..Default::default()
217            },
218            ContributorPreset::NumericMedium => ContributorConfig {
219                display_as_sort: Some(DisplayAsSort::All),
220                and: Some(AndOptions::None),
221                delimiter: Some(", ".to_string()),
222                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
223                initialize_with: Some("".to_string()),
224                name_form: Some(NameForm::Initials),
225                sort_separator: Some(" ".to_string()),
226                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
227                shorten: Some(ShortenListOptions {
228                    min: 4,
229                    use_first: 3,
230                    ..Default::default()
231                }),
232                ..Default::default()
233            },
234            ContributorPreset::NumericTight => ContributorConfig {
235                display_as_sort: Some(DisplayAsSort::All),
236                and: Some(AndOptions::None),
237                delimiter: Some(", ".to_string()),
238                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
239                initialize_with: Some("".to_string()),
240                name_form: Some(NameForm::Initials),
241                sort_separator: Some(" ".to_string()),
242                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
243                shorten: Some(ShortenListOptions {
244                    min: 7,
245                    use_first: 3,
246                    ..Default::default()
247                }),
248                ..Default::default()
249            },
250            ContributorPreset::NumericLarge => ContributorConfig {
251                display_as_sort: Some(DisplayAsSort::All),
252                and: Some(AndOptions::None),
253                delimiter: Some(", ".to_string()),
254                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
255                initialize_with: Some("".to_string()),
256                name_form: Some(NameForm::Initials),
257                sort_separator: Some(" ".to_string()),
258                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
259                shorten: Some(ShortenListOptions {
260                    min: 11,
261                    use_first: 10,
262                    ..Default::default()
263                }),
264                ..Default::default()
265            },
266            ContributorPreset::NumericAllAuthors => ContributorConfig {
267                display_as_sort: Some(DisplayAsSort::All),
268                and: Some(AndOptions::None),
269                delimiter: Some(", ".to_string()),
270                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
271                initialize_with: Some("".to_string()),
272                name_form: Some(NameForm::Initials),
273                sort_separator: Some(" ".to_string()),
274                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::Never),
275                ..Default::default()
276            },
277            ContributorPreset::NumericGivenDot => ContributorConfig {
278                display_as_sort: Some(DisplayAsSort::None),
279                and: Some(AndOptions::None),
280                delimiter: Some(", ".to_string()),
281                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
282                initialize_with: Some(".".to_string()),
283                name_form: Some(NameForm::Initials),
284                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
285                ..Default::default()
286            },
287            #[allow(clippy::unreachable, reason = "Subset of variants handled here")]
288            _ => unreachable!(),
289        }
290    }
291
292    /// Convert specialty-style presets to config.
293    fn config_specialty_presets(&self) -> ContributorConfig {
294        match self {
295            ContributorPreset::AnnualReviews => ContributorConfig {
296                display_as_sort: Some(DisplayAsSort::All),
297                and: Some(AndOptions::None),
298                delimiter: Some(", ".to_string()),
299                delimiter_precedes_last: Some(DelimiterPrecedesLast::Never),
300                initialize_with: Some("".to_string()),
301                name_form: Some(NameForm::Initials),
302                sort_separator: Some(" ".to_string()),
303                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::Never),
304                shorten: Some(ShortenListOptions {
305                    min: 7,
306                    use_first: 5,
307                    ..Default::default()
308                }),
309                ..Default::default()
310            },
311            ContributorPreset::MathPhys => ContributorConfig {
312                display_as_sort: Some(DisplayAsSort::All),
313                and: Some(AndOptions::None),
314                delimiter: Some(", ".to_string()),
315                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
316                initialize_with: Some(".".to_string()),
317                name_form: Some(NameForm::Initials),
318                sort_separator: Some(", ".to_string()),
319                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
320                ..Default::default()
321            },
322            ContributorPreset::SocSciFirst => ContributorConfig {
323                display_as_sort: Some(DisplayAsSort::First),
324                and: Some(AndOptions::None),
325                delimiter: Some(", ".to_string()),
326                delimiter_precedes_last: Some(DelimiterPrecedesLast::Always),
327                initialize_with: Some(". ".to_string()),
328                name_form: Some(NameForm::Initials),
329                sort_separator: Some(", ".to_string()),
330                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
331                ..Default::default()
332            },
333            ContributorPreset::PhysicsNumeric => ContributorConfig {
334                display_as_sort: Some(DisplayAsSort::None),
335                and: Some(AndOptions::None),
336                delimiter: Some(", ".to_string()),
337                initialize_with: Some(". ".to_string()),
338                name_form: Some(NameForm::Initials),
339                demote_non_dropping_particle: Some(DemoteNonDroppingParticle::SortOnly),
340                ..Default::default()
341            },
342            #[allow(clippy::unreachable, reason = "Subset of variants handled here")]
343            _ => unreachable!(),
344        }
345    }
346
347    /// Convert this preset to a concrete `ContributorConfig`.
348    pub fn config(&self) -> ContributorConfig {
349        match self {
350            ContributorPreset::Apa
351            | ContributorPreset::Chicago
352            | ContributorPreset::Vancouver
353            | ContributorPreset::Ieee
354            | ContributorPreset::Harvard
355            | ContributorPreset::Springer => self.config_named_presets(),
356            ContributorPreset::NumericCompact
357            | ContributorPreset::NumericMedium
358            | ContributorPreset::NumericTight
359            | ContributorPreset::NumericLarge
360            | ContributorPreset::NumericAllAuthors
361            | ContributorPreset::NumericGivenDot => self.config_numeric_presets(),
362            ContributorPreset::AnnualReviews
363            | ContributorPreset::MathPhys
364            | ContributorPreset::SocSciFirst
365            | ContributorPreset::PhysicsNumeric => self.config_specialty_presets(),
366        }
367    }
368}
369
370/// Date formatting presets.
371///
372/// Each preset defines how dates are displayed in citations and bibliographies,
373/// including month format, EDTF uncertainty/approximation markers, and range
374/// delimiters.
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
376#[cfg_attr(feature = "schema", derive(JsonSchema))]
377#[serde(rename_all = "kebab-case")]
378#[non_exhaustive]
379pub enum DatePreset {
380    /// Long month names, EDTF markers, en-dash ranges.
381    /// Example: "January 15, 2024", "ca. 2024", "2024?"
382    Long,
383    /// Short month names, EDTF markers, en-dash ranges.
384    /// Example: "Jan 15, 2024"
385    Short,
386    /// Numeric months, EDTF markers, en-dash ranges.
387    /// Example: "1/15/2024"
388    Numeric,
389    /// ISO 8601 numeric format, no EDTF markers.
390    /// Example: "2024-01-15"
391    Iso,
392}
393
394impl DatePreset {
395    /// Convert this preset to a concrete `DateConfig`.
396    pub fn config(&self) -> DateConfig {
397        match self {
398            DatePreset::Long => DateConfig {
399                month: MonthFormat::Long,
400                ..Default::default()
401            },
402            DatePreset::Short => DateConfig {
403                month: MonthFormat::Short,
404                ..Default::default()
405            },
406            DatePreset::Numeric => DateConfig {
407                month: MonthFormat::Numeric,
408                ..Default::default()
409            },
410            DatePreset::Iso => DateConfig {
411                month: MonthFormat::Numeric,
412                uncertainty_marker: None,
413                approximation_marker: None,
414                ..Default::default()
415            },
416        }
417    }
418}
419
420/// Title formatting presets.
421///
422/// Each preset defines how different types of titles (articles, books, journals)
423/// are formatted. Presets typically differ in whether titles are quoted, italicized,
424/// or plain.
425#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
426#[cfg_attr(feature = "schema", derive(JsonSchema))]
427#[serde(rename_all = "kebab-case")]
428#[non_exhaustive]
429pub enum TitlePreset {
430    /// APA style: article titles plain, book/journal titles italic.
431    /// Example: Article title. *Book Title*. *Journal Title*.
432    Apa,
433    /// Chicago style: article titles quoted, book/journal titles italic.
434    /// Example: "Article Title." *Book Title*. *Journal Title*.
435    Chicago,
436    /// IEEE style: article titles quoted, book/journal titles italic.
437    /// Example: "Article title," *Book Title*. *Journal Title*.
438    Ieee,
439    /// Humanities style: monographs, periodicals, and serials all italic,
440    /// articles plain. Common in geography, history, and social sciences.
441    /// Example: Article title. *Book Title*. *Journal Title*. *Series Title*.
442    Humanities,
443    /// Journal-focused emphasis: periodicals and serials italic,
444    /// monographs plain.
445    JournalEmphasis,
446    /// Scientific/Vancouver style: all titles plain (no formatting).
447    /// Example: Article title. Book title. Journal title.
448    Scientific,
449}
450
451impl TitlePreset {
452    /// Convert this preset to a concrete `TitlesConfig`.
453    pub fn config(&self) -> TitlesConfig {
454        use crate::options::titles::TextCase;
455        let emph_rendering = TitleRendering {
456            emph: Some(true),
457            ..Default::default()
458        };
459        match self {
460            TitlePreset::Apa => TitlesConfig {
461                component: Some(TitleRendering {
462                    text_case: Some(TextCase::SentenceApa),
463                    ..Default::default()
464                }),
465                monograph: Some(TitleRendering {
466                    text_case: Some(TextCase::SentenceApa),
467                    emph: Some(true),
468                    ..Default::default()
469                }),
470                periodical: Some(emph_rendering),
471                ..Default::default()
472            },
473            TitlePreset::Chicago | TitlePreset::Ieee => TitlesConfig {
474                component: Some(TitleRendering {
475                    quote: Some(true),
476                    ..Default::default()
477                }),
478                monograph: Some(emph_rendering.clone()),
479                periodical: Some(emph_rendering),
480                ..Default::default()
481            },
482            TitlePreset::Humanities => TitlesConfig {
483                component: Some(TitleRendering::default()),
484                monograph: Some(emph_rendering.clone()),
485                periodical: Some(emph_rendering.clone()),
486                serial: Some(emph_rendering),
487                ..Default::default()
488            },
489            TitlePreset::JournalEmphasis => TitlesConfig {
490                component: Some(TitleRendering::default()),
491                periodical: Some(emph_rendering.clone()),
492                serial: Some(emph_rendering),
493                ..Default::default()
494            },
495            TitlePreset::Scientific => TitlesConfig {
496                component: Some(TitleRendering {
497                    text_case: Some(TextCase::SentenceNlm),
498                    ..Default::default()
499                }),
500                monograph: Some(TitleRendering {
501                    text_case: Some(TextCase::SentenceNlm),
502                    ..Default::default()
503                }),
504                periodical: Some(TitleRendering::default()),
505                ..Default::default()
506            },
507        }
508    }
509}
510
511/// Sort order presets for bibliography entries.
512///
513/// Each preset encodes the sort key sequence for a citation style family.
514/// Use for the `bibliography.sort` field to avoid repeating boilerplate key lists.
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[cfg_attr(feature = "schema", derive(JsonSchema))]
517#[serde(rename_all = "kebab-case")]
518#[non_exhaustive]
519pub enum SortPreset {
520    /// Author → year → title. Standard for author-date styles (APA, Chicago, Harvard).
521    AuthorDateTitle,
522    /// Author → title → year. Used in some footnote and note styles.
523    AuthorTitleDate,
524    /// Citation number only. Used in numeric styles (Vancouver, IEEE).
525    CitationNumber,
526}
527
528impl SortPreset {
529    /// Convert this preset to a concrete `Sort`.
530    pub fn sort(&self) -> Sort {
531        match self {
532            SortPreset::AuthorDateTitle => Sort {
533                shorten_names: false,
534                render_substitutions: false,
535                template: vec![
536                    SortSpec {
537                        key: SortKey::Author,
538                        ascending: true,
539                    },
540                    SortSpec {
541                        key: SortKey::Year,
542                        ascending: true,
543                    },
544                    SortSpec {
545                        key: SortKey::Title,
546                        ascending: true,
547                    },
548                ],
549            },
550            SortPreset::AuthorTitleDate => Sort {
551                shorten_names: false,
552                render_substitutions: false,
553                template: vec![
554                    SortSpec {
555                        key: SortKey::Author,
556                        ascending: true,
557                    },
558                    SortSpec {
559                        key: SortKey::Title,
560                        ascending: true,
561                    },
562                    SortSpec {
563                        key: SortKey::Year,
564                        ascending: true,
565                    },
566                ],
567            },
568            SortPreset::CitationNumber => Sort {
569                shorten_names: false,
570                render_substitutions: false,
571                template: vec![SortSpec {
572                    key: SortKey::CitationNumber,
573                    ascending: true,
574                }],
575            },
576        }
577    }
578
579    /// Convert this preset to a `GroupSort` for use in citation sorting.
580    pub fn group_sort(&self) -> GroupSort {
581        let keys: Vec<GroupSortKey> = match self {
582            SortPreset::AuthorDateTitle => vec![
583                GroupSortKey {
584                    key: GroupSortKey_::Author,
585                    ascending: true,
586                    order: None,
587                    sort_order: None,
588                },
589                GroupSortKey {
590                    key: GroupSortKey_::Issued,
591                    ascending: true,
592                    order: None,
593                    sort_order: None,
594                },
595                GroupSortKey {
596                    key: GroupSortKey_::Title,
597                    ascending: true,
598                    order: None,
599                    sort_order: None,
600                },
601            ],
602            SortPreset::AuthorTitleDate => vec![
603                GroupSortKey {
604                    key: GroupSortKey_::Author,
605                    ascending: true,
606                    order: None,
607                    sort_order: None,
608                },
609                GroupSortKey {
610                    key: GroupSortKey_::Title,
611                    ascending: true,
612                    order: None,
613                    sort_order: None,
614                },
615                GroupSortKey {
616                    key: GroupSortKey_::Issued,
617                    ascending: true,
618                    order: None,
619                    sort_order: None,
620                },
621            ],
622            SortPreset::CitationNumber => vec![],
623        };
624        GroupSort { template: keys }
625    }
626}
627
628/// Substitute presets for author substitution fallback logic.
629///
630/// These presets define the order in which fields are tried when the primary
631/// author is missing. Most styles follow the standard order, but some have
632/// variations.
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634#[cfg_attr(feature = "schema", derive(JsonSchema))]
635#[serde(rename_all = "kebab-case")]
636#[non_exhaustive]
637pub enum SubstitutePreset {
638    /// Standard substitution order: Editor → Title → Translator.
639    /// Used by most citation styles (APA, Chicago, etc.).
640    Standard,
641    /// Editor-first: Editor → Translator → Title.
642    /// Prioritizes contributors over title.
643    EditorFirst,
644    /// Title-first: Title → Editor → Translator.
645    /// Used when anonymous works should show title prominently.
646    TitleFirst,
647    /// Editor only with short role labels.
648    EditorShort,
649    /// Editor only with long role labels.
650    EditorLong,
651    /// Editor then translator with short role labels.
652    EditorTranslatorShort,
653    /// Editor then translator with long role labels.
654    EditorTranslatorLong,
655    /// Editor then title with short role labels.
656    EditorTitleShort,
657    /// Editor then title with long role labels.
658    EditorTitleLong,
659    /// Editor then translator then title with short role labels.
660    EditorTranslatorTitleShort,
661    /// Editor then translator then title with long role labels.
662    EditorTranslatorTitleLong,
663}
664
665impl SubstitutePreset {
666    /// Convert this preset to a concrete `Substitute`.
667    pub fn config(&self) -> Substitute {
668        match self {
669            SubstitutePreset::Standard => Substitute {
670                contributor_role_form: None,
671                template: vec![
672                    SubstituteKey::Editor,
673                    SubstituteKey::Title,
674                    SubstituteKey::Translator,
675                ],
676                overrides: HashMap::new(),
677                role_substitute: HashMap::new(),
678                unknown_fields: std::collections::BTreeMap::new(),
679            },
680            SubstitutePreset::EditorFirst => Substitute {
681                contributor_role_form: None,
682                template: vec![
683                    SubstituteKey::Editor,
684                    SubstituteKey::Translator,
685                    SubstituteKey::Title,
686                ],
687                overrides: HashMap::new(),
688                role_substitute: HashMap::new(),
689                unknown_fields: std::collections::BTreeMap::new(),
690            },
691            SubstitutePreset::TitleFirst => Substitute {
692                contributor_role_form: None,
693                template: vec![
694                    SubstituteKey::Title,
695                    SubstituteKey::Editor,
696                    SubstituteKey::Translator,
697                ],
698                overrides: HashMap::new(),
699                role_substitute: HashMap::new(),
700                unknown_fields: std::collections::BTreeMap::new(),
701            },
702            SubstitutePreset::EditorShort => Substitute {
703                contributor_role_form: Some("short".to_string()),
704                template: vec![SubstituteKey::Editor],
705                overrides: HashMap::new(),
706                role_substitute: HashMap::new(),
707                unknown_fields: std::collections::BTreeMap::new(),
708            },
709            SubstitutePreset::EditorLong => Substitute {
710                contributor_role_form: Some("long".to_string()),
711                template: vec![SubstituteKey::Editor],
712                overrides: HashMap::new(),
713                role_substitute: HashMap::new(),
714                unknown_fields: std::collections::BTreeMap::new(),
715            },
716            SubstitutePreset::EditorTranslatorShort => Substitute {
717                contributor_role_form: Some("short".to_string()),
718                template: vec![SubstituteKey::Editor, SubstituteKey::Translator],
719                overrides: HashMap::new(),
720                role_substitute: HashMap::new(),
721                unknown_fields: std::collections::BTreeMap::new(),
722            },
723            SubstitutePreset::EditorTranslatorLong => Substitute {
724                contributor_role_form: Some("long".to_string()),
725                template: vec![SubstituteKey::Editor, SubstituteKey::Translator],
726                overrides: HashMap::new(),
727                role_substitute: HashMap::new(),
728                unknown_fields: std::collections::BTreeMap::new(),
729            },
730            SubstitutePreset::EditorTitleShort => Substitute {
731                contributor_role_form: Some("short".to_string()),
732                template: vec![SubstituteKey::Editor, SubstituteKey::Title],
733                overrides: HashMap::new(),
734                role_substitute: HashMap::new(),
735                unknown_fields: std::collections::BTreeMap::new(),
736            },
737            SubstitutePreset::EditorTitleLong => Substitute {
738                contributor_role_form: Some("long".to_string()),
739                template: vec![SubstituteKey::Editor, SubstituteKey::Title],
740                overrides: HashMap::new(),
741                role_substitute: HashMap::new(),
742                unknown_fields: std::collections::BTreeMap::new(),
743            },
744            SubstitutePreset::EditorTranslatorTitleShort => Substitute {
745                contributor_role_form: Some("short".to_string()),
746                template: vec![
747                    SubstituteKey::Editor,
748                    SubstituteKey::Translator,
749                    SubstituteKey::Title,
750                ],
751                overrides: HashMap::new(),
752                role_substitute: HashMap::new(),
753                unknown_fields: std::collections::BTreeMap::new(),
754            },
755            SubstitutePreset::EditorTranslatorTitleLong => Substitute {
756                contributor_role_form: Some("long".to_string()),
757                template: vec![
758                    SubstituteKey::Editor,
759                    SubstituteKey::Translator,
760                    SubstituteKey::Title,
761                ],
762                overrides: HashMap::new(),
763                role_substitute: HashMap::new(),
764                unknown_fields: std::collections::BTreeMap::new(),
765            },
766        }
767    }
768}
769
770/// Multilingual rendering policy presets.
771///
772/// Each preset encodes the romanization and translation conventions for a major
773/// citation style or style family when rendering references in an English-language
774/// context.  Style YAML authors can write a bare preset name:
775///
776/// ```yaml
777/// options:
778///   multilingual: romanized-translated
779/// ```
780///
781/// and Citum resolves it to the full [`MultilingualConfig`] at load time.
782///
783/// Preset names describe the **rendering behavior**, not a specific style family.
784/// For CJK-inclusive 3-way views (e.g. `romanized original [translated]`) use an
785/// explicit `pattern:` block instead — that display is something these styles
786/// *allow*, not something they mandate by default.
787#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
788#[cfg_attr(feature = "schema", derive(JsonSchema))]
789#[serde(rename_all = "kebab-case")]
790#[non_exhaustive]
791pub enum MultilingualPreset {
792    /// Romanize titles and append an English translation in brackets.
793    ///
794    /// Title pattern: `romanized [translated]` (= `Combined` mode).
795    /// Names: romanized.  Script: `Latn`.
796    ///
797    /// Appropriate for APA, Chicago, MLA, Harvard, Vancouver, AMA, NLM, CSE,
798    /// and most other English-language styles.  All of these styles require
799    /// romanized names and romanized titles; translation is recommended or
800    /// required for non-English titles; original-script display is a house
801    /// option that styles *allow* but do not mandate.
802    RomanizedTranslated,
803    /// Romanize titles and names only — no translation appended.
804    ///
805    /// Title pattern: romanized form only (= `Transliterated` mode).
806    /// Names: romanized.  Script: `Latn`.
807    ///
808    /// Appropriate for IEEE and numeric styles where the translation bracket
809    /// is typically omitted.
810    RomanizedOnly,
811    /// Three-part view: romanized form, original script, bracketed translation.
812    ///
813    /// Title pattern: `romanized original-script [translated]`.
814    /// Names: `romanized original-script` (no bracket).
815    /// Script: `Latn`.  Han and Hangul names rendered family-first with no inter-part space.
816    ///
817    /// This is the rendering pattern used by some area-studies and East Asian studies
818    /// house styles (e.g. CJKR, JAAS).  It is **not** a mandated disciplinary standard —
819    /// it is a display convention that some publishers allow or require.
820    ///
821    /// When CJK names render in original script the `use-native-ordering` flag ensures
822    /// family-first ordering.  For Latin-script (romanized) names, ordering follows the
823    /// normal template `name-order`.
824    RomanizedScriptTranslated,
825}
826
827impl MultilingualPreset {
828    /// Resolve this preset into a concrete [`MultilingualConfig`].
829    pub fn config(self) -> MultilingualConfig {
830        match self {
831            MultilingualPreset::RomanizedTranslated => MultilingualConfig {
832                title_mode: Some(MultilingualMode::Combined),
833                name_mode: Some(MultilingualMode::Transliterated),
834                preferred_script: Some("Latn".to_string()),
835                ..Default::default()
836            },
837            MultilingualPreset::RomanizedOnly => MultilingualConfig {
838                title_mode: Some(MultilingualMode::Transliterated),
839                name_mode: Some(MultilingualMode::Transliterated),
840                preferred_script: Some("Latn".to_string()),
841                ..Default::default()
842            },
843            MultilingualPreset::RomanizedScriptTranslated => {
844                let mut scripts = HashMap::new();
845                scripts.insert(
846                    "Han".to_string(),
847                    ScriptConfig {
848                        use_native_ordering: true,
849                        ..Default::default()
850                    },
851                );
852                scripts.insert(
853                    "Hangul".to_string(),
854                    ScriptConfig {
855                        use_native_ordering: true,
856                        ..Default::default()
857                    },
858                );
859                MultilingualConfig {
860                    title_mode: Some(MultilingualMode::Pattern(vec![
861                        MultilingualSegment {
862                            view: MultilingualView::Transliterated,
863                            wrap: SegmentWrap::None,
864                        },
865                        MultilingualSegment {
866                            view: MultilingualView::OriginalScript,
867                            wrap: SegmentWrap::None,
868                        },
869                        MultilingualSegment {
870                            view: MultilingualView::Translated,
871                            wrap: SegmentWrap::Brackets,
872                        },
873                    ])),
874                    name_mode: Some(MultilingualMode::Pattern(vec![
875                        MultilingualSegment {
876                            view: MultilingualView::Transliterated,
877                            wrap: SegmentWrap::None,
878                        },
879                        MultilingualSegment {
880                            view: MultilingualView::OriginalScript,
881                            wrap: SegmentWrap::None,
882                        },
883                    ])),
884                    preferred_script: Some("Latn".to_string()),
885                    scripts,
886                    ..Default::default()
887                }
888            }
889        }
890    }
891}
892
893/// Entry for `options.multilingual`: either a preset name or an explicit config block.
894///
895/// Style YAML can use a short preset name:
896/// ```yaml
897/// options:
898///   multilingual: romanized-translated
899/// ```
900/// or a full explicit block:
901/// ```yaml
902/// options:
903///   multilingual:
904///     title-mode: combined
905///     name-mode: transliterated
906///     preferred-script: Latn
907/// ```
908#[derive(Debug, Clone, Serialize, PartialEq)]
909#[cfg_attr(feature = "schema", derive(JsonSchema))]
910#[serde(untagged)]
911pub enum MultilingualConfigEntry {
912    /// A named preset (`"romanized-translated"` or `"romanized-only"`).
913    Preset(MultilingualPreset),
914    /// An explicit multilingual configuration block.
915    Explicit(Box<MultilingualConfig>),
916}
917
918impl MultilingualConfigEntry {
919    /// Resolve this entry into a concrete [`MultilingualConfig`].
920    pub fn resolve(self) -> MultilingualConfig {
921        match self {
922            MultilingualConfigEntry::Preset(p) => p.config(),
923            MultilingualConfigEntry::Explicit(c) => *c,
924        }
925    }
926}
927
928/// Custom deserializer for [`MultilingualConfigEntry`].
929///
930/// Accepts either a bare preset name (`"romanized-translated"`, `"romanized-only"`) or a full
931/// `MultilingualConfig` map block. A hand-written visitor is used instead of
932/// `#[serde(untagged)]` because the untagged mechanism wraps inner
933/// deserializers in a content-based buffer that converts externally-tagged
934/// YAML maps (like `{pattern: [...]}`) into serde enum inputs — and those
935/// inputs cannot be re-dispatched through `deserialize_any`, causing a
936/// *"untagged and internally tagged enums do not support enum input"* error
937/// on serialization roundtrips.
938impl<'de> serde::Deserialize<'de> for MultilingualConfigEntry {
939    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
940    where
941        D: serde::Deserializer<'de>,
942    {
943        struct EntryVisitor;
944
945        impl<'de> serde::de::Visitor<'de> for EntryVisitor {
946            type Value = MultilingualConfigEntry;
947
948            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
949                write!(f, "a multilingual preset name or an explicit config block")
950            }
951
952            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
953                let preset =
954                    MultilingualPreset::deserialize(serde::de::value::StrDeserializer::new(v))?;
955                Ok(MultilingualConfigEntry::Preset(preset))
956            }
957
958            fn visit_map<A: serde::de::MapAccess<'de>>(
959                self,
960                map: A,
961            ) -> Result<Self::Value, A::Error> {
962                let config = MultilingualConfig::deserialize(
963                    serde::de::value::MapAccessDeserializer::new(map),
964                )?;
965                Ok(MultilingualConfigEntry::Explicit(Box::new(config)))
966            }
967        }
968
969        deserializer.deserialize_any(EntryVisitor)
970    }
971}
972
973#[cfg(test)]
974#[allow(
975    clippy::unwrap_used,
976    clippy::expect_used,
977    clippy::panic,
978    clippy::indexing_slicing,
979    clippy::todo,
980    clippy::unimplemented,
981    clippy::unreachable,
982    clippy::get_unwrap,
983    reason = "Panicking is acceptable and often desired in tests."
984)]
985mod tests {
986    use super::*;
987
988    #[test]
989    fn test_contributor_preset_apa() {
990        let config = ContributorPreset::Apa.config();
991        assert_eq!(config.and, Some(AndOptions::Symbol));
992        assert_eq!(config.display_as_sort, Some(DisplayAsSort::First));
993        let shorten = config.shorten.unwrap();
994        assert_eq!(shorten.min, 21);
995        assert_eq!(shorten.use_first, 19);
996    }
997
998    #[test]
999    fn test_contributor_preset_chicago() {
1000        let config = ContributorPreset::Chicago.config();
1001        assert_eq!(config.and, Some(AndOptions::Text));
1002        assert_eq!(config.display_as_sort, Some(DisplayAsSort::First));
1003    }
1004
1005    #[test]
1006    fn test_contributor_preset_vancouver() {
1007        let config = ContributorPreset::Vancouver.config();
1008        assert_eq!(config.and, Some(AndOptions::None));
1009        assert_eq!(config.display_as_sort, Some(DisplayAsSort::All));
1010    }
1011
1012    #[test]
1013    fn test_contributor_preset_springer() {
1014        let config = ContributorPreset::Springer.config();
1015        assert_eq!(config.and, Some(AndOptions::None));
1016        assert_eq!(config.display_as_sort, Some(DisplayAsSort::All));
1017        assert_eq!(config.sort_separator, Some(" ".to_string()));
1018        let shorten = config.shorten.unwrap();
1019        assert_eq!(shorten.min, 5);
1020        assert_eq!(shorten.use_first, 3);
1021    }
1022
1023    #[test]
1024    fn test_contributor_preset_numeric_compact() {
1025        let config = ContributorPreset::NumericCompact.config();
1026        assert_eq!(config.and, Some(AndOptions::None));
1027        assert_eq!(config.display_as_sort, Some(DisplayAsSort::All));
1028        assert_eq!(config.sort_separator, Some(" ".to_string()));
1029        assert_eq!(
1030            config.demote_non_dropping_particle,
1031            Some(DemoteNonDroppingParticle::SortOnly)
1032        );
1033        let shorten = config.shorten.unwrap();
1034        assert_eq!(shorten.min, 7);
1035        assert_eq!(shorten.use_first, 6);
1036    }
1037
1038    #[test]
1039    fn test_contributor_preset_numeric_all_authors() {
1040        let config = ContributorPreset::NumericAllAuthors.config();
1041        assert_eq!(config.and, Some(AndOptions::None));
1042        assert_eq!(config.display_as_sort, Some(DisplayAsSort::All));
1043        assert_eq!(config.sort_separator, Some(" ".to_string()));
1044        assert_eq!(config.initialize_with, Some("".to_string()));
1045        assert_eq!(
1046            config.demote_non_dropping_particle,
1047            Some(DemoteNonDroppingParticle::Never)
1048        );
1049        assert!(config.shorten.is_none());
1050    }
1051
1052    #[test]
1053    fn test_contributor_preset_numeric_given_dot() {
1054        let config = ContributorPreset::NumericGivenDot.config();
1055        assert_eq!(config.and, Some(AndOptions::None));
1056        assert_eq!(config.display_as_sort, Some(DisplayAsSort::None));
1057        assert_eq!(config.initialize_with, Some(".".to_string()));
1058        assert_eq!(
1059            config.demote_non_dropping_particle,
1060            Some(DemoteNonDroppingParticle::SortOnly)
1061        );
1062        assert_eq!(
1063            config.delimiter_precedes_last,
1064            Some(DelimiterPrecedesLast::Always)
1065        );
1066    }
1067
1068    #[test]
1069    fn test_date_preset_long() {
1070        let config = DatePreset::Long.config();
1071        assert_eq!(config.month, MonthFormat::Long);
1072        assert!(config.uncertainty_marker.is_some());
1073    }
1074
1075    #[test]
1076    fn test_date_preset_iso() {
1077        let config = DatePreset::Iso.config();
1078        assert_eq!(config.month, MonthFormat::Numeric);
1079        // ISO preset suppresses EDTF markers
1080        assert!(config.uncertainty_marker.is_none());
1081        assert!(config.approximation_marker.is_none());
1082    }
1083
1084    #[test]
1085    fn test_title_preset_apa() {
1086        let config = TitlePreset::Apa.config();
1087        // Component titles should be plain (no formatting)
1088        let component = config.component.unwrap();
1089        assert!(component.quote.is_none() || component.quote == Some(false));
1090        // Monograph titles should be italic
1091        let monograph = config.monograph.unwrap();
1092        assert_eq!(monograph.emph, Some(true));
1093    }
1094
1095    #[test]
1096    fn test_title_preset_chicago() {
1097        let config = TitlePreset::Chicago.config();
1098        // Component titles should be quoted
1099        let component = config.component.unwrap();
1100        assert_eq!(component.quote, Some(true));
1101        // Monograph titles should be italic
1102        let monograph = config.monograph.unwrap();
1103        assert_eq!(monograph.emph, Some(true));
1104    }
1105
1106    #[test]
1107    fn test_preset_yaml_roundtrip() {
1108        let yaml = r#"apa"#;
1109        let preset: ContributorPreset = serde_yaml::from_str(yaml).unwrap();
1110        assert_eq!(preset, ContributorPreset::Apa);
1111
1112        let serialized = serde_yaml::to_string(&preset).unwrap();
1113        assert!(serialized.contains("apa"));
1114    }
1115
1116    #[test]
1117    fn test_all_presets_serialize() {
1118        // Ensure all presets can be serialized/deserialized
1119        let contributor_presets = vec![
1120            ContributorPreset::Apa,
1121            ContributorPreset::Chicago,
1122            ContributorPreset::Vancouver,
1123            ContributorPreset::Ieee,
1124            ContributorPreset::Harvard,
1125            ContributorPreset::Springer,
1126            ContributorPreset::NumericCompact,
1127            ContributorPreset::NumericMedium,
1128            ContributorPreset::NumericTight,
1129            ContributorPreset::NumericLarge,
1130            ContributorPreset::NumericAllAuthors,
1131            ContributorPreset::NumericGivenDot,
1132            ContributorPreset::AnnualReviews,
1133            ContributorPreset::MathPhys,
1134            ContributorPreset::SocSciFirst,
1135            ContributorPreset::PhysicsNumeric,
1136        ];
1137        for preset in contributor_presets {
1138            let yaml = serde_yaml::to_string(&preset).unwrap();
1139            let _: ContributorPreset = serde_yaml::from_str(&yaml).unwrap();
1140        }
1141
1142        let date_presets = vec![
1143            DatePreset::Long,
1144            DatePreset::Short,
1145            DatePreset::Numeric,
1146            DatePreset::Iso,
1147        ];
1148        for preset in date_presets {
1149            let yaml = serde_yaml::to_string(&preset).unwrap();
1150            let _: DatePreset = serde_yaml::from_str(&yaml).unwrap();
1151        }
1152
1153        let title_presets = vec![
1154            TitlePreset::Apa,
1155            TitlePreset::Chicago,
1156            TitlePreset::Ieee,
1157            TitlePreset::Humanities,
1158            TitlePreset::JournalEmphasis,
1159            TitlePreset::Scientific,
1160        ];
1161        for preset in title_presets {
1162            let yaml = serde_yaml::to_string(&preset).unwrap();
1163            let _: TitlePreset = serde_yaml::from_str(&yaml).unwrap();
1164        }
1165
1166        let substitute_presets = vec![
1167            SubstitutePreset::Standard,
1168            SubstitutePreset::EditorFirst,
1169            SubstitutePreset::TitleFirst,
1170            SubstitutePreset::EditorShort,
1171            SubstitutePreset::EditorLong,
1172            SubstitutePreset::EditorTranslatorShort,
1173            SubstitutePreset::EditorTranslatorLong,
1174            SubstitutePreset::EditorTitleShort,
1175            SubstitutePreset::EditorTitleLong,
1176            SubstitutePreset::EditorTranslatorTitleShort,
1177            SubstitutePreset::EditorTranslatorTitleLong,
1178        ];
1179        for preset in substitute_presets {
1180            let yaml = serde_yaml::to_string(&preset).unwrap();
1181            let _: SubstitutePreset = serde_yaml::from_str(&yaml).unwrap();
1182        }
1183    }
1184
1185    #[test]
1186    fn test_substitute_preset_standard() {
1187        let config = SubstitutePreset::Standard.config();
1188        assert_eq!(
1189            config.template,
1190            vec![
1191                SubstituteKey::Editor,
1192                SubstituteKey::Title,
1193                SubstituteKey::Translator,
1194            ]
1195        );
1196    }
1197
1198    #[test]
1199    fn test_substitute_preset_title_first() {
1200        let config = SubstitutePreset::TitleFirst.config();
1201        assert_eq!(config.template[0], SubstituteKey::Title);
1202    }
1203}