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