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