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::{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
217impl ComponentValues for TemplateNumber {
218    fn values<F: crate::render::format::OutputFormat<Output = String>>(
219        &self,
220        reference: &Reference,
221        hints: &ProcHints,
222        options: &RenderOptions<'_>,
223    ) -> Option<ProcValues<F::Output>> {
224        let fmt = F::default();
225
226        let value = resolve_number_value(
227            &self.number,
228            reference,
229            hints,
230            options,
231            self.show_with_locator.unwrap_or(false),
232        );
233
234        value.filter(|s| !s.is_empty()).map(|value| {
235            // Resolve effective rendering options
236            let effective_rendering = &self.rendering;
237
238            // Free-text number values (e.g. `edition`) honor an explicit
239            // text-case override the same way string variables do.
240            let value = if let Some(tc) = effective_rendering.text_case {
241                crate::values::text_case::apply_text_case(&value, tc)
242            } else {
243                value
244            };
245            let value_is_numeric = is_numeric(&value);
246
247            let value = if self.form == Some(NumberForm::Ordinal) {
248                render_ordinal(value, options)
249            } else {
250                value
251            };
252
253            // Handle label if label_form is specified
254            let label_prefix = if let Some(label_form) = &self.label_form {
255                resolve_number_label(
256                    &self.number,
257                    label_form,
258                    &value,
259                    self.gender.clone(),
260                    effective_rendering,
261                    options,
262                    &fmt,
263                )
264            } else {
265                None
266            };
267
268            // `when_numeric` resolves this number's locale term (GB/T 7714's
269            // `edition`/`volume` general terms) and wraps the value with it —
270            // only when the resolved value is numeric; free-text values like
271            // `修订版` or a pre-labeled `美国卷` render bare.
272            let (numeric_prefix, numeric_suffix) = self
273                .when_numeric
274                .as_ref()
275                .filter(|_| value_is_numeric)
276                .and_then(|form| {
277                    let general_term = number_var_to_general_term(&self.number)?;
278                    options.locale.resolved_general_term(
279                        &general_term,
280                        &label_form_to_term_form(form),
281                        self.gender.clone(),
282                    )
283                })
284                .map(|term| split_numeric_term(&term))
285                .unwrap_or((None, None));
286
287            let prefix = match (label_prefix, numeric_prefix) {
288                (Some(label), Some(numeric)) => Some(format!("{label}{numeric}")),
289                (Some(label), None) => Some(label),
290                (None, Some(numeric)) => Some(numeric),
291                (None, None) => None,
292            };
293            let suffix = numeric_suffix;
294
295            ProcValues {
296                value,
297                prefix,
298                suffix,
299                url: crate::values::resolve_effective_url(
300                    self.links.as_ref(),
301                    options.config.links.as_ref(),
302                    reference,
303                    citum_schema::options::LinkAnchor::Component,
304                ),
305                substituted_key: None,
306                pre_formatted: false,
307            }
308        })
309    }
310}
311
312/// Citeproc-style `is-numeric` check used to gate [`TemplateNumber::when_numeric`]
313/// affixes.
314///
315/// True for digit runs optionally joined by whitespace, commas, hyphens, or
316/// ampersands (bare numerals, ranges, and lists like `"2"`, `"1-3"`,
317/// `"12, 14"`). False for any value that mixes a digit with other text
318/// (`"新1版"`) or contains none (`"修订版"`, `"美国卷"`, `"第二卷"` — the last
319/// uses a CJK numeral character, not an ASCII digit, and so is treated as an
320/// already-complete label rather than a bare number to wrap.
321fn is_numeric(value: &str) -> bool {
322    let value = value.trim();
323    let mut saw_digit = false;
324    for ch in value.chars() {
325        if ch.is_ascii_digit() {
326            saw_digit = true;
327        } else if !matches!(ch, '-' | ',' | '&' | ' ') {
328            return false;
329        }
330    }
331    saw_digit
332}
333
334#[cfg(test)]
335mod is_numeric_tests {
336    use super::is_numeric;
337
338    #[test]
339    fn given_bare_digits_when_checked_then_numeric() {
340        assert!(is_numeric("2"));
341        assert!(is_numeric("510"));
342    }
343
344    #[test]
345    fn given_digit_range_or_list_when_checked_then_numeric() {
346        assert!(is_numeric("1-3"));
347        assert!(is_numeric("12, 14"));
348    }
349
350    #[test]
351    fn given_digit_embedded_in_free_text_when_checked_then_not_numeric() {
352        assert!(!is_numeric("新1版"));
353    }
354
355    #[test]
356    fn given_free_text_without_digits_when_checked_then_not_numeric() {
357        assert!(!is_numeric("修订版"));
358        assert!(!is_numeric("美国卷"));
359    }
360
361    #[test]
362    fn given_cjk_numeral_when_checked_then_not_numeric() {
363        // "二" is a CJK numeral character, not an ASCII digit; GB/T 7714
364        // treats "第二卷" as an already-complete label, not a bare number.
365        assert!(!is_numeric("第二卷"));
366    }
367
368    #[test]
369    fn given_empty_value_when_checked_then_not_numeric() {
370        assert!(!is_numeric(""));
371        assert!(!is_numeric("   "));
372    }
373}
374
375#[cfg(test)]
376mod split_numeric_term_tests {
377    use super::split_numeric_term;
378
379    #[test]
380    fn given_circumfix_term_when_split_then_wraps_around_marker() {
381        assert_eq!(
382            split_numeric_term("第%s卷"),
383            (Some("第".to_string()), Some("卷".to_string()))
384        );
385    }
386
387    #[test]
388    fn given_suffix_only_term_when_split_then_prefix_only_marker() {
389        // "%s卷" (no leading text) has an empty prefix half, so no prefix is emitted.
390        assert_eq!(split_numeric_term("%s卷"), (None, Some("卷".to_string())));
391    }
392
393    #[test]
394    fn given_plain_term_when_split_then_space_separated_suffix() {
395        assert_eq!(split_numeric_term("版"), (None, Some(" 版".to_string())));
396    }
397}
398
399/// Maps a number variable to its corresponding locator type.
400///
401/// Determines which `LocatorType` corresponds to a given numeric variable,
402/// allowing proper label selection when rendering page, volume, or issue information.
403/// Returns `None` for variables with no locator equivalent (e.g. edition, version).
404#[must_use]
405pub fn number_var_to_locator_type(
406    var: &NumberVariable,
407) -> Option<citum_schema::citation::LocatorType> {
408    use citum_schema::citation::LocatorType;
409    match var {
410        NumberVariable::Volume => Some(LocatorType::Volume),
411        NumberVariable::Pages => Some(LocatorType::Page),
412        NumberVariable::ChapterNumber => Some(LocatorType::Chapter),
413        NumberVariable::NumberOfPages => Some(LocatorType::Page),
414        NumberVariable::NumberOfVolumes => Some(LocatorType::Volume),
415        NumberVariable::Number
416        | NumberVariable::DocketNumber
417        | NumberVariable::PatentNumber
418        | NumberVariable::StandardNumber
419        | NumberVariable::ReportNumber
420        | NumberVariable::PrintingNumber => Some(LocatorType::Number),
421        NumberVariable::PartNumber => Some(LocatorType::Part),
422        NumberVariable::SupplementNumber => Some(LocatorType::Supplement),
423        NumberVariable::Issue => Some(LocatorType::Issue),
424        NumberVariable::Custom(kind) => Some(LocatorType::Custom(kind.clone())),
425        _ => None,
426    }
427}
428
429/// Heuristically detect whether a locator string should use plural labeling.
430///
431/// Returns `true` if the value contains range or list separators — hyphens (`-`),
432/// en-dashes (`–`), commas (`,`), or ampersands (`&`) — indicating multiple items
433/// such as `"1-10"`, `"1, 3"`, or `"1 & 3"`.
434#[must_use]
435pub fn check_plural(value: &str, _locator_type: &citum_schema::citation::LocatorType) -> bool {
436    // Simple heuristic: if contains ranges or separators, it's plural.
437    // "1-10", "1, 3", "1 & 3"
438    value.contains('–') || value.contains('-') || value.contains(',') || value.contains('&')
439}
440
441/// Format a page range according to the specified format.
442///
443/// Formats: expanded (default), minimal, minimal-two, chicago, chicago-16.
444/// `delimiter` is the range separator (usually the locale's
445/// `page-range-delimiter`, en-dash by default; AMA and similar use a hyphen).
446#[must_use]
447pub fn format_page_range(
448    pages: &str,
449    format: Option<&citum_schema::options::PageRangeFormat>,
450    delimiter: &str,
451) -> String {
452    use citum_schema::options::PageRangeFormat;
453
454    // Normalize any en-dash separator to a plain hyphen so splitting below is
455    // delimiter-agnostic; ranges are re-joined with the configured `delimiter`.
456    let normalized = pages.replace('\u{2013}', "-");
457    let with_delimiter = || normalized.replace('-', delimiter);
458
459    // If no format specified, just apply the delimiter to the range.
460    let Some(format) = format else {
461        return with_delimiter();
462    };
463
464    let parts: Vec<&str> = normalized.split('-').collect();
465    let [start, end] = parts.as_slice() else {
466        return with_delimiter(); // Not a simple range
467    };
468    let start = start.trim();
469    let end = end.trim();
470
471    // Parse as numbers
472    let start_num: Option<u32> = start.parse().ok();
473    let end_num: Option<u32> = end.parse().ok();
474
475    match (start_num, end_num) {
476        (Some(s), Some(e)) if e > s => {
477            let formatted_end = match format {
478                PageRangeFormat::Expanded => end.to_string(),
479                PageRangeFormat::Minimal => format_minimal(start, end, 1),
480                PageRangeFormat::MinimalTwo => format_minimal(start, end, 2),
481                PageRangeFormat::Chicago | PageRangeFormat::Chicago16 => {
482                    format_chicago_page_range_end(s, e)
483                }
484                _ => end.to_string(), // Future variants: default to expanded
485            };
486            format!("{start}{delimiter}{formatted_end}")
487        }
488        _ => with_delimiter(), // Can't parse or invalid range
489    }
490}
491
492/// Minimal format: keep only differing digits, with minimum `min_digits`
493#[must_use]
494pub fn format_minimal(start: &str, end: &str, min_digits: usize) -> String {
495    let start_chars: Vec<char> = start.chars().collect();
496    let end_chars: Vec<char> = end.chars().collect();
497
498    if start_chars.len() != end_chars.len() {
499        return end.to_string();
500    }
501
502    // Find first differing position
503    let mut first_diff = 0;
504    for (i, (s, e)) in start_chars.iter().zip(end_chars.iter()).enumerate() {
505        if s != e {
506            first_diff = i;
507            break;
508        }
509    }
510
511    // Keep at least min_digits from the end
512    let keep_from = first_diff.min(end_chars.len().saturating_sub(min_digits));
513    end_chars
514        .get(keep_from..)
515        .unwrap_or_default()
516        .iter()
517        .collect()
518}
519
520/// Format the second number in a Chicago Manual of Style page range.
521#[must_use]
522fn format_chicago_page_range_end(start: u32, end: u32) -> String {
523    // Chicago rules:
524    // - start < 100: use all digits
525    // - start exact multiple of 100: use all digits
526    // - start % 100 is 1..=9: use the changed part only and trim any leading
527    //   zero from that suffix (1002–6, 505–17, 107–8)
528    // - otherwise: keep at least two digits unless more are needed to show the
529    //   changed part (321–25, 1087–89, 1496–500, 13792–803)
530
531    if start < 100 || start.is_multiple_of(100) {
532        return end.to_string();
533    }
534
535    let start_str = start.to_string();
536    let end_str = end.to_string();
537    let changed_end_part = if start % 100 <= 9 {
538        format_minimal(&start_str, &end_str, 1)
539    } else {
540        format_minimal(&start_str, &end_str, 2)
541    };
542
543    if changed_end_part.len() > 1 && changed_end_part.starts_with('0') {
544        let trimmed = changed_end_part.trim_start_matches('0');
545        if trimmed.is_empty() {
546            "0".to_string()
547        } else {
548            trimmed.to_string()
549        }
550    } else {
551        changed_end_part
552    }
553}
554
555/// Format a Chicago Manual of Style page range end.
556#[must_use]
557pub fn format_chicago(start: u32, end: u32) -> String {
558    format_chicago_page_range_end(start, end)
559}
560
561#[cfg(test)]
562#[allow(
563    clippy::unwrap_used,
564    clippy::expect_used,
565    clippy::panic,
566    clippy::indexing_slicing,
567    clippy::todo,
568    clippy::unimplemented,
569    clippy::unreachable,
570    clippy::get_unwrap,
571    reason = "Panicking is acceptable and often desired in tests."
572)]
573mod tests {
574    use super::*;
575    use citum_schema::options::PageRangeFormat;
576
577    #[test]
578    fn test_format_chicago_page_range_end() {
579        for (start, end, expected) in [
580            (3, 10, "10"),
581            (71, 72, "72"),
582            (92, 113, "113"),
583            (100, 104, "104"),
584            (600, 613, "613"),
585            (107, 108, "8"),
586            (505, 517, "17"),
587            (1002, 1006, "6"),
588            (321, 325, "25"),
589            (415, 532, "532"),
590            (1087, 1089, "89"),
591            (1496, 1500, "500"),
592            (13792, 13803, "803"),
593            (12991, 13001, "3001"),
594        ] {
595            assert_eq!(format_chicago_page_range_end(start, end), expected);
596        }
597    }
598
599    #[test]
600    fn test_format_minimal() {
601        for (start, end, min_digits, expected) in [
602            ("100", "105", 1, "5"),
603            ("100", "105", 2, "05"),
604            ("1536", "1538", 1, "8"),
605            ("1536", "1538", 2, "38"),
606            ("1536", "1538", 4, "1538"),
607            ("12", "15", 1, "5"),
608            ("12", "15", 2, "15"),
609            ("10", "150", 1, "150"),
610        ] {
611            assert_eq!(format_minimal(start, end, min_digits), expected);
612        }
613    }
614
615    #[test]
616    fn test_format_page_range() {
617        // Default en-dash delimiter.
618        let en = "\u{2013}";
619        for (input, format, expected) in [
620            ("10-15", None, "10–15"),
621            ("10–15", None, "10–15"),
622            ("321-328", None, "321–328"),
623            ("10-15", Some(PageRangeFormat::Expanded), "10–15"),
624            ("42-45", Some(PageRangeFormat::Expanded), "42–45"),
625            ("3-10", Some(PageRangeFormat::Chicago), "3–10"),
626            ("71-72", Some(PageRangeFormat::Chicago), "71–72"),
627            ("92-113", Some(PageRangeFormat::Chicago), "92–113"),
628            ("100-104", Some(PageRangeFormat::Chicago), "100–104"),
629            ("600-613", Some(PageRangeFormat::Chicago), "600–613"),
630            ("107-108", Some(PageRangeFormat::Chicago), "107–8"),
631            ("505-517", Some(PageRangeFormat::Chicago), "505–17"),
632            ("1002-1006", Some(PageRangeFormat::Chicago), "1002–6"),
633            ("321-325", Some(PageRangeFormat::Chicago), "321–25"),
634            ("415-532", Some(PageRangeFormat::Chicago), "415–532"),
635            ("1087-1089", Some(PageRangeFormat::Chicago), "1087–89"),
636            ("1496-1500", Some(PageRangeFormat::Chicago), "1496–500"),
637            ("13792-13803", Some(PageRangeFormat::Chicago), "13792–803"),
638            ("12991-13001", Some(PageRangeFormat::Chicago), "12991–3001"),
639            ("3-10", Some(PageRangeFormat::Chicago16), "3–10"),
640            ("71-72", Some(PageRangeFormat::Chicago16), "71–72"),
641            ("92-113", Some(PageRangeFormat::Chicago16), "92–113"),
642            ("100-104", Some(PageRangeFormat::Chicago16), "100–104"),
643            ("600-613", Some(PageRangeFormat::Chicago16), "600–613"),
644            ("107-108", Some(PageRangeFormat::Chicago16), "107–8"),
645            ("505-517", Some(PageRangeFormat::Chicago16), "505–17"),
646            ("1002-1006", Some(PageRangeFormat::Chicago16), "1002–6"),
647            ("321-325", Some(PageRangeFormat::Chicago16), "321–25"),
648            ("415-532", Some(PageRangeFormat::Chicago16), "415–532"),
649            ("1087-1089", Some(PageRangeFormat::Chicago16), "1087–89"),
650            ("1496-1500", Some(PageRangeFormat::Chicago16), "1496–500"),
651            ("13792-13803", Some(PageRangeFormat::Chicago16), "13792–803"),
652            (
653                "12991-13001",
654                Some(PageRangeFormat::Chicago16),
655                "12991–3001",
656            ),
657            ("100-105", Some(PageRangeFormat::Minimal), "100–5"),
658            ("321-328", Some(PageRangeFormat::Minimal), "321–8"),
659            ("42-45", Some(PageRangeFormat::Minimal), "42–5"),
660            ("12-17", Some(PageRangeFormat::Minimal), "12–7"),
661            ("100-105", Some(PageRangeFormat::MinimalTwo), "100–05"),
662            ("42-45", Some(PageRangeFormat::MinimalTwo), "42–45"),
663            ("10", Some(PageRangeFormat::Chicago), "10"),
664            ("10-5", Some(PageRangeFormat::Chicago), "10–5"),
665            ("X-Y", Some(PageRangeFormat::Chicago), "X–Y"),
666            ("10-15-20", Some(PageRangeFormat::Chicago), "10–15–20"),
667        ] {
668            assert_eq!(format_page_range(input, format.as_ref(), en), expected);
669        }
670    }
671
672    #[test]
673    fn test_format_page_range_hyphen_delimiter() {
674        // AMA-style hyphen delimiter: en-dash input is normalized to a hyphen,
675        // and range formats still apply.
676        for (input, format, expected) in [
677            ("436-444", None, "436-444"),
678            ("436–444", None, "436-444"),
679            ("321-328", Some(PageRangeFormat::Expanded), "321-328"),
680            ("321-328", Some(PageRangeFormat::Chicago), "321-28"),
681        ] {
682            assert_eq!(format_page_range(input, format.as_ref(), "-"), expected);
683        }
684    }
685
686    #[test]
687    fn test_check_plural() {
688        for (value, expected) in [
689            ("1-10", true),
690            ("1–10", true),
691            ("1, 3", true),
692            ("1 & 3", true),
693            ("1", false),
694            ("IV", false),
695        ] {
696            assert_eq!(
697                check_plural(value, &citum_schema::citation::LocatorType::Page),
698                expected
699            );
700        }
701    }
702
703    #[test]
704    fn number_var_to_locator_type_maps_printing_number() {
705        assert_eq!(
706            number_var_to_locator_type(&NumberVariable::PrintingNumber),
707            Some(citum_schema::citation::LocatorType::Number)
708        );
709    }
710
711    #[test]
712    fn number_var_to_locator_type_maps_part_number() {
713        assert_eq!(
714            number_var_to_locator_type(&NumberVariable::PartNumber),
715            Some(citum_schema::citation::LocatorType::Part)
716        );
717    }
718
719    #[test]
720    fn number_var_to_locator_type_maps_supplement_number() {
721        assert_eq!(
722            number_var_to_locator_type(&NumberVariable::SupplementNumber),
723            Some(citum_schema::citation::LocatorType::Supplement)
724        );
725    }
726}