Skip to main content

citum_engine/values/
date.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 date fields with locale-aware formatting.
7//!
8//! This module handles date component rendering with support for different date forms,
9//! time formatting, and locale-specific date presentation.
10
11use crate::reference::{EdtfString, Reference};
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_edtf::{Edtf, Timezone, UnspecifiedYear, Year};
14use citum_schema::locale::{GeneralTerm, TermForm};
15use citum_schema::options::dates::TimeFormat;
16use citum_schema::reference::types::RefDate;
17use citum_schema::reference::{ClassExtension, WorkRelation};
18use citum_schema::template::{DateForm, DateVariable as TemplateDateVar, TemplateDate};
19
20fn month_to_string(month: u32, months: &[String]) -> String {
21    if month > 0 {
22        let index = month - 1;
23        if let Some(month_name) = months.get(index as usize) {
24            month_name.clone()
25        } else {
26            String::new()
27        }
28    } else {
29        String::new()
30    }
31}
32
33fn extract_month(date: &EdtfString, months: &[String], seasons: &[String]) -> String {
34    let parsed_date = date.parse();
35    let edtf = match parsed_date {
36        RefDate::Edtf(edtf) => edtf,
37        RefDate::Literal(_) => return String::new(),
38    };
39    match edtf.month() {
40        Some(month) => month_to_string(month, months),
41        None => match edtf.season() {
42            Some(season) => month_to_string(season, seasons),
43            None => String::new(),
44        },
45    }
46}
47
48fn event_date(reference: &Reference) -> Option<EdtfString> {
49    match reference.extension() {
50        ClassExtension::Event(event) => event.date.clone(),
51        ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
52        ClassExtension::SerialComponent(component) => {
53            embedded_event_date(component.event.as_ref()?)
54        }
55        ClassExtension::AudioVisual(audio_visual) => {
56            embedded_event_date(audio_visual.event.as_ref()?)
57        }
58        _ => None,
59    }
60}
61
62fn embedded_event_date(relation: &WorkRelation) -> Option<EdtfString> {
63    let WorkRelation::Embedded(reference) = relation else {
64        return None;
65    };
66    let ClassExtension::Event(event) = reference.extension() else {
67        return None;
68    };
69    event.date.clone()
70}
71
72/// Compute the delta for unspecified year ranges.
73fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
74    match u {
75        UnspecifiedYear::None => 0,
76        UnspecifiedYear::One => 9,
77        UnspecifiedYear::Two => 99,
78        UnspecifiedYear::Three => 999,
79        UnspecifiedYear::Four => 9999,
80    }
81}
82
83/// Format a year with era-aware rendering.
84fn format_display_year(
85    year: &Year,
86    date_terms: &citum_schema::locale::DateTerms,
87    era_labels: &citum_schema::options::dates::EraLabels,
88    _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
89    range_delimiter: &str,
90) -> String {
91    // Handle positive unspecified years: normalize 'u' to 'X'
92    if year.unspecified != UnspecifiedYear::None && year.value > 0 {
93        let mut s = year.value.to_string();
94        let unspec_count = match year.unspecified {
95            UnspecifiedYear::One => 1,
96            UnspecifiedYear::Two => 2,
97            UnspecifiedYear::Three => 3,
98            UnspecifiedYear::Four => 4,
99            _ => 0,
100        };
101        for _ in 0..unspec_count {
102            if let Some(last) = s.pop()
103                && last != '0'
104            {
105                s.push('X');
106            }
107        }
108        if s.len() < year.value.to_string().len() {
109            let diff = year.value.to_string().len() - s.len();
110            for _ in 0..diff {
111                s.push('X');
112            }
113        }
114        return s;
115    }
116
117    // Handle negative unspecified years: compute historical range
118    if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
119        let delta = unspecified_year_delta(&year.unspecified);
120        let astronomical_min = year.value - delta;
121        let astronomical_max = year.value;
122        let historical_end = 1 - astronomical_max;
123        let historical_start = 1 - astronomical_min;
124
125        let era_term = match era_labels {
126            citum_schema::options::dates::EraLabels::Default => {
127                date_terms.before_era.as_deref().unwrap_or("")
128            }
129            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
130            citum_schema::options::dates::EraLabels::BceCe => {
131                date_terms.bce.as_deref().unwrap_or("")
132            }
133        };
134
135        if era_term.is_empty() {
136            format!("{historical_start}{range_delimiter}{historical_end}")
137        } else {
138            format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
139        }
140    } else if year.value <= 0 {
141        // Fully specified negative year
142        let historical_year = 1 - year.value;
143        let era_term = match era_labels {
144            citum_schema::options::dates::EraLabels::Default => {
145                date_terms.before_era.as_deref().unwrap_or("")
146            }
147            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
148            citum_schema::options::dates::EraLabels::BceCe => {
149                date_terms.bce.as_deref().unwrap_or("")
150            }
151        };
152
153        if era_term.is_empty() {
154            historical_year.to_string()
155        } else {
156            format!("{historical_year} {era_term}")
157        }
158    } else {
159        // Positive year
160        let era_term = match era_labels {
161            citum_schema::options::dates::EraLabels::Default => "",
162            citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
163            citum_schema::options::dates::EraLabels::BceCe => {
164                date_terms.ce.as_deref().unwrap_or("")
165            }
166        };
167
168        if era_term.is_empty() {
169            year.value.to_string()
170        } else {
171            format!("{} {}", year.value, era_term)
172        }
173    }
174}
175
176/// Legacy format_display_year for backwards compatibility.
177fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
178    if year.unspecified != UnspecifiedYear::None {
179        return year.to_string();
180    }
181
182    if year.value <= 0 {
183        let historical_year = 1 - year.value;
184        if let Some(term) = before_era.filter(|term| !term.is_empty()) {
185            format!("{historical_year} {term}")
186        } else {
187            historical_year.to_string()
188        }
189    } else {
190        year.value.to_string()
191    }
192}
193
194#[allow(dead_code, reason = "kept for backwards compatibility")]
195fn extract_display_year_legacy(date: &EdtfString, before_era: Option<&str>) -> String {
196    match date.parse() {
197        RefDate::Edtf(edtf) => match edtf {
198            Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
199            Edtf::Interval(interval) => {
200                format_display_year_legacy(&interval.start.year, before_era)
201            }
202            Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
203                format_display_year_legacy(&date.year, before_era)
204            }
205        },
206        RefDate::Literal(_) => String::new(),
207    }
208}
209
210/// Formats a time with the specified format, optionally including seconds and timezone.
211///
212/// Converts 24-hour time to 12-hour format if specified, and appends localized
213/// AM/PM or timezone indicators as configured.
214fn format_time(
215    time: citum_edtf::Time,
216    format: &TimeFormat,
217    show_seconds: bool,
218    show_timezone: bool,
219    am_term: Option<&str>,
220    pm_term: Option<&str>,
221    utc_term: Option<&str>,
222) -> String {
223    let (display_hour, period) = match format {
224        TimeFormat::Hour12 => {
225            let (h, p) = if time.hour == 0 {
226                (12u32, am_term.unwrap_or("AM"))
227            } else if time.hour < 12 {
228                (time.hour, am_term.unwrap_or("AM"))
229            } else if time.hour == 12 {
230                (12u32, pm_term.unwrap_or("PM"))
231            } else {
232                (time.hour - 12, pm_term.unwrap_or("PM"))
233            };
234            (h, Some(p))
235        }
236        TimeFormat::Hour24 => (time.hour, None),
237    };
238
239    let time_str = if show_seconds {
240        format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
241    } else {
242        format!("{:02}:{:02}", display_hour, time.minute)
243    };
244
245    let with_period = match period {
246        Some(p) => format!("{time_str} {p}"),
247        None => time_str,
248    };
249
250    if show_timezone {
251        let tz_str = match time.timezone {
252            Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
253            Some(Timezone::Offset(mins)) => {
254                let sign = if mins >= 0 { '+' } else { '-' };
255                let abs = mins.unsigned_abs();
256                format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
257            }
258            None => String::new(),
259        };
260        if tz_str.is_empty() {
261            with_period
262        } else {
263            format!("{with_period} {tz_str}")
264        }
265    } else {
266        with_period
267    }
268}
269
270/// Format a single date or a date range (open or closed) according to the
271/// given form, delegating both endpoints of a range to
272/// [`format_single_date`] so locale patterns apply symmetrically.
273fn format_date_range(
274    date: &EdtfString,
275    form: &DateForm,
276    locale: &citum_schema::locale::Locale,
277    date_config: Option<&citum_schema::options::dates::DateConfig>,
278) -> Option<String> {
279    let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
280
281    match date.parse() {
282        RefDate::Edtf(Edtf::Interval(interval)) => {
283            format_closed_range(date, &interval, form, locale, date_config, delimiter)
284        }
285        RefDate::Edtf(Edtf::IntervalFrom(_)) => {
286            // Open-ended range (e.g., "1990/..'): the accessors on the whole
287            // interval already resolve to the start point.
288            let start = format_single_date(date, form, locale, date_config)?;
289            if let Some(end_marker) = date_config
290                .and_then(|c| c.open_range_marker.as_deref())
291                .or(locale.dates.open_ended_term.as_deref())
292            {
293                Some(format!("{start}{delimiter}{end_marker}"))
294            } else {
295                Some(start)
296            }
297        }
298        // Non-range dates and open-ended-from-start ranges ("../2020") only
299        // have one known point, which the accessors already expose.
300        _ => format_single_date(date, form, locale, date_config),
301    }
302}
303
304/// Format a closed date range, collapsing the start point's year when both
305/// endpoints share a year and the form displays a month.
306fn format_closed_range(
307    date: &EdtfString,
308    interval: &citum_edtf::Interval,
309    form: &DateForm,
310    locale: &citum_schema::locale::Locale,
311    date_config: Option<&citum_schema::options::dates::DateConfig>,
312    delimiter: &str,
313) -> Option<String> {
314    let same_year = interval.start.year.value == interval.end.year.value;
315    let both_have_month =
316        interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
317
318    if same_year
319        && both_have_month
320        && let Some(collapsed) = format_same_year_range(
321            &interval.start,
322            &interval.end,
323            form,
324            locale,
325            date_config,
326            delimiter,
327        )
328    {
329        return Some(collapsed);
330    }
331
332    let start = format_single_date(date, form, locale, date_config);
333    let end = format_single_date(
334        &EdtfString(interval.end.to_string()),
335        form,
336        locale,
337        date_config,
338    );
339
340    match (start, end) {
341        (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
342        (Some(s), None) => Some(s),
343        (None, Some(e)) => Some(e),
344        (None, None) => None,
345    }
346}
347
348/// Format a closed range whose endpoints share a year, suppressing the
349/// redundant year on one side (e.g. "May 14–June 2, 2023").
350///
351/// Only forms with a defined month-suppressed companion collapse; other
352/// forms (e.g. abbreviated-month forms with no such companion) return
353/// `None` so the caller falls back to the uncollapsed rendering.
354fn format_same_year_range(
355    start: &citum_edtf::Date,
356    end: &citum_edtf::Date,
357    form: &DateForm,
358    locale: &citum_schema::locale::Locale,
359    date_config: Option<&citum_schema::options::dates::DateConfig>,
360    delimiter: &str,
361) -> Option<String> {
362    let (start_form, end_form) = match form {
363        DateForm::Full => (DateForm::MonthDay, DateForm::Full),
364        DateForm::YearMonth => (DateForm::Month, DateForm::YearMonth),
365        DateForm::YearMonthDay => (DateForm::YearMonthDay, DateForm::MonthDay),
366        _ => return None,
367    };
368
369    let start_str = format_single_date(
370        &EdtfString(start.to_string()),
371        &start_form,
372        locale,
373        date_config,
374    )?;
375    let end_str = format_single_date(&EdtfString(end.to_string()), &end_form, locale, date_config)?;
376    Some(format!("{start_str}{delimiter}{end_str}"))
377}
378
379/// Apply uncertainty and approximation markers to formatted date.
380fn apply_date_markers(
381    value: String,
382    date: &EdtfString,
383    date_config: Option<&citum_schema::options::dates::DateConfig>,
384) -> String {
385    let mut result = value;
386    if date.is_approximate()
387        && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
388    {
389        result = format!("{marker}{result}");
390    }
391    if date.is_uncertain()
392        && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
393    {
394        result = format!("{result}{marker}");
395    }
396    result
397}
398
399/// Compute the disambiguation suffix for year-based citations.
400fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
401    date: &EdtfString,
402    form: &DateForm,
403    hints: &ProcHints,
404    options: &RenderOptions<'_>,
405    fmt: &F,
406) -> Option<String> {
407    if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
408        compute_disamb_suffix_label(hints, options, fmt)
409    } else {
410        None
411    }
412}
413
414fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
415    hints: &ProcHints,
416    options: &RenderOptions<'_>,
417    fmt: &F,
418) -> Option<String> {
419    // Check if year suffix is enabled, resolving the processing default
420    // centrally so an unset `processing` matches the rest of the engine.
421    let use_suffix = options
422        .config
423        .effective_processing()
424        .config()
425        .disambiguate
426        .as_ref()
427        .is_some_and(|d| d.year_suffix);
428
429    if hints.disamb_condition && use_suffix {
430        int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
431    } else {
432        None
433    }
434}
435
436fn date_form_displays_year(form: &DateForm) -> bool {
437    !matches!(form, DateForm::MonthDay)
438}
439
440fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
441    let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
442        date_config.no_date_year_suffix_delimiter.as_str()
443    });
444    value.push_str(delimiter);
445    value.push_str(suffix);
446}
447
448fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
449    if year.is_empty() || suffix.is_empty() {
450        return formatted.to_string();
451    }
452
453    let year_index = match form {
454        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
455        DateForm::YearMonth
456        | DateForm::Full
457        | DateForm::DayMonthAbbrYear
458        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
459        DateForm::MonthDay => None,
460        _ => None,
461    };
462
463    let Some(index) = year_index else {
464        return format!("{formatted}{suffix}");
465    };
466
467    let year_end = index + year.len();
468    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
469    let result = format!(
470        "{}{}{}{}",
471        &formatted[..index],
472        year,
473        suffix,
474        &formatted[year_end..]
475    );
476    result
477}
478
479/// Format a single date (non-range) according to the given form.
480#[allow(
481    clippy::too_many_lines,
482    reason = "date formatting handles 6 form variants"
483)]
484fn format_single_date(
485    date: &EdtfString,
486    form: &DateForm,
487    locale: &citum_schema::locale::Locale,
488    date_config: Option<&citum_schema::options::dates::DateConfig>,
489) -> Option<String> {
490    let default_era = citum_schema::options::dates::EraLabels::Default;
491    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
492    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
493    let neg_unspecified = date_config
494        .map(|c| &c.negative_unspecified_years)
495        .unwrap_or(&default_neg_unspec);
496    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
497
498    let extract_year = |d: &EdtfString| -> String {
499        match d.parse() {
500            RefDate::Edtf(edtf) => match edtf {
501                Edtf::Date(dt) => format_display_year(
502                    &dt.year,
503                    &locale.dates,
504                    era_labels,
505                    neg_unspecified,
506                    range_delimiter,
507                ),
508                Edtf::Interval(interval) => format_display_year(
509                    &interval.start.year,
510                    &locale.dates,
511                    era_labels,
512                    neg_unspecified,
513                    range_delimiter,
514                ),
515                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
516                    &dt.year,
517                    &locale.dates,
518                    era_labels,
519                    neg_unspecified,
520                    range_delimiter,
521                ),
522            },
523            RefDate::Literal(_) => String::new(),
524        }
525    };
526
527    match form {
528        DateForm::Year => {
529            let year = extract_year(date);
530            if year.is_empty() { None } else { Some(year) }
531        }
532        DateForm::YearMonth => {
533            let year = extract_year(date);
534            if year.is_empty() {
535                return None;
536            }
537            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
538            let month_opt = (!month.is_empty()).then_some(month.as_str());
539            if let Some(rendered) =
540                locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
541            {
542                return Some(rendered);
543            }
544            if month.is_empty() {
545                Some(year)
546            } else {
547                Some(format!("{month} {year}"))
548            }
549        }
550        DateForm::Month => {
551            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
552            if month.is_empty() { None } else { Some(month) }
553        }
554        DateForm::MonthDay => {
555            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
556            if month.is_empty() {
557                return None;
558            }
559            let day = date.day();
560            if let Some(rendered) =
561                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
562            {
563                return Some(rendered);
564            }
565            match day {
566                Some(d) => Some(format!("{month} {d}")),
567                None => Some(month),
568            }
569        }
570        DateForm::Full => {
571            let year = extract_year(date);
572            if year.is_empty() {
573                return None;
574            }
575            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
576            let day = date.day();
577            let base = locale
578                .resolve_date_pattern(
579                    "pattern.date-full",
580                    Some(&year),
581                    (!month.is_empty()).then_some(month.as_str()),
582                    day,
583                )
584                .unwrap_or_else(|| match (month.is_empty(), day) {
585                    (true, _) => year.clone(),
586                    (false, None) => format!("{month} {year}"),
587                    (false, Some(d)) => format!("{month} {d}, {year}"),
588                });
589            // Append time component if configured and present
590            if let (Some(time_fmt), Some(time)) = (
591                date_config.and_then(|c| c.time_format.as_ref()),
592                date.time(),
593            ) {
594                let show_secs = date_config.is_some_and(|c| c.show_seconds);
595                let show_tz = date_config.is_some_and(|c| c.show_timezone);
596                let time_str = format_time(
597                    time,
598                    time_fmt,
599                    show_secs,
600                    show_tz,
601                    locale.dates.am.as_deref(),
602                    locale.dates.pm.as_deref(),
603                    locale.dates.timezone_utc.as_deref(),
604                );
605                Some(format!("{base}, {time_str}"))
606            } else {
607                Some(base)
608            }
609        }
610        DateForm::YearMonthDay => {
611            let year = extract_year(date);
612            if year.is_empty() {
613                return None;
614            }
615            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
616            let day = date.day();
617            let month_opt = (!month.is_empty()).then_some(month.as_str());
618            if let Some(rendered) = locale.resolve_date_pattern(
619                "pattern.date-year-month-day",
620                Some(&year),
621                month_opt,
622                day,
623            ) {
624                return Some(rendered);
625            }
626            match (month.is_empty(), day) {
627                (true, _) => Some(year),
628                (false, None) => Some(format!("{year}, {month}")),
629                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
630            }
631        }
632        DateForm::DayMonthAbbrYear => {
633            let year = extract_year(date);
634            if year.is_empty() {
635                return None;
636            }
637            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
638            let day = date.day();
639            let month_opt = (!month.is_empty()).then_some(month.as_str());
640            if let Some(rendered) = locale.resolve_date_pattern(
641                "pattern.date-day-month-abbr-year",
642                Some(&year),
643                month_opt,
644                day,
645            ) {
646                return Some(rendered);
647            }
648            match (month.is_empty(), day) {
649                (true, _) => Some(year),
650                (false, None) => Some(format!("{month} {year}")),
651                (false, Some(d)) => Some(format!("{d} {month} {year}")),
652            }
653        }
654        DateForm::MonthAbbrDayYear => {
655            let year = extract_year(date);
656            if year.is_empty() {
657                return None;
658            }
659            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
660            let day = date.day();
661            let month_opt = (!month.is_empty()).then_some(month.as_str());
662            if let Some(rendered) = locale.resolve_date_pattern(
663                "pattern.date-month-abbr-day-year",
664                Some(&year),
665                month_opt,
666                day,
667            ) {
668                return Some(rendered);
669            }
670            match (month.is_empty(), day) {
671                (true, _) => Some(year),
672                (false, None) => Some(format!("{month} {year}")),
673                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
674            }
675        }
676        _ => Some(extract_year(date)),
677    }
678}
679
680impl ComponentValues for TemplateDate {
681    fn values<F: crate::render::format::OutputFormat<Output = String>>(
682        &self,
683        reference: &Reference,
684        hints: &ProcHints,
685        options: &RenderOptions<'_>,
686    ) -> Option<ProcValues<F::Output>> {
687        let fmt = F::default();
688        let date_opt: Option<EdtfString> = match self.date {
689            TemplateDateVar::Issued => reference.effective_issued_date(),
690            TemplateDateVar::Accessed => reference.accessed(),
691            TemplateDateVar::OriginalPublished => reference.original_date(),
692            TemplateDateVar::EventDate => event_date(reference),
693            _ => None,
694        };
695
696        let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
697            // Handle fallback if date is missing
698            if let Some(fallbacks) = &self.fallback {
699                for component in fallbacks {
700                    if let Some(values) = component.values::<F>(reference, hints, options) {
701                        return Some(values);
702                    }
703                }
704            }
705            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
706            if matches!(self.date, TemplateDateVar::Issued)
707                && let Some(mut nd) = options.locale.resolved_general_term(
708                    &GeneralTerm::NoDate,
709                    &TermForm::Short,
710                    None,
711                )
712            {
713                if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
714                    append_no_date_disamb_suffix(&mut nd, &suffix, options);
715                }
716                return Some(ProcValues {
717                    value: nd,
718                    prefix: None,
719                    suffix: None,
720                    url: None,
721                    substituted_key: None,
722                    pre_formatted: false,
723                });
724            }
725            return None;
726        };
727
728        let locale = options.locale;
729        let date_config = options.config.dates.as_ref();
730        let effective_form = self.form.clone();
731
732        let formatted = format_date_range(&date, &effective_form, locale, date_config);
733
734        // Apply uncertainty and approximation markers
735        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
736
737        // Handle disambiguation suffix (a, b, c...).
738        // Year-suffix is keyed off the issued year only; suppress it for other date
739        // components (e.g. original-published) so a reprint template renders
740        // `(1926/1967a)` rather than `(1926a/1967a)`.
741        let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
742            .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
743            .flatten();
744
745        formatted.map(|value| {
746            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
747                (
748                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
749                    None,
750                )
751            } else {
752                (value, None)
753            };
754
755            ProcValues {
756                value,
757                prefix: None,
758                suffix,
759                url: crate::values::resolve_effective_url(
760                    self.links.as_ref(),
761                    options.config.links.as_ref(),
762                    reference,
763                    citum_schema::options::LinkAnchor::Component,
764                ),
765                substituted_key: None,
766                pre_formatted: false,
767            }
768        })
769    }
770}
771
772/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
773#[must_use]
774pub fn int_to_letter(n: u32) -> Option<String> {
775    if n == 0 {
776        return None;
777    }
778
779    let mut result = String::new();
780    let mut num = n - 1;
781
782    loop {
783        result.push((b'a' + (num % 26) as u8) as char);
784        if num < 26 {
785            break;
786        }
787        num = num / 26 - 1;
788    }
789
790    Some(result.chars().rev().collect())
791}
792
793#[cfg(test)]
794#[allow(
795    clippy::unwrap_used,
796    clippy::expect_used,
797    clippy::panic,
798    clippy::indexing_slicing,
799    clippy::todo,
800    clippy::unimplemented,
801    clippy::unreachable,
802    clippy::get_unwrap,
803    reason = "Panicking is acceptable and often desired in tests."
804)]
805mod tests {
806    use super::*;
807
808    #[test]
809    fn test_int_to_letter() {
810        // Test basic single-letter conversions (1-26)
811        assert_eq!(int_to_letter(1), Some("a".to_string()));
812        assert_eq!(int_to_letter(2), Some("b".to_string()));
813        assert_eq!(int_to_letter(26), Some("z".to_string()));
814
815        // Test double-letter conversions (27+)
816        assert_eq!(int_to_letter(27), Some("aa".to_string()));
817        assert_eq!(int_to_letter(52), Some("az".to_string()));
818        assert_eq!(int_to_letter(53), Some("ba".to_string()));
819
820        // Test zero returns None
821        assert_eq!(int_to_letter(0), None);
822    }
823}
824
825#[cfg(test)]
826#[allow(
827    clippy::unwrap_used,
828    clippy::expect_used,
829    clippy::panic,
830    clippy::indexing_slicing,
831    clippy::todo,
832    clippy::unimplemented,
833    clippy::unreachable,
834    clippy::get_unwrap,
835    reason = "Panicking is acceptable and often desired in tests."
836)]
837mod time_tests {
838    use super::*;
839    use citum_edtf::{Time, Timezone};
840
841    #[test]
842    fn test_format_time_12h_utc() {
843        let time = Time {
844            hour: 23,
845            minute: 20,
846            second: 30,
847            timezone: Some(Timezone::Utc),
848        };
849        let result = format_time(
850            time,
851            &TimeFormat::Hour12,
852            false,
853            true,
854            Some("AM"),
855            Some("PM"),
856            Some("UTC"),
857        );
858        assert_eq!(result, "11:20 PM UTC");
859    }
860
861    #[test]
862    fn test_format_time_24h_utc() {
863        let time = Time {
864            hour: 23,
865            minute: 20,
866            second: 30,
867            timezone: Some(Timezone::Utc),
868        };
869        let result = format_time(
870            time,
871            &TimeFormat::Hour24,
872            false,
873            true,
874            None,
875            None,
876            Some("UTC"),
877        );
878        assert_eq!(result, "23:20 UTC");
879    }
880
881    #[test]
882    fn test_format_time_with_offset() {
883        let time = Time {
884            hour: 10,
885            minute: 10,
886            second: 10,
887            timezone: Some(Timezone::Offset(330)),
888        };
889        let result = format_time(
890            time,
891            &TimeFormat::Hour24,
892            false,
893            true,
894            None,
895            None,
896            Some("UTC"),
897        );
898        assert_eq!(result, "10:10 +05:30");
899    }
900
901    #[test]
902    fn test_format_time_no_timezone() {
903        let time = Time {
904            hour: 14,
905            minute: 30,
906            second: 0,
907            timezone: None,
908        };
909        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
910        assert_eq!(result, "14:30");
911    }
912}
913
914#[cfg(test)]
915#[allow(
916    clippy::unwrap_used,
917    clippy::expect_used,
918    clippy::panic,
919    clippy::indexing_slicing,
920    clippy::todo,
921    clippy::unimplemented,
922    clippy::unreachable,
923    clippy::get_unwrap,
924    reason = "Panicking is acceptable and often desired in tests."
925)]
926mod era_tests {
927    use super::*;
928    use citum_edtf::{UnspecifiedYear, Year};
929    use citum_schema::locale::{DateTerms, Locale};
930    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
931
932    fn en_terms() -> DateTerms {
933        Locale::en_us().dates
934    }
935
936    #[test]
937    fn positive_year_default_no_suffix() {
938        let year = Year {
939            value: 54,
940            unspecified: UnspecifiedYear::None,
941        };
942        let result = format_display_year(
943            &year,
944            &en_terms(),
945            &EraLabels::Default,
946            &NegativeUnspecifiedYears::Range,
947            "–",
948        );
949        assert_eq!(result, "54");
950    }
951
952    #[test]
953    fn positive_year_bc_ad() {
954        let year = Year {
955            value: 54,
956            unspecified: UnspecifiedYear::None,
957        };
958        let result = format_display_year(
959            &year,
960            &en_terms(),
961            &EraLabels::BcAd,
962            &NegativeUnspecifiedYears::Range,
963            "–",
964        );
965        assert_eq!(result, "54 AD");
966    }
967
968    #[test]
969    fn positive_year_bce_ce() {
970        let year = Year {
971            value: 54,
972            unspecified: UnspecifiedYear::None,
973        };
974        let result = format_display_year(
975            &year,
976            &en_terms(),
977            &EraLabels::BceCe,
978            &NegativeUnspecifiedYears::Range,
979            "–",
980        );
981        assert_eq!(result, "54 CE");
982    }
983
984    #[test]
985    fn negative_year_default() {
986        let year = Year {
987            value: -43,
988            unspecified: UnspecifiedYear::None,
989        };
990        let result = format_display_year(
991            &year,
992            &en_terms(),
993            &EraLabels::Default,
994            &NegativeUnspecifiedYears::Range,
995            "–",
996        );
997        assert_eq!(result, "44 BC");
998    }
999
1000    #[test]
1001    fn negative_year_bc_ad() {
1002        let year = Year {
1003            value: -43,
1004            unspecified: UnspecifiedYear::None,
1005        };
1006        let result = format_display_year(
1007            &year,
1008            &en_terms(),
1009            &EraLabels::BcAd,
1010            &NegativeUnspecifiedYears::Range,
1011            "–",
1012        );
1013        assert_eq!(result, "44 BC");
1014    }
1015
1016    #[test]
1017    fn negative_year_bce_ce() {
1018        let year = Year {
1019            value: -43,
1020            unspecified: UnspecifiedYear::None,
1021        };
1022        let result = format_display_year(
1023            &year,
1024            &en_terms(),
1025            &EraLabels::BceCe,
1026            &NegativeUnspecifiedYears::Range,
1027            "–",
1028        );
1029        assert_eq!(result, "44 BCE");
1030    }
1031
1032    #[test]
1033    fn positive_unspecified_ones() {
1034        let year = Year {
1035            value: 1990,
1036            unspecified: UnspecifiedYear::One,
1037        };
1038        let result = format_display_year(
1039            &year,
1040            &en_terms(),
1041            &EraLabels::Default,
1042            &NegativeUnspecifiedYears::Range,
1043            "–",
1044        );
1045        assert_eq!(result, "199X");
1046    }
1047
1048    #[test]
1049    fn positive_unspecified_two() {
1050        let year = Year {
1051            value: 1900,
1052            unspecified: UnspecifiedYear::Two,
1053        };
1054        let result = format_display_year(
1055            &year,
1056            &en_terms(),
1057            &EraLabels::Default,
1058            &NegativeUnspecifiedYears::Range,
1059            "–",
1060        );
1061        assert_eq!(result, "19XX");
1062    }
1063
1064    #[test]
1065    fn negative_unspecified_range() {
1066        let year = Year {
1067            value: -90,
1068            unspecified: UnspecifiedYear::One,
1069        };
1070        let result = format_display_year(
1071            &year,
1072            &en_terms(),
1073            &EraLabels::Default,
1074            &NegativeUnspecifiedYears::Range,
1075            "–",
1076        );
1077        assert_eq!(result, "100–91 BC");
1078    }
1079
1080    #[test]
1081    fn negative_unspecified_century() {
1082        let year = Year {
1083            value: 0,
1084            unspecified: UnspecifiedYear::Two,
1085        };
1086        let result = format_display_year(
1087            &year,
1088            &en_terms(),
1089            &EraLabels::Default,
1090            &NegativeUnspecifiedYears::Range,
1091            "–",
1092        );
1093        assert_eq!(result, "100–1 BC");
1094    }
1095
1096    #[test]
1097    fn backwards_compat_negative_year() {
1098        let year = Year {
1099            value: -99,
1100            unspecified: UnspecifiedYear::None,
1101        };
1102        let result = format_display_year(
1103            &year,
1104            &en_terms(),
1105            &EraLabels::Default,
1106            &NegativeUnspecifiedYears::Range,
1107            "–",
1108        );
1109        assert_eq!(result, "100 BC");
1110    }
1111}
1112
1113#[cfg(test)]
1114#[allow(
1115    clippy::unwrap_used,
1116    clippy::expect_used,
1117    reason = "Panicking is acceptable in tests."
1118)]
1119mod locale_pattern_tests {
1120    use super::*;
1121    use citum_schema::locale::Locale;
1122
1123    fn en_us() -> Locale {
1124        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1125            .expect("en-US locale should parse")
1126    }
1127
1128    fn es_es() -> Locale {
1129        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1130            .expect("es-ES locale should parse")
1131    }
1132
1133    fn eu_es() -> Locale {
1134        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1135            .expect("eu-ES locale should parse")
1136    }
1137
1138    fn full(locale: &Locale, edtf: &str) -> String {
1139        format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1140            .expect("date should render")
1141    }
1142
1143    fn month_day(locale: &Locale, edtf: &str) -> String {
1144        format_single_date(
1145            &EdtfString(edtf.to_string()),
1146            &DateForm::MonthDay,
1147            locale,
1148            None,
1149        )
1150        .expect("date should render")
1151    }
1152
1153    #[test]
1154    fn en_us_full_unchanged_by_pattern_machinery() {
1155        // Regression: en-US declares no pattern.date-*, so the engine's
1156        // hardcoded English assembly must still produce the original output.
1157        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1158    }
1159
1160    #[test]
1161    fn en_us_month_day_unchanged_by_pattern_machinery() {
1162        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1163    }
1164
1165    #[test]
1166    fn en_us_month_form_renders_month_name_only() {
1167        // given a year-month date and the month-only form
1168        let out = format_single_date(
1169            &EdtfString("2023-06".to_string()),
1170            &DateForm::Month,
1171            &en_us(),
1172            None,
1173        );
1174        // then only the month name renders (no year), e.g. magazines
1175        assert_eq!(out.as_deref(), Some("June"));
1176    }
1177
1178    #[test]
1179    fn en_us_month_form_renders_season_name() {
1180        // given an EDTF season date and the month-only form
1181        let out = format_single_date(
1182            &EdtfString("2023-21".to_string()),
1183            &DateForm::Month,
1184            &en_us(),
1185            None,
1186        );
1187        // then the locale's season term renders in place of a month name
1188        assert_eq!(out.as_deref(), Some("Spring"));
1189    }
1190
1191    #[test]
1192    fn en_us_year_month_form_renders_season_and_year() {
1193        let out = format_single_date(
1194            &EdtfString("2023-21".to_string()),
1195            &DateForm::YearMonth,
1196            &en_us(),
1197            None,
1198        );
1199        assert_eq!(out.as_deref(), Some("Spring 2023"));
1200    }
1201
1202    #[test]
1203    fn en_us_full_form_renders_season_and_year() {
1204        assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1205    }
1206
1207    #[test]
1208    fn es_es_year_month_form_renders_localized_season() {
1209        let out = format_single_date(
1210            &EdtfString("2023-23".to_string()),
1211            &DateForm::YearMonth,
1212            &es_es(),
1213            None,
1214        );
1215        assert_eq!(out.as_deref(), Some("otoño de 2023"));
1216    }
1217
1218    #[test]
1219    fn es_es_full_uses_locale_pattern() {
1220        // Spanish day-first assembly via pattern.date-full.
1221        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1222    }
1223
1224    #[test]
1225    fn es_es_month_day_uses_locale_pattern() {
1226        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1227    }
1228
1229    #[test]
1230    fn eu_es_full_uses_locale_pattern() {
1231        // Basque genitive-absolutive shape via pattern.date-full.
1232        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
1233        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1234    }
1235
1236    #[test]
1237    fn eu_es_month_day_uses_locale_pattern() {
1238        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1239    }
1240
1241    fn year_month(locale: &Locale, edtf: &str) -> String {
1242        format_single_date(
1243            &EdtfString(edtf.to_string()),
1244            &DateForm::YearMonth,
1245            locale,
1246            None,
1247        )
1248        .expect("date should render")
1249    }
1250
1251    fn year_month_day(locale: &Locale, edtf: &str) -> String {
1252        format_single_date(
1253            &EdtfString(edtf.to_string()),
1254            &DateForm::YearMonthDay,
1255            locale,
1256            None,
1257        )
1258        .expect("date should render")
1259    }
1260
1261    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1262        format_single_date(
1263            &EdtfString(edtf.to_string()),
1264            &DateForm::DayMonthAbbrYear,
1265            locale,
1266            None,
1267        )
1268        .expect("date should render")
1269    }
1270
1271    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1272        format_single_date(
1273            &EdtfString(edtf.to_string()),
1274            &DateForm::MonthAbbrDayYear,
1275            locale,
1276            None,
1277        )
1278        .expect("date should render")
1279    }
1280
1281    #[test]
1282    fn en_us_year_month_unchanged_by_pattern_machinery() {
1283        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
1284        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1285    }
1286
1287    #[test]
1288    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1289        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1290    }
1291
1292    #[test]
1293    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1294        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1295    }
1296
1297    #[test]
1298    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1299        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1300    }
1301
1302    #[test]
1303    fn es_es_year_month_uses_locale_pattern() {
1304        // Spanish: month before year connected with "de".
1305        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1306    }
1307
1308    #[test]
1309    fn eu_es_year_month_uses_locale_pattern() {
1310        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
1311        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1312    }
1313
1314    #[test]
1315    fn year_month_missing_month_falls_back_to_year() {
1316        // Year-only EDTF: no month to pattern-assemble, returns year alone.
1317        assert_eq!(year_month(&es_es(), "2023"), "2023");
1318    }
1319
1320    #[test]
1321    fn es_es_year_month_day_uses_locale_pattern() {
1322        // Spanish: year first, then day/month connected with "de".
1323        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1324    }
1325
1326    #[test]
1327    fn es_es_year_month_day_missing_day_falls_back() {
1328        // Pattern requires $day; evaluator returns None, falls back to
1329        // hardcoded "{year}, {month}".
1330        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1331    }
1332
1333    #[test]
1334    fn es_es_day_month_abbr_year_uses_locale_pattern() {
1335        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
1336        assert_eq!(
1337            day_month_abbr_year(&es_es(), "2023-01-12"),
1338            "12 ene. de 2023"
1339        );
1340    }
1341
1342    #[test]
1343    fn es_es_day_month_abbr_year_missing_day_falls_back() {
1344        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1345        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1346    }
1347
1348    #[test]
1349    fn es_es_month_abbr_day_year_uses_locale_pattern() {
1350        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
1351        assert_eq!(
1352            month_abbr_day_year(&es_es(), "2023-01-12"),
1353            "ene. 12 de 2023"
1354        );
1355    }
1356
1357    #[test]
1358    fn es_es_month_abbr_day_year_missing_day_falls_back() {
1359        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1360        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1361    }
1362
1363    #[test]
1364    fn pattern_missing_day_falls_back_to_english_assembly() {
1365        // Year-month only input: pattern.date-full requires {$day} so the
1366        // evaluator returns None, and the engine falls through to its
1367        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
1368        // can fix this for inflected locales — out of scope for this bean.)
1369        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1370    }
1371}
1372
1373#[cfg(test)]
1374#[allow(
1375    clippy::unwrap_used,
1376    clippy::expect_used,
1377    reason = "Panicking is acceptable in tests."
1378)]
1379mod range_tests {
1380    use super::*;
1381    use citum_schema::locale::Locale;
1382
1383    fn en_us() -> Locale {
1384        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1385            .expect("en-US locale should parse")
1386    }
1387
1388    fn es_es() -> Locale {
1389        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1390            .expect("es-ES locale should parse")
1391    }
1392
1393    fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1394        format_date_range(&EdtfString(edtf.to_string()), &form, locale, None)
1395    }
1396
1397    #[test]
1398    fn closed_range_year_form_regression() {
1399        // given a closed range with distinct years and the Year form
1400        // then it renders as a plain year-to-year range (no collapse)
1401        assert_eq!(
1402            range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1403            Some("2020–2022")
1404        );
1405    }
1406
1407    #[test]
1408    fn closed_range_full_form_different_years() {
1409        // given a closed range spanning two years, Full form
1410        // then both endpoints render in full
1411        assert_eq!(
1412            range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
1413            Some("May 14, 2023–June 2, 2024")
1414        );
1415    }
1416
1417    #[test]
1418    fn closed_range_full_form_same_year_collapses() {
1419        // given a closed range within a single year, Full form
1420        // then the start's year is suppressed and trails the end instead
1421        assert_eq!(
1422            range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
1423            Some("May 14–June 2, 2023")
1424        );
1425    }
1426
1427    #[test]
1428    fn closed_range_year_month_day_same_year_collapses() {
1429        // given a closed range within a single year, YearMonthDay form
1430        // then the leading year renders once and the end's year is suppressed
1431        assert_eq!(
1432            range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
1433            Some("2023, May 14–June 2")
1434        );
1435    }
1436
1437    #[test]
1438    fn closed_range_full_form_es_es_locale_pattern() {
1439        // given a closed range spanning two years under a locale that
1440        // declares pattern.date-full
1441        // then both endpoints render through the Spanish pattern
1442        assert_eq!(
1443            range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
1444            Some("12 de enero de 2023–3 de febrero de 2024")
1445        );
1446    }
1447
1448    #[test]
1449    fn interval_to_year_form() {
1450        // given an open-ended-from-start range ("../2020")
1451        // then it renders as the single known (end) point
1452        assert_eq!(
1453            range(&en_us(), "../2020", DateForm::Year).as_deref(),
1454            Some("2020")
1455        );
1456    }
1457
1458    #[test]
1459    fn closed_range_year_month_same_year_collapses() {
1460        // given month-only endpoints in the same year, YearMonth form
1461        // then the start month renders without the (shared) year
1462        assert_eq!(
1463            range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
1464            Some("May–June 2023")
1465        );
1466    }
1467
1468    #[test]
1469    fn closed_range_season_same_year_collapses() {
1470        // given EDTF season endpoints in the same year, YearMonth form
1471        // then the start season renders without the (shared) year
1472        assert_eq!(
1473            range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
1474            Some("Spring–Summer 2023")
1475        );
1476    }
1477}