Skip to main content

citum_engine/values/
number.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6//! Rendering logic for numeric variables (volume, issue, pages, citation numbers, etc.).
7//!
8//! This module handles number component rendering with support for page range formatting,
9//! edition labels, and numeric citation identifiers.
10
11use crate::reference::Reference;
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_schema::locale::{GrammaticalGender, TermForm};
14use citum_schema::reference::ClassExtension;
15use citum_schema::template::{NumberVariable, TemplateNumber};
16
17/// Resolve the raw value string for a number variable from a reference.
18fn resolve_number_value(
19    number: &NumberVariable,
20    reference: &Reference,
21    hints: &ProcHints,
22    options: &RenderOptions<'_>,
23    show_with_locator: bool,
24) -> Option<String> {
25    match number {
26        NumberVariable::Volume => reference.volume().map(|v| v.to_string()),
27        NumberVariable::Issue => reference.issue().map(|v| v.to_string()),
28        NumberVariable::Pages => {
29            let suppress = !show_with_locator
30                && options.context == crate::values::RenderContext::Citation
31                && options.locator_raw.is_some()
32                && matches!(
33                    options.config.processing,
34                    Some(citum_schema::options::Processing::Note)
35                );
36            if suppress {
37                None
38            } else {
39                reference.pages().map(|p| {
40                    format_page_range(&p.to_string(), options.config.page_range_format.as_ref())
41                })
42            }
43        }
44        NumberVariable::ChapterNumber => match reference.extension() {
45            ClassExtension::Statute(r) => r.chapter_number.clone(),
46            _ => reference.numbering_value(&citum_schema::reference::NumberingType::Chapter),
47        },
48        NumberVariable::Edition => reference.edition(),
49        NumberVariable::CollectionNumber => reference.collection_number(),
50        NumberVariable::Number => reference.number(),
51        NumberVariable::Custom(kind) => reference.numbering_value(
52            &citum_schema::reference::NumberingType::Custom(kind.clone()),
53        ),
54        NumberVariable::DocketNumber => match reference.extension() {
55            ClassExtension::Brief(r) => r.docket_number.clone(),
56            _ => None,
57        },
58        NumberVariable::PatentNumber => match reference.extension() {
59            ClassExtension::Patent(r) => Some(r.patent_number.clone()),
60            _ => None,
61        },
62        NumberVariable::StandardNumber => match reference.extension() {
63            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
64            _ => None,
65        },
66        NumberVariable::ReportNumber => reference.report_number(),
67        NumberVariable::PartNumber => {
68            reference.numbering_value(&citum_schema::reference::NumberingType::Part)
69        }
70        NumberVariable::SupplementNumber => {
71            reference.numbering_value(&citum_schema::reference::NumberingType::Supplement)
72        }
73        NumberVariable::PrintingNumber => {
74            reference.numbering_value(&citum_schema::reference::NumberingType::Printing)
75        }
76        NumberVariable::CitationNumber => hints.citation_number.map(|n| {
77            if options.context == crate::values::RenderContext::Citation
78                && let Some(sub_label) = &hints.citation_sub_label
79            {
80                return format!("{n}{sub_label}");
81            }
82            n.to_string()
83        }),
84        NumberVariable::CitationLabel => {
85            let Some(citum_schema::options::Processing::Label(config)) =
86                options.config.processing.as_ref()
87            else {
88                return None;
89            };
90            let params = config.effective_params();
91            let base = crate::processor::labels::generate_base_label(reference, &params);
92            if base.is_empty() {
93                return None;
94            }
95            let suffix = if hints.disamb_condition && hints.group_index > 0 {
96                crate::values::int_to_letter(hints.group_index as u32).unwrap_or_default()
97            } else {
98                String::new()
99            };
100            Some(format!("{base}{suffix}"))
101        }
102        _ => None,
103    }
104}
105
106/// Resolve a label prefix for a number variable if `label_form` is configured.
107fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
108    number: &NumberVariable,
109    label_form: &citum_schema::template::LabelForm,
110    value: &str,
111    requested_gender: Option<GrammaticalGender>,
112    effective_rendering: &citum_schema::template::Rendering,
113    options: &RenderOptions<'_>,
114    fmt: &F,
115) -> Option<String> {
116    if let Some(locator_type) = number_var_to_locator_type(number) {
117        // Check pluralization
118        let plural = check_plural(value, &locator_type);
119
120        let term_form = match label_form {
121            citum_schema::template::LabelForm::Long => TermForm::Long,
122            citum_schema::template::LabelForm::Short => TermForm::Short,
123            citum_schema::template::LabelForm::Symbol => TermForm::Symbol,
124        };
125
126        options
127            .locale
128            .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
129            .map(|t| {
130                let term_str = if crate::values::should_strip_periods(effective_rendering, options)
131                {
132                    crate::values::strip_trailing_periods(&t)
133                } else {
134                    t
135                };
136                fmt.text(&format!("{term_str} "))
137            })
138    } else {
139        None
140    }
141}
142
143impl ComponentValues for TemplateNumber {
144    fn values<F: crate::render::format::OutputFormat<Output = String>>(
145        &self,
146        reference: &Reference,
147        hints: &ProcHints,
148        options: &RenderOptions<'_>,
149    ) -> Option<ProcValues<F::Output>> {
150        let fmt = F::default();
151
152        let value = resolve_number_value(
153            &self.number,
154            reference,
155            hints,
156            options,
157            self.show_with_locator.unwrap_or(false),
158        );
159
160        value.filter(|s| !s.is_empty()).map(|value| {
161            // Resolve effective rendering options
162            let effective_rendering = &self.rendering;
163
164            // Handle label if label_form is specified
165            let prefix = if let Some(label_form) = &self.label_form {
166                resolve_number_label(
167                    &self.number,
168                    label_form,
169                    &value,
170                    self.gender.clone(),
171                    effective_rendering,
172                    options,
173                    &fmt,
174                )
175            } else {
176                None
177            };
178
179            ProcValues {
180                value,
181                prefix,
182                suffix: None,
183                url: crate::values::resolve_effective_url(
184                    self.links.as_ref(),
185                    options.config.links.as_ref(),
186                    reference,
187                    citum_schema::options::LinkAnchor::Component,
188                ),
189                substituted_key: None,
190                pre_formatted: false,
191            }
192        })
193    }
194}
195
196/// Maps a number variable to its corresponding locator type.
197///
198/// Determines which `LocatorType` corresponds to a given numeric variable,
199/// allowing proper label selection when rendering page, volume, or issue information.
200/// Returns `None` for variables with no locator equivalent (e.g. edition, version).
201#[must_use]
202pub fn number_var_to_locator_type(
203    var: &NumberVariable,
204) -> Option<citum_schema::citation::LocatorType> {
205    use citum_schema::citation::LocatorType;
206    match var {
207        NumberVariable::Volume => Some(LocatorType::Volume),
208        NumberVariable::Pages => Some(LocatorType::Page),
209        NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
210        NumberVariable::NumberOfPages => Some(LocatorType::Page),
211        NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
212        NumberVariable::Number
213        | NumberVariable::DocketNumber
214        | NumberVariable::PatentNumber
215        | NumberVariable::StandardNumber
216        | NumberVariable::ReportNumber
217        | NumberVariable::PrintingNumber => Some(LocatorType::Number),
218        NumberVariable::PartNumber => Some(LocatorType::Part),
219        NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
220        NumberVariable::Issue => Some(LocatorType::Issue),
221        NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
222        _ => None,
223    }
224}
225
226/// Heuristically detect whether a locator string should use plural labeling.
227///
228/// Returns `true` if the value contains range or list separators — hyphens (`-`),
229/// en-dashes (`–`), commas (`,`), or ampersands (`&`) — indicating multiple items
230/// such as `"1-10"`, `"1, 3"`, or `"1 & 3"`.
231#[must_use]
232pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
233    // Simple heuristic: if contains ranges or separators, it's plural.
234    // "1-10", "1, 3", "1 & 3"
235    value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
236}
237
238/// Format a page range according to the specified format.
239///
240/// Formats: expanded (default), minimal, minimal-two, chicago, chicago-16
241#[must_use]
242pub fn format_page_range(
243    pages: &str,
244    format: Option<&citum_schema::options::PageRangeFormat>,
245) -> String {
246    use citum_schema::options::PageRangeFormat;
247
248    // First, replace hyphen with en-dash
249    let pages = pages.replace('-', "–");
250
251    // If no range or no format specified, return as-is
252    let Some(format) = format else {
253        return pages; // Default: just convert to en-dash
254    };
255
256    // Check if this is a range (contains en-dash)
257    let parts: Vec<&str> = pages.split('–').collect();
258    if parts.len() != 2 {
259        return pages; // Not a simple range
260    }
261
262    #[allow(clippy::indexing_slicing, reason = "length checked")]
263    let start = parts[0].trim();
264    #[allow(clippy::indexing_slicing, reason = "length checked")]
265    let end = parts[1].trim();
266
267    // Parse as numbers
268    let start_num: Option<u32> = start.parse().ok();
269    let end_num: Option<u32> = end.parse().ok();
270
271    match (start_num, end_num) {
272        (Some(s), Some(e)) if e > s => {
273            let formatted_end = match format {
274                PageRangeFormat::Expanded => end.to_string(),
275                PageRangeFormat::Minimal => format_minimal(start, end, 1),
276                PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
277                PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => format_chicago(s, e),
278                _ => end.to_string(), // Future variants: default to expanded
279            };
280            format!("{start}–{formatted_end}")
281        }
282        _ => pages, // Can't parse or invalid range
283    }
284}
285
286/// Minimal format: keep only differing digits, with minimum `min_digits`
287#[must_use]
288pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
289    let start_chars: Vec<char> = start.chars().collect();
290    let end_chars: Vec<char> = end.chars().collect();
291
292    if start_chars.len() != end_chars.len() {
293        return end.to_string();
294    }
295
296    // Find first differing position
297    let mut first_diff = 0;
298    for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
299        if s != e {
300            first_diff = i;
301            break;
302        }
303    }
304
305    // Keep at least min_digits from the end
306    let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
307    end_chars
308        .get(keep_from..)
309        .unwrap_or_default()
310        .iter()
311        .collect()
312}
313
314/// Chicago Manual of Style page range format
315#[must_use]
316pub fn format_chicago(start: u32, end: u32) -> String {
317    // Chicago rules (simplified from CMOS 17th):
318    // - Under 100: use all digits (3–10, 71–72, 96–117)
319    // - 100+, same hundreds: use changed part only for 2+ digits (107–8, 321–28, 1536–38)
320    // - Different hundreds: use all digits (107–108, 321–328 if change of hundreds)
321
322    if start < 100 || end < 100 {
323        return end.to_string();
324    }
325
326    let start_str = start.to_string();
327    let end_str = end.to_string();
328
329    if start_str.len() != end_str.len() {
330        return end_str;
331    }
332
333    // Check if same hundreds
334    let start_prefix = start / 100;
335    let end_prefix = end / 100;
336
337    if start_prefix != end_prefix {
338        return end_str; // Different hundreds, use full number
339    }
340
341    // Same hundreds: use minimal-two style
342    format_minimal(&start_str, &end_str, 2)
343}
344
345#[cfg(test)]
346#[allow(
347    clippy::unwrap_used,
348    clippy::expect_used,
349    clippy::panic,
350    clippy::indexing_slicing,
351    clippy::todo,
352    clippy::unimplemented,
353    clippy::unreachable,
354    clippy::get_unwrap,
355    reason = "Panicking is acceptable and often desired in tests."
356)]
357mod tests {
358    use super::*;
359    use citum_schema::options::PageRangeFormat;
360
361    #[test]
362    fn test_format_chicago() {
363        for (start, end, expected) in [
364            (3, 10, "10"),
365            (71, 72, "72"),
366            (96, 117, "117"),
367            (107, 108, "08"),
368            (321, 328, "28"),
369            (1536, 1538, "38"),
370            (107, 208, "208"),
371            (321, 428, "428"),
372        ] {
373            assert_eq!(format_chicago(start, end), expected);
374        }
375    }
376
377    #[test]
378    fn test_format_minimal() {
379        for (start, end, min_digits, expected) in [
380            ("100", "105", 1, "5"),
381            ("100", "105", 2, "05"),
382            ("1536", "1538", 1, "8"),
383            ("1536", "1538", 2, "38"),
384            ("1536", "1538", 4, "1538"),
385            ("12", "15", 1, "5"),
386            ("12", "15", 2, "15"),
387            ("10", "150", 1, "150"),
388        ] {
389            assert_eq!(format_minimal(start, end, min_digits), expected);
390        }
391    }
392
393    #[test]
394    fn test_format_page_range() {
395        for (input, format, expected) in [
396            ("10-15", None, "10–15"),
397            ("10–15", None, "10–15"),
398            ("321-328", None, "321–328"),
399            ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
400            ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
401            ("107-108", Some(PageRangeFormat::Chicago), "107–08"),
402            ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
403            ("321-328", Some(PageRangeFormat::Chicago), "321–28"),
404            ("321-428", Some(PageRangeFormat::Chicago), "321–428"),
405            ("1536-1538", Some(PageRangeFormat::Chicago), "1536–38"),
406            ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
407            ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
408            ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
409            ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
410            ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
411            ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
412            ("10", Some(PageRangeFormat::Chicago), "10"),
413            ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
414            ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
415            ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
416        ] {
417            assert_eq!(format_page_range(input, format.as_ref()), expected);
418        }
419    }
420
421    #[test]
422    fn test_check_plural() {
423        for (value, expected) in [
424            ("1-10", true),
425            ("1–10", true),
426            ("1, 3", true),
427            ("1 & 3", true),
428            ("1", false),
429            ("IV", false),
430        ] {
431            assert_eq!(
432                check_plural(value, &citum_schema::citation::LocatorType::Page),
433                expected
434            );
435        }
436    }
437
438    #[test]
439    fn number_var_to_locator_type_maps_printing_number() {
440        assert_eq!(
441            number_var_to_locator_type(&NumberVariable::PrintingNumber),
442            Some(citum_schema::citation::LocatorType::Number)
443        );
444    }
445
446    #[test]
447    fn number_var_to_locator_type_maps_part_number() {
448        assert_eq!(
449            number_var_to_locator_type(&NumberVariable::PartNumber),
450            Some(citum_schema::citation::LocatorType::Part)
451        );
452    }
453
454    #[test]
455    fn number_var_to_locator_type_maps_supplement_number() {
456        assert_eq!(
457            number_var_to_locator_type(&NumberVariable::SupplementNumber),
458            Some(citum_schema::citation::LocatorType::Supplement)
459        );
460    }
461}