Skip to main content

citum_schema_style/options/
locators.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Locator rendering configuration.
7//!
8//! Defines how citation locators (page numbers, sections, etc.) are displayed,
9//! including label forms, range formatting, and compound locator patterns.
10
11use super::PageRangeFormat;
12use citum_schema_data::citation::LocatorType;
13use std::collections::HashMap;
14
15#[cfg(feature = "schema")]
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19/// How a locator label is displayed.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[cfg_attr(feature = "schema", derive(JsonSchema))]
22#[serde(rename_all = "kebab-case")]
23pub enum LabelForm {
24    /// No label, bare value: "33"
25    None,
26    /// Short form: "p. 33"
27    #[default]
28    Short,
29    /// Long form: "page 33"
30    Long,
31    /// Symbol form if available in locale
32    Symbol,
33}
34
35/// Whether labels appear on every segment, only the first, or none.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "schema", derive(JsonSchema))]
38#[serde(rename_all = "kebab-case")]
39pub enum LabelRepeat {
40    /// Label on every segment
41    #[default]
42    All,
43    /// Label only on the first segment
44    First,
45    /// No labels
46    None,
47}
48
49/// A coarse reference genre used as an optional gate on locator patterns.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51#[cfg_attr(feature = "schema", derive(JsonSchema))]
52#[serde(rename_all = "kebab-case")]
53pub enum TypeClass {
54    /// Legal citations (e.g., "legal-case", "statute")
55    Legal,
56    /// Classical works with traditional numbering
57    Classical,
58    /// Standard reference types
59    #[default]
60    Standard,
61}
62
63/// Per-locator-kind configuration overrides.
64#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(JsonSchema))]
66#[serde(rename_all = "kebab-case")]
67pub struct LocatorKindConfig {
68    /// Override the default label form for this locator kind.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub label_form: Option<LabelForm>,
71    /// Override the global range format for this locator kind.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub range_format: Option<PageRangeFormat>,
74    /// Strip trailing periods from labels (e.g., "p." → "p").
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub strip_label_periods: Option<bool>,
77    /// Forward-compat: captures unknown keys when an older engine reads a
78    /// style produced by a newer schema. Empty by default; treated as a
79    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
80    #[serde(
81        flatten,
82        default,
83        skip_serializing_if = "std::collections::BTreeMap::is_empty"
84    )]
85    #[cfg_attr(feature = "schema", schemars(skip))]
86    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
87}
88
89/// A pattern matching a specific combination of LocatorType values.
90///
91/// Patterns are tested in declaration order; first match wins.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[cfg_attr(feature = "schema", derive(JsonSchema))]
94#[serde(rename_all = "kebab-case")]
95pub struct LocatorPattern {
96    /// The set of LocatorType values this pattern matches (order-insensitive).
97    pub kinds: Vec<LocatorType>,
98    /// Optional gate on reference type class.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub type_class: Option<TypeClass>,
101    /// Rendering order of segments when pattern matches.
102    pub order: Vec<LocatorType>,
103    /// Delimiter between segments (default: ", ").
104    #[serde(default = "default_delimiter")]
105    pub delimiter: String,
106    /// Whether labels appear on every segment, only the first, or none.
107    #[serde(default)]
108    pub label_repeat: LabelRepeat,
109    /// Forward-compat: captures unknown keys when an older engine reads a
110    /// style produced by a newer schema. Empty by default; treated as a
111    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
112    #[serde(
113        flatten,
114        default,
115        skip_serializing_if = "std::collections::BTreeMap::is_empty"
116    )]
117    #[cfg_attr(feature = "schema", schemars(skip))]
118    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
119}
120
121/// Top-level locator rendering configuration.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[cfg_attr(feature = "schema", derive(JsonSchema))]
124#[serde(rename_all = "kebab-case")]
125pub struct LocatorConfig {
126    /// Default label form for all locator kinds (default: Short).
127    #[serde(default = "default_label_form")]
128    pub default_label_form: LabelForm,
129    /// Range format for all locator kinds (default: Expanded).
130    #[serde(default)]
131    pub range_format: PageRangeFormat,
132    /// Strip trailing periods from labels globally (e.g., "p." → "p").
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub strip_label_periods: Option<bool>,
135    /// Per-kind configuration overrides.
136    #[serde(default)]
137    pub kinds: HashMap<LocatorType, LocatorKindConfig>,
138    /// Patterns for compound locators and type-specific rendering.
139    #[serde(default)]
140    pub patterns: Vec<LocatorPattern>,
141    /// Fallback delimiter for unmatched compound locators (default: ", ").
142    #[serde(default = "default_delimiter")]
143    pub fallback_delimiter: String,
144    /// Forward-compat: captures unknown keys when an older engine reads a
145    /// style produced by a newer schema. Empty by default; treated as a
146    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
147    #[serde(
148        flatten,
149        default,
150        skip_serializing_if = "std::collections::BTreeMap::is_empty"
151    )]
152    #[cfg_attr(feature = "schema", schemars(skip))]
153    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
154}
155
156impl Default for LocatorConfig {
157    fn default() -> Self {
158        Self {
159            default_label_form: LabelForm::Short,
160            range_format: PageRangeFormat::Expanded,
161            strip_label_periods: None,
162            kinds: HashMap::new(),
163            patterns: Vec::new(),
164            fallback_delimiter: ", ".to_string(),
165            unknown_fields: std::collections::BTreeMap::new(),
166        }
167    }
168}
169
170/// Named presets for common locator configurations.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[cfg_attr(feature = "schema", derive(JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174pub enum LocatorPreset {
175    /// Note style: bare page numbers, short labels for other kinds.
176    Note,
177    /// Author-date / numbered: short labels for all kinds.
178    AuthorDate,
179}
180
181impl LocatorPreset {
182    /// Resolve a preset to an explicit `LocatorConfig`.
183    #[must_use]
184    pub fn config(self) -> LocatorConfig {
185        match self {
186            LocatorPreset::Note => LocatorConfig {
187                default_label_form: LabelForm::Short,
188                range_format: PageRangeFormat::Expanded,
189                strip_label_periods: None,
190                kinds: {
191                    let mut m = HashMap::new();
192                    // Page locators have no label in note style
193                    m.insert(
194                        LocatorType::Page,
195                        LocatorKindConfig {
196                            label_form: Some(LabelForm::None),
197                            range_format: None,
198                            strip_label_periods: None,
199                            unknown_fields: std::collections::BTreeMap::new(),
200                        },
201                    );
202                    m
203                },
204                patterns: Vec::new(),
205                fallback_delimiter: ", ".to_string(),
206                unknown_fields: std::collections::BTreeMap::new(),
207            },
208            LocatorPreset::AuthorDate => LocatorConfig {
209                default_label_form: LabelForm::Short,
210                range_format: PageRangeFormat::Expanded,
211                strip_label_periods: None,
212                kinds: HashMap::new(),
213                patterns: Vec::new(),
214                fallback_delimiter: ", ".to_string(),
215                unknown_fields: std::collections::BTreeMap::new(),
216            },
217        }
218    }
219}
220
221/// Preset-or-explicit entry — same pattern as DateConfigEntry.
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[cfg_attr(feature = "schema", derive(JsonSchema))]
224#[serde(untagged)]
225pub enum LocatorConfigEntry {
226    /// A preset name.
227    Preset(LocatorPreset),
228    /// Explicit configuration.
229    Explicit(LocatorConfig),
230}
231
232impl LocatorConfigEntry {
233    /// Resolve a LocatorConfigEntry to an explicit LocatorConfig.
234    #[must_use]
235    pub fn resolve(self) -> LocatorConfig {
236        match self {
237            LocatorConfigEntry::Preset(preset) => preset.config(),
238            LocatorConfigEntry::Explicit(config) => config,
239        }
240    }
241}
242
243/// Default label form.
244fn default_label_form() -> LabelForm {
245    LabelForm::Short
246}
247
248/// Default delimiter string.
249fn default_delimiter() -> String {
250    ", ".to_string()
251}
252
253#[cfg(test)]
254#[allow(
255    clippy::unwrap_used,
256    clippy::expect_used,
257    clippy::panic,
258    clippy::indexing_slicing,
259    clippy::todo,
260    clippy::unimplemented,
261    clippy::unreachable,
262    clippy::get_unwrap,
263    reason = "Panicking is acceptable and often desired in tests."
264)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_locator_preset_note() {
270        let config = LocatorPreset::Note.config();
271        assert_eq!(config.default_label_form, LabelForm::Short);
272        assert_eq!(config.range_format, PageRangeFormat::Expanded);
273    }
274
275    #[test]
276    fn test_locator_preset_author_date() {
277        let config = LocatorPreset::AuthorDate.config();
278        assert_eq!(config.default_label_form, LabelForm::Short);
279        assert_eq!(config.range_format, PageRangeFormat::Expanded);
280    }
281
282    #[test]
283    fn test_locator_config_entry_preset() {
284        let entry = LocatorConfigEntry::Preset(LocatorPreset::Note);
285        let config = entry.resolve();
286        assert_eq!(config.default_label_form, LabelForm::Short);
287    }
288
289    #[test]
290    fn test_locator_config_entry_explicit() {
291        let entry = LocatorConfigEntry::Explicit(LocatorConfig {
292            default_label_form: LabelForm::Long,
293            ..Default::default()
294        });
295        let config = entry.resolve();
296        assert_eq!(config.default_label_form, LabelForm::Long);
297    }
298
299    #[test]
300    fn test_locator_config_default() {
301        let config = LocatorConfig::default();
302        assert_eq!(config.default_label_form, LabelForm::Short);
303        assert_eq!(config.fallback_delimiter, ", ");
304    }
305}