Skip to main content

citum_schema_style/
grouping.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6#[cfg(feature = "schema")]
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use crate::Template;
12use crate::locale::{GeneralTerm, TermForm};
13use crate::presets::SortPreset;
14use crate::template::TypeSelector;
15
16/// A bibliography group with selector, optional heading, and per-group sorting.
17///
18/// Groups allow styles to divide bibliographies into labeled sections with
19/// distinct sorting rules. Items match the first group whose selector evaluates
20/// to true (first-match semantics).
21///
22/// # Examples
23///
24/// ```yaml
25/// groups:
26///   - id: vietnamese
27///     heading:
28///       localized:
29///         vi: "Tài liệu tiếng Việt"
30///         en-US: "Vietnamese Sources"
31///     selector:
32///       field:
33///         language: vi
34///     sort:
35///       template:
36///         - key: author
37///           sort-order: given-family
38/// ```
39#[derive(Debug, Clone, Deserialize, Serialize, Default)]
40#[cfg_attr(feature = "schema", derive(JsonSchema))]
41#[serde(rename_all = "kebab-case")]
42pub struct BibliographyGroup {
43    /// Unique identifier for this group.
44    pub id: String,
45
46    /// Optional heading to display above this group.
47    /// Omit for no heading (e.g., fallback group).
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub heading: Option<GroupHeading>,
50
51    /// Selector predicate to match references.
52    pub selector: GroupSelector,
53
54    /// Optional per-group sorting specification.
55    /// Falls back to global bibliography sort if omitted.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub sort: Option<GroupSortEntry>,
58
59    /// Optional per-group template override.
60    /// Falls back to global bibliography template if omitted.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub template: Option<Template>,
63
64    /// Optional disambiguation scope.
65    /// - `globally` (default): Year suffixes are assigned across the whole bibliography.
66    /// - `locally`: Year suffixes are assigned independently within this group.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub disambiguate: Option<DisambiguationScope>,
69}
70
71/// Localizable heading source for bibliography groups.
72#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
73#[cfg_attr(feature = "schema", derive(JsonSchema))]
74#[serde(rename_all = "kebab-case", untagged)]
75pub enum GroupHeading {
76    /// Fixed literal heading text.
77    Literal {
78        /// Literal heading value.
79        literal: String,
80    },
81    /// Locale general term key resolved at render time.
82    Term {
83        /// Locale general term key.
84        term: GeneralTerm,
85        /// Optional term form (defaults to long).
86        #[serde(skip_serializing_if = "Option::is_none")]
87        form: Option<TermForm>,
88    },
89    /// Locale-indexed heading map.
90    Localized {
91        /// Map keyed by BCP 47 locale identifiers or language tags.
92        localized: HashMap<String, String>,
93    },
94}
95
96/// Scope for disambiguation (e.g., year suffix assignment).
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
98#[cfg_attr(feature = "schema", derive(JsonSchema))]
99#[serde(rename_all = "kebab-case")]
100pub enum DisambiguationScope {
101    /// Disambiguate across all items in the bibliography.
102    #[default]
103    Globally,
104    /// Disambiguate only within the current group.
105    Locally,
106}
107
108/// Selector predicate for matching references to groups.
109///
110/// All specified conditions must match (AND logic).
111/// Use the `not` field for negation-based fallback groups.
112#[derive(Debug, Clone, Deserialize, Serialize, Default)]
113#[cfg_attr(feature = "schema", derive(JsonSchema))]
114#[serde(rename_all = "kebab-case")]
115pub struct GroupSelector {
116    /// Match references by type.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[serde(rename = "type")]
119    pub ref_type: Option<TypeSelector>,
120
121    /// Match references by citation status.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub cited: Option<CitedStatus>,
124
125    /// Match references by field values (e.g., language, keywords).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub field: Option<HashMap<String, FieldMatcher>>,
128
129    /// Negation for fallback groups.
130    /// Matches references that do NOT match the nested selector.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub not: Option<Box<GroupSelector>>,
133}
134
135/// Citation status filter.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
137#[cfg_attr(feature = "schema", derive(JsonSchema))]
138#[serde(rename_all = "kebab-case")]
139pub enum CitedStatus {
140    /// Match only references cited in the document.
141    Visible,
142    /// Match all references regardless of citation status.
143    Any,
144}
145
146/// Field value matcher.
147#[derive(Debug, Clone, Deserialize, Serialize)]
148#[cfg_attr(feature = "schema", derive(JsonSchema))]
149#[serde(untagged)]
150pub enum FieldMatcher {
151    /// Match exact field value.
152    Exact(String),
153    /// Match any of multiple values.
154    Multiple(Vec<String>),
155    // Future: Pattern(FieldPattern) for regex/glob matching
156}
157
158/// Citation sort configuration: either a preset name or explicit configuration.
159#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
160#[cfg_attr(feature = "schema", derive(JsonSchema))]
161#[serde(untagged)]
162pub enum GroupSortEntry {
163    /// A named sort preset (e.g., "author-date-title").
164    Preset(SortPreset),
165    /// Explicit sort configuration.
166    Explicit(GroupSort),
167}
168
169impl GroupSortEntry {
170    /// Resolve this sort entry to a concrete `GroupSort`.
171    ///
172    /// If this is a preset, the preset is resolved to its corresponding sort configuration.
173    /// If already explicit, it is returned as-is.
174    pub fn resolve(&self) -> GroupSort {
175        match self {
176            GroupSortEntry::Preset(preset) => preset.group_sort(),
177            GroupSortEntry::Explicit(sort) => sort.clone(),
178        }
179    }
180}
181
182/// Per-group sorting specification.
183///
184/// Sorting follows a template of sort keys, applied in order.
185/// The first key is the primary sort, second is the tiebreaker, etc.
186#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
187#[cfg_attr(feature = "schema", derive(JsonSchema))]
188#[serde(rename_all = "kebab-case")]
189pub struct GroupSort {
190    /// Ordered list of sort keys to apply.
191    pub template: Vec<GroupSortKey>,
192}
193
194/// A single sort key in a group sorting template.
195#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
196#[cfg_attr(feature = "schema", derive(JsonSchema))]
197#[serde(rename_all = "kebab-case")]
198pub struct GroupSortKey {
199    /// The field or variable to sort by.
200    pub key: SortKey,
201
202    /// Sort order direction.
203    #[serde(default = "default_true")]
204    pub ascending: bool,
205
206    /// For type-based ordering: explicit type sequence.
207    ///
208    /// Example: `["legal-case", "statute", "treaty"]` for Bluebook hierarchy.
209    /// Items appear in this order regardless of alphabetical content.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub order: Option<Vec<String>>,
212
213    /// For name-based sorting: culturally appropriate name order.
214    ///
215    /// Example: `given-family` for Vietnamese, `family-given` for Western.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub sort_order: Option<NameSortOrder>,
218}
219
220fn default_true() -> bool {
221    true
222}
223
224/// Sort key selector.
225#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
226#[cfg_attr(feature = "schema", derive(JsonSchema))]
227#[serde(rename_all = "kebab-case")]
228pub enum SortKey {
229    /// Sort by reference type.
230    #[serde(rename = "type")]
231    RefType,
232    /// Sort by author/contributor.
233    Author,
234    /// Sort by title.
235    Title,
236    /// Sort by issued date.
237    Issued,
238    /// Sort by custom field.
239    Field(String),
240}
241
242/// Name sorting order for culturally appropriate collation.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
244#[cfg_attr(feature = "schema", derive(JsonSchema))]
245#[serde(rename_all = "kebab-case")]
246pub enum NameSortOrder {
247    /// Family name first (Western convention).
248    /// Example: "Smith, John" → "S" sorts before "T"
249    FamilyGiven,
250    /// Given name first (Vietnamese convention).
251    /// Example: "Nguyễn Văn A" → "Nguyễn" sorts before "Trần"
252    GivenFamily,
253}
254
255#[cfg(test)]
256#[allow(
257    clippy::unwrap_used,
258    clippy::expect_used,
259    clippy::panic,
260    clippy::indexing_slicing,
261    clippy::todo,
262    clippy::unimplemented,
263    clippy::unreachable,
264    clippy::get_unwrap,
265    reason = "Panicking is acceptable and often desired in tests."
266)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_group_selector_type_single() {
272        let yaml = r#"
273type: legal-case
274"#;
275        let selector: GroupSelector = serde_yaml::from_str(yaml).unwrap();
276        assert!(selector.ref_type.is_some());
277        match selector.ref_type.unwrap() {
278            TypeSelector::Single(t) => assert_eq!(t, "legal-case"),
279            _ => panic!("Expected Single"),
280        }
281    }
282
283    #[test]
284    fn test_group_selector_type_multiple() {
285        let yaml = r#"
286type: [legal-case, statute, treaty]
287"#;
288        let selector: GroupSelector = serde_yaml::from_str(yaml).unwrap();
289        match selector.ref_type.unwrap() {
290            TypeSelector::Multiple(types) => {
291                assert_eq!(types, vec!["legal-case", "statute", "treaty"]);
292            }
293            _ => panic!("Expected Multiple"),
294        }
295    }
296
297    #[test]
298    fn test_group_selector_field_exact() {
299        let yaml = r#"
300field:
301  language: vi
302"#;
303        let selector: GroupSelector = serde_yaml::from_str(yaml).unwrap();
304        let fields = selector.field.unwrap();
305        match fields.get("language").unwrap() {
306            FieldMatcher::Exact(lang) => assert_eq!(lang, "vi"),
307            _ => panic!("Expected Exact"),
308        }
309    }
310
311    #[test]
312    fn test_group_selector_negation() {
313        let yaml = r#"
314not:
315  type: legal-case
316"#;
317        let selector: GroupSelector = serde_yaml::from_str(yaml).unwrap();
318        let negated = selector.not.unwrap();
319        assert!(negated.ref_type.is_some());
320    }
321
322    #[test]
323    fn test_bibliography_group_minimal() {
324        let yaml = r#"
325id: cases
326selector:
327  type: legal-case
328"#;
329        let group: BibliographyGroup = serde_yaml::from_str(yaml).unwrap();
330        assert_eq!(group.id, "cases");
331        assert!(group.heading.is_none());
332        assert!(group.sort.is_none());
333    }
334
335    #[test]
336    fn test_bibliography_group_full() {
337        let yaml = r#"
338id: vietnamese
339heading:
340  localized:
341    vi: "Tài liệu tiếng Việt"
342    en-US: "Vietnamese Sources"
343selector:
344  field:
345    language: vi
346sort:
347  template:
348    - key: author
349      sort-order: given-family
350    - key: issued
351      ascending: false
352"#;
353        let group: BibliographyGroup = serde_yaml::from_str(yaml).unwrap();
354        assert_eq!(group.id, "vietnamese");
355        match group.heading.unwrap() {
356            GroupHeading::Localized { localized } => {
357                assert_eq!(localized.get("vi").unwrap(), "Tài liệu tiếng Việt");
358                assert_eq!(localized.get("en-US").unwrap(), "Vietnamese Sources");
359            }
360            _ => panic!("Expected localized heading"),
361        }
362
363        let sort = group.sort.unwrap().resolve();
364        assert_eq!(sort.template.len(), 2);
365
366        match &sort.template[0].key {
367            SortKey::Author => {}
368            _ => panic!("Expected Author"),
369        }
370        assert_eq!(
371            sort.template[0].sort_order,
372            Some(NameSortOrder::GivenFamily)
373        );
374
375        match &sort.template[1].key {
376            SortKey::Issued => {}
377            _ => panic!("Expected Issued"),
378        }
379        assert!(!sort.template[1].ascending);
380    }
381
382    #[test]
383    fn test_type_order_sorting() {
384        let yaml = r#"
385template:
386  - key: type
387    order: [legal-case, statute, treaty]
388"#;
389        let sort: GroupSort = serde_yaml::from_str(yaml).unwrap();
390        assert_eq!(sort.template.len(), 1);
391
392        let order = sort.template[0].order.as_ref().unwrap();
393        assert_eq!(order, &vec!["legal-case", "statute", "treaty"]);
394    }
395
396    #[test]
397    fn test_group_heading_literal() {
398        let yaml = r#"
399literal: "Primary Sources"
400"#;
401        let heading: GroupHeading = serde_yaml::from_str(yaml).unwrap();
402        match heading {
403            GroupHeading::Literal { literal } => assert_eq!(literal, "Primary Sources"),
404            _ => panic!("Expected literal heading"),
405        }
406    }
407
408    #[test]
409    fn test_group_heading_term() {
410        let yaml = r#"
411term: no-date
412form: short
413"#;
414        let heading: GroupHeading = serde_yaml::from_str(yaml).unwrap();
415        match heading {
416            GroupHeading::Term { term, form } => {
417                assert_eq!(term, GeneralTerm::NoDate);
418                assert_eq!(form, Some(TermForm::Short));
419            }
420            _ => panic!("Expected term heading"),
421        }
422    }
423}