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::{DigitSystem, GeneralTerm, GrammaticalGender, MessageArgs, TermForm};
14use citum_schema::reference::ClassExtension;
15use citum_schema::template::{LabelForm, NumberForm, 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                    let delimiter =
41                        options.config.page_range_delimiter.as_deref().unwrap_or(
42                            options.locale.grammar_options.page_range_delimiter.as_str(),
43                        );
44                    format_page_range(
45                        &p.to_string(),
46                        options.config.page_range_format.as_ref(),
47                        delimiter,
48                    )
49                })
50            }
51        }
52        NumberVariable::ChapterNumber => match reference.extension() {
53            ClassExtension::Statute(r) => r.chapter_number.clone(),
54            _ => reference.numbering_value(&citum_schema::reference::NumberingType::Chapter),
55        },
56        NumberVariable::Edition => reference.edition(),
57        NumberVariable::CollectionNumber => reference.collection_number(),
58        NumberVariable::Number => reference.number(),
59        NumberVariable::Custom(kind) => reference.numbering_value(
60            &citum_schema::reference::NumberingType::Custom(kind.clone()),
61        ),
62        NumberVariable::DocketNumber => match reference.extension() {
63            ClassExtension::Brief(r) => r.docket_number.clone(),
64            _ => None,
65        },
66        NumberVariable::PatentNumber => match reference.extension() {
67            // GB/T 7714 and most citation styles cite the filing/application
68            // number (CSL `call-number`) in preference to the granted
69            // number when both are known; fall back to the granted number
70            // otherwise (e.g. no application number was recorded).
71            ClassExtension::Patent(r) => Some(
72                r.application_number
73                    .clone()
74                    .unwrap_or_else(|| r.patent_number.clone()),
75            ),
76            _ => None,
77        },
78        NumberVariable::StandardNumber => match reference.extension() {
79            ClassExtension::Standard(r) => Some(r.standard_number.clone()),
80            _ => None,
81        },
82        NumberVariable::ReportNumber => reference.report_number(),
83        NumberVariable::PartNumber => {
84            reference.numbering_value(&citum_schema::reference::NumberingType::Part)
85        }
86        NumberVariable::SupplementNumber => {
87            reference.numbering_value(&citum_schema::reference::NumberingType::Supplement)
88        }
89        NumberVariable::PrintingNumber => {
90            reference.numbering_value(&citum_schema::reference::NumberingType::Printing)
91        }
92        NumberVariable::FirstReferenceNoteNumber => {
93            hints.first_reference_note_number.map(|n| n.to_string())
94        }
95        NumberVariable::CitationNumber => hints.citation_number.map(|n| {
96            if options.context == crate::values::RenderContext::Citation
97                && let Some(sub_label) = &hints.citation_sub_label
98            {
99                return format!("{n}{sub_label}");
100            }
101            n.to_string()
102        }),
103        NumberVariable::CitationLabel => {
104            let Some(citum_schema::options::Processing::Label(config)) =
105                options.config.processing.as_ref()
106            else {
107                return None;
108            };
109            let params = config.effective_params();
110            let base = crate::processor::labels::generate_base_label(reference, &params);
111            if base.is_empty() {
112                return None;
113            }
114            let suffix = if hints.disamb_condition && hints.group_index > 0 {
115                crate::values::int_to_letter(hints.group_index as u32).unwrap_or_default()
116            } else {
117                String::new()
118            };
119            Some(format!("{base}{suffix}"))
120        }
121        _ => None,
122    }
123}
124
125/// Convert a component-level [`LabelForm`] to the locale's [`TermForm`] vocabulary.
126fn label_form_to_term_form(label_form: &LabelForm) -> TermForm {
127    match label_form {
128        LabelForm::Long => TermForm::Long,
129        LabelForm::Short => TermForm::Short,
130        LabelForm::Symbol => TermForm::Symbol,
131    }
132}
133
134/// Resolve a label prefix for a number variable if `label_form` is configured.
135fn resolve_number_label<F: crate::render::format::OutputFormat<Output = String>>(
136    number: &NumberVariable,
137    label_form: &LabelForm,
138    value: &str,
139    requested_gender: Option<GrammaticalGender>,
140    effective_rendering: &citum_schema::template::Rendering,
141    options: &RenderOptions<'_>,
142    fmt: &F,
143) -> Option<String> {
144    if let Some(locator_type) = number_var_to_locator_type(number) {
145        // Check pluralization
146        let plural = check_plural(value, &locator_type);
147        let term_form = label_form_to_term_form(label_form);
148
149        options
150            .locale
151            .resolved_locator_term(&locator_type, plural, &term_form, requested_gender)
152            .map(|t| {
153                let term_str = if crate::values::should_strip_periods(effective_rendering, options)
154                {
155                    crate::values::strip_trailing_periods(&t)
156                } else {
157                    t
158                };
159                fmt.text(&format!("{term_str} "))
160            })
161    } else {
162        None
163    }
164}
165
166/// Maps a number variable to its corresponding general locale term, for
167/// [`TemplateNumber::when_numeric`] resolution. Distinct from
168/// [`number_var_to_locator_type`]: locators are for citation-position labels
169/// (`p. 35`); general terms cover non-locator numbering concepts like
170/// `edition` that a citation would never point a reader to.
171#[must_use]
172fn number_var_to_general_term(var: &NumberVariable) -> Option<GeneralTerm> {
173    match var {
174        NumberVariable::Edition => Some(GeneralTerm::Edition),
175        NumberVariable::Volume => Some(GeneralTerm::Volume),
176        _ => None,
177    }
178}
179
180/// Split a resolved locale term into a `(prefix, suffix)` affix pair around
181/// the value it wraps.
182///
183/// A term containing a literal `%s` (the CSL-M circumfix convention, e.g.
184/// zh-CN's `第%s卷`) splits into the text before and after that marker. A
185/// term without `%s` (e.g. `版`) follows the value as a space-separated
186/// suffix, matching GB/T 7714's `<number/> <label/>` ordering for numeric
187/// editions.
188fn split_numeric_term(term: &str) -> (Option<String>, Option<String>) {
189    if let Some((before, after)) = term.split_once("%s") {
190        let prefix = (!before.is_empty()).then(|| before.to_string());
191        let suffix = (!after.is_empty()).then(|| after.to_string());
192        (prefix, suffix)
193    } else {
194        (None, Some(format!(" {term}")))
195    }
196}
197
198/// Render one numeric value through the active locale's ordinal message.
199///
200/// Values that are not a single unsigned integer remain unchanged because MF2
201/// ordinal categories only apply to countable whole numbers.
202fn render_ordinal(value: String, options: &RenderOptions<'_>) -> String {
203    let Ok(count) = value.parse::<u64>() else {
204        return value;
205    };
206    let args = MessageArgs {
207        count: Some(count),
208        value: Some(&value),
209        ..MessageArgs::default()
210    };
211    options
212        .locale
213        .resolve_message("number.ordinal", &args)
214        .unwrap_or(value)
215}
216
217/// Replace ASCII digits in a rendered numeric value with the locale's configured glyphs.
218///
219/// Non-digit characters remain unchanged, so ranges and mixed identifiers preserve their
220/// punctuation and letters while their numeric portions follow locale conventions.
221fn localize_digits(value: String, digit_system: &DigitSystem) -> String {
222    let digits = match digit_system {
223        DigitSystem::Western => return value,
224        DigitSystem::ArabicIndic => ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'],
225        DigitSystem::ExtendedArabicIndic => ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'],
226        DigitSystem::Devanagari => ['०', '१', '२', '३', '४', '५', '६', '७', '८', '९'],
227        _ => return value,
228    };
229
230    value
231        .chars()
232        .map(|ch| {
233            ch.to_digit(10)
234                .filter(|_| ch.is_ascii_digit())
235                .and_then(|digit| digits.get(digit as usize).copied())
236                .unwrap_or(ch)
237        })
238        .collect()
239}
240
241impl ComponentValues for TemplateNumber {
242    fn values<F: crate::render::format::OutputFormat<Output = String>>(
243        &self,
244        reference: &Reference,
245        hints: &ProcHints,
246        options: &RenderOptions<'_>,
247    ) -> Option<ProcValues<F::Output>> {
248        let fmt = F::default();
249
250        let value = resolve_number_value(
251            &self.number,
252            reference,
253            hints,
254            options,
255            self.show_with_locator.unwrap_or(false),
256        );
257
258        value.filter(|s| !s.is_empty()).map(|value| {
259            // Resolve effective rendering options
260            let effective_rendering = &self.rendering;
261
262            // Free-text number values (e.g. `edition`) honor an explicit
263            // text-case override the same way string variables do.
264            let value = if let Some(tc) = effective_rendering.text_case {
265                let language = reference.language();
266                crate::values::text_case::apply_text_case_with_language(
267                    &value,
268                    tc,
269                    language.as_deref(),
270                )
271            } else {
272                value
273            };
274            let value_is_numeric = is_numeric(&value);
275
276            let value = if self.form == Some(NumberForm::Ordinal) {
277                render_ordinal(value, options)
278            } else {
279                value
280            };
281
282            // Handle label if label_form is specified
283            let label_prefix = if let Some(label_form) = &self.label_form {
284                resolve_number_label(
285                    &self.number,
286                    label_form,
287                    &value,
288                    self.gender.clone(),
289                    effective_rendering,
290                    options,
291                    &fmt,
292                )
293            } else {
294                None
295            };
296
297            // `when_numeric` resolves this number's locale term (GB/T 7714's
298            // `edition`/`volume` general terms) and wraps the value with it —
299            // only when the resolved value is numeric; free-text values like
300            // `修订版` or a pre-labeled `美国卷` render bare.
301            let (numeric_prefix, numeric_suffix) = self
302                .when_numeric
303                .as_ref()
304                .filter(|_| value_is_numeric)
305                .and_then(|form| {
306                    let general_term = number_var_to_general_term(&self.number)?;
307                    options.locale.resolved_general_term(
308                        &general_term,
309                        &label_form_to_term_form(form),
310                        self.gender.clone(),
311                    )
312                })
313                .map(|term| split_numeric_term(&term))
314                .unwrap_or((None, None));
315
316            let prefix = match (label_prefix, numeric_prefix) {
317                (Some(label), Some(numeric)) => Some(format!("{label}{numeric}")),
318                (Some(label), None) => Some(label),
319                (None, Some(numeric)) => Some(numeric),
320                (None, None) => None,
321            };
322            let suffix = numeric_suffix;
323            let value = localize_digits(value, &options.locale.number_formats.digit_system);
324
325            ProcValues {
326                value,
327                prefix,
328                suffix,
329                url: crate::values::resolve_effective_url(
330                    self.links.as_ref(),
331                    options.config.links.as_ref(),
332                    reference,
333                    citum_schema::options::LinkAnchor::Component,
334                ),
335                substituted_key: None,
336                pre_formatted: false,
337            }
338        })
339    }
340}
341
342/// Citeproc-style `is-numeric` check used to gate [`TemplateNumber::when_numeric`]
343/// affixes.
344///
345/// True for digit runs optionally joined by whitespace, commas, hyphens, or
346/// ampersands (bare numerals, ranges, and lists like `"2"`, `"1-3"`,
347/// `"12, 14"`). False for any value that mixes a digit with other text
348/// (`"新1版"`) or contains none (`"修订版"`, `"美国卷"`, `"第二卷"` — the last
349/// uses a CJK numeral character, not an ASCII digit, and so is treated as an
350/// already-complete label rather than a bare number to wrap.
351fn is_numeric(value: &str) -> bool {
352    let value = value.trim();
353    let mut saw_digit = false;
354    for ch in value.chars() {
355        if ch.is_ascii_digit() {
356            saw_digit = true;
357        } else if !matches!(ch, '-' | ',' | '&' | ' ') {
358            return false;
359        }
360    }
361    saw_digit
362}
363
364#[cfg(test)]
365mod is_numeric_tests {
366    use super::is_numeric;
367
368    #[test]
369    fn given_bare_digits_when_checked_then_numeric() {
370        assert!(is_numeric("2"));
371        assert!(is_numeric("510"));
372    }
373
374    #[test]
375    fn given_digit_range_or_list_when_checked_then_numeric() {
376        assert!(is_numeric("1-3"));
377        assert!(is_numeric("12, 14"));
378    }
379
380    #[test]
381    fn given_digit_embedded_in_free_text_when_checked_then_not_numeric() {
382        assert!(!is_numeric("新1版"));
383    }
384
385    #[test]
386    fn given_free_text_without_digits_when_checked_then_not_numeric() {
387        assert!(!is_numeric("修订版"));
388        assert!(!is_numeric("美国卷"));
389    }
390
391    #[test]
392    fn given_cjk_numeral_when_checked_then_not_numeric() {
393        // "二" is a CJK numeral character, not an ASCII digit; GB/T 7714
394        // treats "第二卷" as an already-complete label, not a bare number.
395        assert!(!is_numeric("第二卷"));
396    }
397
398    #[test]
399    fn given_empty_value_when_checked_then_not_numeric() {
400        assert!(!is_numeric(""));
401        assert!(!is_numeric("   "));
402    }
403}
404
405#[cfg(test)]
406mod digit_system_tests {
407    use super::localize_digits;
408    use citum_schema::locale::DigitSystem;
409
410    #[test]
411    fn localizes_ascii_digits_for_supported_digit_systems() {
412        for (digit_system, expected) in [
413            (DigitSystem::Western, "AB-12, 34"),
414            (DigitSystem::ArabicIndic, "AB-١٢, ٣٤"),
415            (DigitSystem::ExtendedArabicIndic, "AB-۱۲, ۳۴"),
416            (DigitSystem::Devanagari, "AB-१२, ३४"),
417        ] {
418            assert_eq!(
419                localize_digits("AB-12, 34".to_string(), &digit_system),
420                expected
421            );
422        }
423    }
424
425    #[test]
426    fn preserves_western_digits_for_unknown_digit_system() {
427        assert_eq!(
428            localize_digits(
429                "12".to_string(),
430                &DigitSystem::Unknown("future".to_string())
431            ),
432            "12"
433        );
434    }
435}
436
437#[cfg(test)]
438mod split_numeric_term_tests {
439    use super::split_numeric_term;
440
441    #[test]
442    fn given_circumfix_term_when_split_then_wraps_around_marker() {
443        assert_eq!(
444            split_numeric_term("第%s卷"),
445            (Some("第".to_string()), Some("卷".to_string()))
446        );
447    }
448
449    #[test]
450    fn given_suffix_only_term_when_split_then_prefix_only_marker() {
451        // "%s卷" (no leading text) has an empty prefix half, so no prefix is emitted.
452        assert_eq!(split_numeric_term("%s卷"), (None, Some("卷".to_string())));
453    }
454
455    #[test]
456    fn given_plain_term_when_split_then_space_separated_suffix() {
457        assert_eq!(split_numeric_term("版"), (None, Some(" 版".to_string())));
458    }
459}
460
461/// Maps a number variable to its corresponding locator type.
462///
463/// Determines which `LocatorType` corresponds to a given numeric variable,
464/// allowing proper label selection when rendering page, volume, or issue information.
465/// Returns `None` for variables with no locator equivalent (e.g. edition, version).
466#[must_use]
467pub fn number_var_to_locator_type(
468    var: &NumberVariable,
469) -> Option<citum_schema::citation::LocatorType> {
470    use citum_schema::citation::LocatorType;
471    match var {
472        NumberVariable::Volume => Some(LocatorType::Volume),
473        NumberVariable::Pages => Some(LocatorType::Page),
474        NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
475        NumberVariable::NumberOfPages => Some(LocatorType::Page),
476        NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
477        NumberVariable::Number
478        | NumberVariable::DocketNumber
479        | NumberVariable::PatentNumber
480        | NumberVariable::StandardNumber
481        | NumberVariable::ReportNumber
482        | NumberVariable::PrintingNumber => Some(LocatorType::Number),
483        NumberVariable::PartNumber => Some(LocatorType::Part),
484        NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
485        NumberVariable::Issue => Some(LocatorType::Issue),
486        NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
487        _ => None,
488    }
489}
490
491/// Heuristically detect whether a locator string should use plural labeling.
492///
493/// Returns `true` if the value contains range or list separators — hyphens (`-`),
494/// en-dashes (`–`), commas (`,`), or ampersands (`&`) — indicating multiple items
495/// such as `"1-10"`, `"1, 3"`, or `"1 & 3"`.
496#[must_use]
497pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
498    // Simple heuristic: if contains ranges or separators, it's plural.
499    // "1-10", "1, 3", "1 & 3"
500    value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
501}
502
503/// Format a page range according to the specified format.
504///
505/// Formats: expanded (default), minimal, minimal-two, chicago, chicago-16.
506/// `delimiter` is the range separator (usually the locale's
507/// `page-range-delimiter`, en-dash by default; AMA and similar use a hyphen).
508#[must_use]
509pub fn format_page_range(
510    pages: &str,
511    format: Option<&citum_schema::options::PageRangeFormat>,
512    delimiter: &str,
513) -> String {
514    use citum_schema::options::PageRangeFormat;
515
516    // Normalize any en-dash separator to a plain hyphen so splitting below is
517    // delimiter-agnostic; ranges are re-joined with the configured `delimiter`.
518    let normalized = pages.replace('\u{2013}', "-");
519    let with_delimiter = || normalized.replace('-', delimiter);
520
521    // If no format specified, just apply the delimiter to the range.
522    let Some(format) = format else {
523        return with_delimiter();
524    };
525
526    let parts: Vec<&str> = normalized.split('-').collect();
527    let [start, end] = parts.as_slice() else {
528        return with_delimiter(); // Not a simple range
529    };
530    let start = start.trim();
531    let end = end.trim();
532
533    // Parse as numbers
534    let start_num: Option<u32> = start.parse().ok();
535    let end_num: Option<u32> = end.parse().ok();
536
537    match (start_num, end_num) {
538        (Some(s), Some(e)) if e > s => {
539            let formatted_end = match format {
540                PageRangeFormat::Expanded => end.to_string(),
541                PageRangeFormat::Minimal => format_minimal(start, end, 1),
542                PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
543                PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
544                    format_chicago_range_end(s, e)
545                }
546                _ => end.to_string(), // Future variants: default to expanded
547            };
548            format!("{start}{delimiter}{formatted_end}")
549        }
550        _ => with_delimiter(), // Can't parse or invalid range
551    }
552}
553
554/// Minimal format: keep only differing digits, with minimum `min_digits`
555#[must_use]
556pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
557    let start_chars: Vec<char> = start.chars().collect();
558    let end_chars: Vec<char> = end.chars().collect();
559
560    if start_chars.len() != end_chars.len() {
561        return end.to_string();
562    }
563
564    // Find first differing position
565    let mut first_diff = 0;
566    for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
567        if s != e {
568            first_diff = i;
569            break;
570        }
571    }
572
573    // Keep at least min_digits from the end
574    let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
575    end_chars
576        .get(keep_from..)
577        .unwrap_or_default()
578        .iter()
579        .collect()
580}
581
582/// Format the second number in a Chicago Manual of Style inclusive-number range.
583#[must_use]
584pub(crate) fn format_chicago_range_end(start: u32, end: u32) -> String {
585    // Chicago rules:
586    // - start < 100: use all digits
587    // - start exact multiple of 100: use all digits
588    // - start % 100 is 1..=9: use the changed part only and trim any leading
589    //   zero from that suffix (1002–6, 505–17, 107–8)
590    // - otherwise: keep at least two digits unless more are needed to show the
591    //   changed part (321–25, 1087–89, 1496–500, 13792–803)
592
593    if start < 100 || start.is_multiple_of(100) {
594        return end.to_string();
595    }
596
597    let start_str = start.to_string();
598    let end_str = end.to_string();
599    let changed_end_part = if start % 100 <= 9 {
600        format_minimal(&start_str, &end_str, 1)
601    } else {
602        format_minimal(&start_str, &end_str, 2)
603    };
604
605    if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
606        let trimmed = changed_end_part.trim_start_matches('0');
607        if trimmed.is_empty() {
608            "0".to_string()
609        } else {
610            trimmed.to_string()
611        }
612    } else {
613        changed_end_part
614    }
615}
616
617/// Format a Chicago Manual of Style inclusive-number range end.
618#[must_use]
619pub fn format_chicago(start: u32, end: u32) -> String {
620    format_chicago_range_end(start, end)
621}
622
623#[cfg(test)]
624#[allow(
625    clippy::unwrap_used,
626    clippy::expect_used,
627    clippy::panic,
628    clippy::indexing_slicing,
629    clippy::todo,
630    clippy::unimplemented,
631    clippy::unreachable,
632    clippy::get_unwrap,
633    reason = "Panicking is acceptable and often desired in tests."
634)]
635mod tests {
636    use super::*;
637    use citum_schema::options::PageRangeFormat;
638
639    #[test]
640    fn test_format_chicago_page_range_end() {
641        for (start, end, expected) in [
642            (3, 10, "10"),
643            (71, 72, "72"),
644            (92, 113, "113"),
645            (100, 104, "104"),
646            (600, 613, "613"),
647            (107, 108, "8"),
648            (505, 517, "17"),
649            (1002, 1006, "6"),
650            (321, 325, "25"),
651            (415, 532, "532"),
652            (1087, 1089, "89"),
653            (1496, 1500, "500"),
654            (13792, 13803, "803"),
655            (12991, 13001, "3001"),
656        ] {
657            assert_eq!(format_chicago_range_end(start, end), expected);
658        }
659    }
660
661    #[test]
662    fn test_format_minimal() {
663        for (start, end, min_digits, expected) in [
664            ("100", "105", 1, "5"),
665            ("100", "105", 2, "05"),
666            ("1536", "1538", 1, "8"),
667            ("1536", "1538", 2, "38"),
668            ("1536", "1538", 4, "1538"),
669            ("12", "15", 1, "5"),
670            ("12", "15", 2, "15"),
671            ("10", "150", 1, "150"),
672        ] {
673            assert_eq!(format_minimal(start, end, min_digits), expected);
674        }
675    }
676
677    #[test]
678    fn test_format_page_range() {
679        // Default en-dash delimiter.
680        let en = "\u{2013}";
681        for (input, format, expected) in [
682            ("10-15", None, "10–15"),
683            ("10–15", None, "10–15"),
684            ("321-328", None, "321–328"),
685            ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
686            ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
687            ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
688            ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
689            ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
690            ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
691            ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
692            ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
693            ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
694            ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
695            ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
696            ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
697            ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
698            ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
699            ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
700            ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
701            ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
702            ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
703            ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
704            ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
705            ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
706            ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
707            ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
708            ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
709            ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
710            ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
711            ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
712            ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
713            ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
714            (
715                "12991-13001",
716                Some(PageRangeFormat::Chicago16),
717                "12991–3001",
718            ),
719            ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
720            ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
721            ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
722            ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
723            ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
724            ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
725            ("10", Some(PageRangeFormat::Chicago), "10"),
726            ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
727            ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
728            ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
729        ] {
730            assert_eq!(format_page_range(input, format.as_ref(), en), expected);
731        }
732    }
733
734    #[test]
735    fn test_format_page_range_hyphen_delimiter() {
736        // AMA-style hyphen delimiter: en-dash input is normalized to a hyphen,
737        // and range formats still apply.
738        for (input, format, expected) in [
739            ("436-444", None, "436-444"),
740            ("436–444", None, "436-444"),
741            ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
742            ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
743        ] {
744            assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
745        }
746    }
747
748    #[test]
749    fn test_check_plural() {
750        for (value, expected) in [
751            ("1-10", true),
752            ("1–10", true),
753            ("1, 3", true),
754            ("1 & 3", true),
755            ("1", false),
756            ("IV", false),
757        ] {
758            assert_eq!(
759                check_plural(value, &citum_schema::citation::LocatorType::Page),
760                expected
761            );
762        }
763    }
764
765    #[test]
766    fn number_var_to_locator_type_maps_printing_number() {
767        assert_eq!(
768            number_var_to_locator_type(&NumberVariable::PrintingNumber),
769            Some(citum_schema::citation::LocatorType::Number)
770        );
771    }
772
773    #[test]
774    fn number_var_to_locator_type_maps_part_number() {
775        assert_eq!(
776            number_var_to_locator_type(&NumberVariable::PartNumber),
777            Some(citum_schema::citation::LocatorType::Part)
778        );
779    }
780
781    #[test]
782    fn number_var_to_locator_type_maps_supplement_number() {
783        assert_eq!(
784            number_var_to_locator_type(&NumberVariable::SupplementNumber),
785            Some(citum_schema::citation::LocatorType::Supplement)
786        );
787    }
788}