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        // Check if year suffix is enabled, resolving the processing default
409        // centrally so an unset `processing` matches the rest of the engine.
410        let use_suffix = options
411            .config
412            .effective_processing()
413            .config()
414            .disambiguate
415            .as_ref()
416            .is_some_and(|d| d.year_suffix);
417
418        if use_suffix {
419            int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
420        } else {
421            None
422        }
423    } else {
424        None
425    }
426}
427
428fn date_form_displays_year(form: &DateForm) -> bool {
429    !matches!(form, DateForm::MonthDay)
430}
431
432fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
433    if year.is_empty() || suffix.is_empty() {
434        return formatted.to_string();
435    }
436
437    let year_index = match form {
438        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
439        DateForm::YearMonth
440        | DateForm::Full
441        | DateForm::DayMonthAbbrYear
442        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
443        DateForm::MonthDay => None,
444        _ => None,
445    };
446
447    let Some(index) = year_index else {
448        return format!("{formatted}{suffix}");
449    };
450
451    let year_end = index + year.len();
452    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
453    let result = format!(
454        "{}{}{}{}",
455        &formatted[..index],
456        year,
457        suffix,
458        &formatted[year_end..]
459    );
460    result
461}
462
463/// Format a single date (non-range) according to the given form.
464#[allow(
465    clippy::too_many_lines,
466    reason = "date formatting handles 6 form variants"
467)]
468fn format_single_date(
469    date: &EdtfString,
470    form: &DateForm,
471    locale: &citum_schema::locale::Locale,
472    date_config: Option<&citum_schema::options::dates::DateConfig>,
473) -> Option<String> {
474    let default_era = citum_schema::options::dates::EraLabels::Default;
475    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
476    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
477    let neg_unspecified = date_config
478        .map(|c| &c.negative_unspecified_years)
479        .unwrap_or(&default_neg_unspec);
480    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
481
482    let extract_year = |d: &EdtfString| -> String {
483        match d.parse() {
484            RefDate::Edtf(edtf) => match edtf {
485                Edtf::Date(dt) => format_display_year(
486                    &dt.year,
487                    &locale.dates,
488                    era_labels,
489                    neg_unspecified,
490                    range_delimiter,
491                ),
492                Edtf::Interval(interval) => format_display_year(
493                    &interval.start.year,
494                    &locale.dates,
495                    era_labels,
496                    neg_unspecified,
497                    range_delimiter,
498                ),
499                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
500                    &dt.year,
501                    &locale.dates,
502                    era_labels,
503                    neg_unspecified,
504                    range_delimiter,
505                ),
506            },
507            RefDate::Literal(_) => String::new(),
508        }
509    };
510
511    match form {
512        DateForm::Year => {
513            let year = extract_year(date);
514            if year.is_empty() { None } else { Some(year) }
515        }
516        DateForm::YearMonth => {
517            let year = extract_year(date);
518            if year.is_empty() {
519                return None;
520            }
521            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
522            let month_opt = (!month.is_empty()).then_some(month.as_str());
523            if let Some(rendered) =
524                locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
525            {
526                return Some(rendered);
527            }
528            if month.is_empty() {
529                Some(year)
530            } else {
531                Some(format!("{month} {year}"))
532            }
533        }
534        DateForm::Month => {
535            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
536            if month.is_empty() { None } else { Some(month) }
537        }
538        DateForm::MonthDay => {
539            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
540            if month.is_empty() {
541                return None;
542            }
543            let day = date.day();
544            if let Some(rendered) =
545                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
546            {
547                return Some(rendered);
548            }
549            match day {
550                Some(d) => Some(format!("{month} {d}")),
551                None => Some(month),
552            }
553        }
554        DateForm::Full => {
555            let year = extract_year(date);
556            if year.is_empty() {
557                return None;
558            }
559            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
560            let day = date.day();
561            let base = locale
562                .resolve_date_pattern(
563                    "pattern.date-full",
564                    Some(&year),
565                    (!month.is_empty()).then_some(month.as_str()),
566                    day,
567                )
568                .unwrap_or_else(|| match (month.is_empty(), day) {
569                    (true, _) => year.clone(),
570                    (false, None) => format!("{month} {year}"),
571                    (false, Some(d)) => format!("{month} {d}, {year}"),
572                });
573            // Append time component if configured and present
574            if let (Some(time_fmt), Some(time)) = (
575                date_config.and_then(|c| c.time_format.as_ref()),
576                date.time(),
577            ) {
578                let show_secs = date_config.is_some_and(|c| c.show_seconds);
579                let show_tz = date_config.is_some_and(|c| c.show_timezone);
580                let time_str = format_time(
581                    time,
582                    time_fmt,
583                    show_secs,
584                    show_tz,
585                    locale.dates.am.as_deref(),
586                    locale.dates.pm.as_deref(),
587                    locale.dates.timezone_utc.as_deref(),
588                );
589                Some(format!("{base}, {time_str}"))
590            } else {
591                Some(base)
592            }
593        }
594        DateForm::YearMonthDay => {
595            let year = extract_year(date);
596            if year.is_empty() {
597                return None;
598            }
599            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
600            let day = date.day();
601            let month_opt = (!month.is_empty()).then_some(month.as_str());
602            if let Some(rendered) = locale.resolve_date_pattern(
603                "pattern.date-year-month-day",
604                Some(&year),
605                month_opt,
606                day,
607            ) {
608                return Some(rendered);
609            }
610            match (month.is_empty(), day) {
611                (true, _) => Some(year),
612                (false, None) => Some(format!("{year}, {month}")),
613                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
614            }
615        }
616        DateForm::DayMonthAbbrYear => {
617            let year = extract_year(date);
618            if year.is_empty() {
619                return None;
620            }
621            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
622            let day = date.day();
623            let month_opt = (!month.is_empty()).then_some(month.as_str());
624            if let Some(rendered) = locale.resolve_date_pattern(
625                "pattern.date-day-month-abbr-year",
626                Some(&year),
627                month_opt,
628                day,
629            ) {
630                return Some(rendered);
631            }
632            match (month.is_empty(), day) {
633                (true, _) => Some(year),
634                (false, None) => Some(format!("{month} {year}")),
635                (false, Some(d)) => Some(format!("{d} {month} {year}")),
636            }
637        }
638        DateForm::MonthAbbrDayYear => {
639            let year = extract_year(date);
640            if year.is_empty() {
641                return None;
642            }
643            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
644            let day = date.day();
645            let month_opt = (!month.is_empty()).then_some(month.as_str());
646            if let Some(rendered) = locale.resolve_date_pattern(
647                "pattern.date-month-abbr-day-year",
648                Some(&year),
649                month_opt,
650                day,
651            ) {
652                return Some(rendered);
653            }
654            match (month.is_empty(), day) {
655                (true, _) => Some(year),
656                (false, None) => Some(format!("{month} {year}")),
657                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
658            }
659        }
660        _ => Some(extract_year(date)),
661    }
662}
663
664impl ComponentValues for TemplateDate {
665    fn values<F: crate::render::format::OutputFormat<Output = String>>(
666        &self,
667        reference: &Reference,
668        hints: &ProcHints,
669        options: &RenderOptions<'_>,
670    ) -> Option<ProcValues<F::Output>> {
671        let fmt = F::default();
672        let date_opt: Option<EdtfString> = match self.date {
673            TemplateDateVar::Issued => reference.effective_issued_date(),
674            TemplateDateVar::Accessed => reference.accessed(),
675            TemplateDateVar::OriginalPublished => reference.original_date(),
676            TemplateDateVar::EventDate => event_date(reference),
677            _ => None,
678        };
679
680        let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
681            // Handle fallback if date is missing
682            if let Some(fallbacks) = &self.fallback {
683                for component in fallbacks {
684                    if let Some(values) = component.values::<F>(reference, hints, options) {
685                        return Some(values);
686                    }
687                }
688            }
689            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
690            if matches!(self.date, TemplateDateVar::Issued)
691                && let Some(nd) = options.locale.resolved_general_term(
692                    &GeneralTerm::NoDate,
693                    &TermForm::Short,
694                    None,
695                )
696            {
697                return Some(ProcValues {
698                    value: nd,
699                    prefix: None,
700                    suffix: None,
701                    url: None,
702                    substituted_key: None,
703                    pre_formatted: false,
704                });
705            }
706            return None;
707        };
708
709        let locale = options.locale;
710        let date_config = options.config.dates.as_ref();
711        let effective_form = self.form.clone();
712
713        let formatted = format_date_range(&date, &effective_form, locale, date_config);
714
715        // Apply uncertainty and approximation markers
716        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
717
718        // Handle disambiguation suffix (a, b, c...).
719        // Year-suffix is keyed off the issued year only; suppress it for other date
720        // components (e.g. original-published) so a reprint template renders
721        // `(1926/1967a)` rather than `(1926a/1967a)`.
722        let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
723            .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
724            .flatten();
725
726        formatted.map(|value| {
727            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
728                (
729                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
730                    None,
731                )
732            } else {
733                (value, None)
734            };
735
736            ProcValues {
737                value,
738                prefix: None,
739                suffix,
740                url: crate::values::resolve_effective_url(
741                    self.links.as_ref(),
742                    options.config.links.as_ref(),
743                    reference,
744                    citum_schema::options::LinkAnchor::Component,
745                ),
746                substituted_key: None,
747                pre_formatted: false,
748            }
749        })
750    }
751}
752
753/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
754#[must_use]
755pub fn int_to_letter(n: u32) -> Option<String> {
756    if n == 0 {
757        return None;
758    }
759
760    let mut result = String::new();
761    let mut num = n - 1;
762
763    loop {
764        result.push((b'a' + (num % 26) as u8) as char);
765        if num < 26 {
766            break;
767        }
768        num = num / 26 - 1;
769    }
770
771    Some(result.chars().rev().collect())
772}
773
774#[cfg(test)]
775#[allow(
776    clippy::unwrap_used,
777    clippy::expect_used,
778    clippy::panic,
779    clippy::indexing_slicing,
780    clippy::todo,
781    clippy::unimplemented,
782    clippy::unreachable,
783    clippy::get_unwrap,
784    reason = "Panicking is acceptable and often desired in tests."
785)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn test_int_to_letter() {
791        // Test basic single-letter conversions (1-26)
792        assert_eq!(int_to_letter(1), Some("a".to_string()));
793        assert_eq!(int_to_letter(2), Some("b".to_string()));
794        assert_eq!(int_to_letter(26), Some("z".to_string()));
795
796        // Test double-letter conversions (27+)
797        assert_eq!(int_to_letter(27), Some("aa".to_string()));
798        assert_eq!(int_to_letter(52), Some("az".to_string()));
799        assert_eq!(int_to_letter(53), Some("ba".to_string()));
800
801        // Test zero returns None
802        assert_eq!(int_to_letter(0), None);
803    }
804}
805
806#[cfg(test)]
807#[allow(
808    clippy::unwrap_used,
809    clippy::expect_used,
810    clippy::panic,
811    clippy::indexing_slicing,
812    clippy::todo,
813    clippy::unimplemented,
814    clippy::unreachable,
815    clippy::get_unwrap,
816    reason = "Panicking is acceptable and often desired in tests."
817)]
818mod time_tests {
819    use super::*;
820    use citum_edtf::{Time, Timezone};
821
822    #[test]
823    fn test_format_time_12h_utc() {
824        let time = Time {
825            hour: 23,
826            minute: 20,
827            second: 30,
828            timezone: Some(Timezone::Utc),
829        };
830        let result = format_time(
831            time,
832            &TimeFormat::Hour12,
833            false,
834            true,
835            Some("AM"),
836            Some("PM"),
837            Some("UTC"),
838        );
839        assert_eq!(result, "11:20 PM UTC");
840    }
841
842    #[test]
843    fn test_format_time_24h_utc() {
844        let time = Time {
845            hour: 23,
846            minute: 20,
847            second: 30,
848            timezone: Some(Timezone::Utc),
849        };
850        let result = format_time(
851            time,
852            &TimeFormat::Hour24,
853            false,
854            true,
855            None,
856            None,
857            Some("UTC"),
858        );
859        assert_eq!(result, "23:20 UTC");
860    }
861
862    #[test]
863    fn test_format_time_with_offset() {
864        let time = Time {
865            hour: 10,
866            minute: 10,
867            second: 10,
868            timezone: Some(Timezone::Offset(330)),
869        };
870        let result = format_time(
871            time,
872            &TimeFormat::Hour24,
873            false,
874            true,
875            None,
876            None,
877            Some("UTC"),
878        );
879        assert_eq!(result, "10:10 +05:30");
880    }
881
882    #[test]
883    fn test_format_time_no_timezone() {
884        let time = Time {
885            hour: 14,
886            minute: 30,
887            second: 0,
888            timezone: None,
889        };
890        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
891        assert_eq!(result, "14:30");
892    }
893}
894
895#[cfg(test)]
896#[allow(
897    clippy::unwrap_used,
898    clippy::expect_used,
899    clippy::panic,
900    clippy::indexing_slicing,
901    clippy::todo,
902    clippy::unimplemented,
903    clippy::unreachable,
904    clippy::get_unwrap,
905    reason = "Panicking is acceptable and often desired in tests."
906)]
907mod era_tests {
908    use super::*;
909    use citum_edtf::{UnspecifiedYear, Year};
910    use citum_schema::locale::{DateTerms, Locale};
911    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
912
913    fn en_terms() -> DateTerms {
914        Locale::en_us().dates
915    }
916
917    #[test]
918    fn positive_year_default_no_suffix() {
919        let year = Year {
920            value: 54,
921            unspecified: UnspecifiedYear::None,
922        };
923        let result = format_display_year(
924            &year,
925            &en_terms(),
926            &EraLabels::Default,
927            &NegativeUnspecifiedYears::Range,
928            "–",
929        );
930        assert_eq!(result, "54");
931    }
932
933    #[test]
934    fn positive_year_bc_ad() {
935        let year = Year {
936            value: 54,
937            unspecified: UnspecifiedYear::None,
938        };
939        let result = format_display_year(
940            &year,
941            &en_terms(),
942            &EraLabels::BcAd,
943            &NegativeUnspecifiedYears::Range,
944            "–",
945        );
946        assert_eq!(result, "54 AD");
947    }
948
949    #[test]
950    fn positive_year_bce_ce() {
951        let year = Year {
952            value: 54,
953            unspecified: UnspecifiedYear::None,
954        };
955        let result = format_display_year(
956            &year,
957            &en_terms(),
958            &EraLabels::BceCe,
959            &NegativeUnspecifiedYears::Range,
960            "–",
961        );
962        assert_eq!(result, "54 CE");
963    }
964
965    #[test]
966    fn negative_year_default() {
967        let year = Year {
968            value: -43,
969            unspecified: UnspecifiedYear::None,
970        };
971        let result = format_display_year(
972            &year,
973            &en_terms(),
974            &EraLabels::Default,
975            &NegativeUnspecifiedYears::Range,
976            "–",
977        );
978        assert_eq!(result, "44 BC");
979    }
980
981    #[test]
982    fn negative_year_bc_ad() {
983        let year = Year {
984            value: -43,
985            unspecified: UnspecifiedYear::None,
986        };
987        let result = format_display_year(
988            &year,
989            &en_terms(),
990            &EraLabels::BcAd,
991            &NegativeUnspecifiedYears::Range,
992            "–",
993        );
994        assert_eq!(result, "44 BC");
995    }
996
997    #[test]
998    fn negative_year_bce_ce() {
999        let year = Year {
1000            value: -43,
1001            unspecified: UnspecifiedYear::None,
1002        };
1003        let result = format_display_year(
1004            &year,
1005            &en_terms(),
1006            &EraLabels::BceCe,
1007            &NegativeUnspecifiedYears::Range,
1008            "–",
1009        );
1010        assert_eq!(result, "44 BCE");
1011    }
1012
1013    #[test]
1014    fn positive_unspecified_ones() {
1015        let year = Year {
1016            value: 1990,
1017            unspecified: UnspecifiedYear::One,
1018        };
1019        let result = format_display_year(
1020            &year,
1021            &en_terms(),
1022            &EraLabels::Default,
1023            &NegativeUnspecifiedYears::Range,
1024            "–",
1025        );
1026        assert_eq!(result, "199X");
1027    }
1028
1029    #[test]
1030    fn positive_unspecified_two() {
1031        let year = Year {
1032            value: 1900,
1033            unspecified: UnspecifiedYear::Two,
1034        };
1035        let result = format_display_year(
1036            &year,
1037            &en_terms(),
1038            &EraLabels::Default,
1039            &NegativeUnspecifiedYears::Range,
1040            "–",
1041        );
1042        assert_eq!(result, "19XX");
1043    }
1044
1045    #[test]
1046    fn negative_unspecified_range() {
1047        let year = Year {
1048            value: -90,
1049            unspecified: UnspecifiedYear::One,
1050        };
1051        let result = format_display_year(
1052            &year,
1053            &en_terms(),
1054            &EraLabels::Default,
1055            &NegativeUnspecifiedYears::Range,
1056            "–",
1057        );
1058        assert_eq!(result, "100–91 BC");
1059    }
1060
1061    #[test]
1062    fn negative_unspecified_century() {
1063        let year = Year {
1064            value: 0,
1065            unspecified: UnspecifiedYear::Two,
1066        };
1067        let result = format_display_year(
1068            &year,
1069            &en_terms(),
1070            &EraLabels::Default,
1071            &NegativeUnspecifiedYears::Range,
1072            "–",
1073        );
1074        assert_eq!(result, "100–1 BC");
1075    }
1076
1077    #[test]
1078    fn backwards_compat_negative_year() {
1079        let year = Year {
1080            value: -99,
1081            unspecified: UnspecifiedYear::None,
1082        };
1083        let result = format_display_year(
1084            &year,
1085            &en_terms(),
1086            &EraLabels::Default,
1087            &NegativeUnspecifiedYears::Range,
1088            "–",
1089        );
1090        assert_eq!(result, "100 BC");
1091    }
1092}
1093
1094#[cfg(test)]
1095#[allow(
1096    clippy::unwrap_used,
1097    clippy::expect_used,
1098    reason = "Panicking is acceptable in tests."
1099)]
1100mod locale_pattern_tests {
1101    use super::*;
1102    use citum_schema::locale::Locale;
1103
1104    fn en_us() -> Locale {
1105        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1106            .expect("en-US locale should parse")
1107    }
1108
1109    fn es_es() -> Locale {
1110        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1111            .expect("es-ES locale should parse")
1112    }
1113
1114    fn eu_es() -> Locale {
1115        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1116            .expect("eu-ES locale should parse")
1117    }
1118
1119    fn full(locale: &Locale, edtf: &str) -> String {
1120        format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1121            .expect("date should render")
1122    }
1123
1124    fn month_day(locale: &Locale, edtf: &str) -> String {
1125        format_single_date(
1126            &EdtfString(edtf.to_string()),
1127            &DateForm::MonthDay,
1128            locale,
1129            None,
1130        )
1131        .expect("date should render")
1132    }
1133
1134    #[test]
1135    fn en_us_full_unchanged_by_pattern_machinery() {
1136        // Regression: en-US declares no pattern.date-*, so the engine's
1137        // hardcoded English assembly must still produce the original output.
1138        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1139    }
1140
1141    #[test]
1142    fn en_us_month_day_unchanged_by_pattern_machinery() {
1143        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1144    }
1145
1146    #[test]
1147    fn en_us_month_form_renders_month_name_only() {
1148        // given a year-month date and the month-only form
1149        let out = format_single_date(
1150            &EdtfString("2023-06".to_string()),
1151            &DateForm::Month,
1152            &en_us(),
1153            None,
1154        );
1155        // then only the month name renders (no year), e.g. magazines
1156        assert_eq!(out.as_deref(), Some("June"));
1157    }
1158
1159    #[test]
1160    fn en_us_month_form_renders_season_name() {
1161        // given an EDTF season date and the month-only form
1162        let out = format_single_date(
1163            &EdtfString("2023-21".to_string()),
1164            &DateForm::Month,
1165            &en_us(),
1166            None,
1167        );
1168        // then the locale's season term renders in place of a month name
1169        assert_eq!(out.as_deref(), Some("Spring"));
1170    }
1171
1172    #[test]
1173    fn en_us_year_month_form_renders_season_and_year() {
1174        let out = format_single_date(
1175            &EdtfString("2023-21".to_string()),
1176            &DateForm::YearMonth,
1177            &en_us(),
1178            None,
1179        );
1180        assert_eq!(out.as_deref(), Some("Spring 2023"));
1181    }
1182
1183    #[test]
1184    fn en_us_full_form_renders_season_and_year() {
1185        assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1186    }
1187
1188    #[test]
1189    fn es_es_year_month_form_renders_localized_season() {
1190        let out = format_single_date(
1191            &EdtfString("2023-23".to_string()),
1192            &DateForm::YearMonth,
1193            &es_es(),
1194            None,
1195        );
1196        assert_eq!(out.as_deref(), Some("otoño de 2023"));
1197    }
1198
1199    #[test]
1200    fn es_es_full_uses_locale_pattern() {
1201        // Spanish day-first assembly via pattern.date-full.
1202        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1203    }
1204
1205    #[test]
1206    fn es_es_month_day_uses_locale_pattern() {
1207        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1208    }
1209
1210    #[test]
1211    fn eu_es_full_uses_locale_pattern() {
1212        // Basque genitive-absolutive shape via pattern.date-full.
1213        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
1214        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1215    }
1216
1217    #[test]
1218    fn eu_es_month_day_uses_locale_pattern() {
1219        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1220    }
1221
1222    fn year_month(locale: &Locale, edtf: &str) -> String {
1223        format_single_date(
1224            &EdtfString(edtf.to_string()),
1225            &DateForm::YearMonth,
1226            locale,
1227            None,
1228        )
1229        .expect("date should render")
1230    }
1231
1232    fn year_month_day(locale: &Locale, edtf: &str) -> String {
1233        format_single_date(
1234            &EdtfString(edtf.to_string()),
1235            &DateForm::YearMonthDay,
1236            locale,
1237            None,
1238        )
1239        .expect("date should render")
1240    }
1241
1242    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1243        format_single_date(
1244            &EdtfString(edtf.to_string()),
1245            &DateForm::DayMonthAbbrYear,
1246            locale,
1247            None,
1248        )
1249        .expect("date should render")
1250    }
1251
1252    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1253        format_single_date(
1254            &EdtfString(edtf.to_string()),
1255            &DateForm::MonthAbbrDayYear,
1256            locale,
1257            None,
1258        )
1259        .expect("date should render")
1260    }
1261
1262    #[test]
1263    fn en_us_year_month_unchanged_by_pattern_machinery() {
1264        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
1265        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1266    }
1267
1268    #[test]
1269    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1270        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1271    }
1272
1273    #[test]
1274    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1275        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1276    }
1277
1278    #[test]
1279    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1280        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1281    }
1282
1283    #[test]
1284    fn es_es_year_month_uses_locale_pattern() {
1285        // Spanish: month before year connected with "de".
1286        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1287    }
1288
1289    #[test]
1290    fn eu_es_year_month_uses_locale_pattern() {
1291        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
1292        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1293    }
1294
1295    #[test]
1296    fn year_month_missing_month_falls_back_to_year() {
1297        // Year-only EDTF: no month to pattern-assemble, returns year alone.
1298        assert_eq!(year_month(&es_es(), "2023"), "2023");
1299    }
1300
1301    #[test]
1302    fn es_es_year_month_day_uses_locale_pattern() {
1303        // Spanish: year first, then day/month connected with "de".
1304        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1305    }
1306
1307    #[test]
1308    fn es_es_year_month_day_missing_day_falls_back() {
1309        // Pattern requires $day; evaluator returns None, falls back to
1310        // hardcoded "{year}, {month}".
1311        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1312    }
1313
1314    #[test]
1315    fn es_es_day_month_abbr_year_uses_locale_pattern() {
1316        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
1317        assert_eq!(
1318            day_month_abbr_year(&es_es(), "2023-01-12"),
1319            "12 ene. de 2023"
1320        );
1321    }
1322
1323    #[test]
1324    fn es_es_day_month_abbr_year_missing_day_falls_back() {
1325        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1326        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1327    }
1328
1329    #[test]
1330    fn es_es_month_abbr_day_year_uses_locale_pattern() {
1331        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
1332        assert_eq!(
1333            month_abbr_day_year(&es_es(), "2023-01-12"),
1334            "ene. 12 de 2023"
1335        );
1336    }
1337
1338    #[test]
1339    fn es_es_month_abbr_day_year_missing_day_falls_back() {
1340        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1341        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1342    }
1343
1344    #[test]
1345    fn pattern_missing_day_falls_back_to_english_assembly() {
1346        // Year-month only input: pattern.date-full requires {$day} so the
1347        // evaluator returns None, and the engine falls through to its
1348        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
1349        // can fix this for inflected locales — out of scope for this bean.)
1350        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1351    }
1352}
1353
1354#[cfg(test)]
1355#[allow(
1356    clippy::unwrap_used,
1357    clippy::expect_used,
1358    reason = "Panicking is acceptable in tests."
1359)]
1360mod range_tests {
1361    use super::*;
1362    use citum_schema::locale::Locale;
1363
1364    fn en_us() -> Locale {
1365        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1366            .expect("en-US locale should parse")
1367    }
1368
1369    fn es_es() -> Locale {
1370        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1371            .expect("es-ES locale should parse")
1372    }
1373
1374    fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1375        format_date_range(&EdtfString(edtf.to_string()), &form, locale, None)
1376    }
1377
1378    #[test]
1379    fn closed_range_year_form_regression() {
1380        // given a closed range with distinct years and the Year form
1381        // then it renders as a plain year-to-year range (no collapse)
1382        assert_eq!(
1383            range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1384            Some("2020–2022")
1385        );
1386    }
1387
1388    #[test]
1389    fn closed_range_full_form_different_years() {
1390        // given a closed range spanning two years, Full form
1391        // then both endpoints render in full
1392        assert_eq!(
1393            range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
1394            Some("May 14, 2023–June 2, 2024")
1395        );
1396    }
1397
1398    #[test]
1399    fn closed_range_full_form_same_year_collapses() {
1400        // given a closed range within a single year, Full form
1401        // then the start's year is suppressed and trails the end instead
1402        assert_eq!(
1403            range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
1404            Some("May 14–June 2, 2023")
1405        );
1406    }
1407
1408    #[test]
1409    fn closed_range_year_month_day_same_year_collapses() {
1410        // given a closed range within a single year, YearMonthDay form
1411        // then the leading year renders once and the end's year is suppressed
1412        assert_eq!(
1413            range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
1414            Some("2023, May 14–June 2")
1415        );
1416    }
1417
1418    #[test]
1419    fn closed_range_full_form_es_es_locale_pattern() {
1420        // given a closed range spanning two years under a locale that
1421        // declares pattern.date-full
1422        // then both endpoints render through the Spanish pattern
1423        assert_eq!(
1424            range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
1425            Some("12 de enero de 2023–3 de febrero de 2024")
1426        );
1427    }
1428
1429    #[test]
1430    fn interval_to_year_form() {
1431        // given an open-ended-from-start range ("../2020")
1432        // then it renders as the single known (end) point
1433        assert_eq!(
1434            range(&en_us(), "../2020", DateForm::Year).as_deref(),
1435            Some("2020")
1436        );
1437    }
1438
1439    #[test]
1440    fn closed_range_year_month_same_year_collapses() {
1441        // given month-only endpoints in the same year, YearMonth form
1442        // then the start month renders without the (shared) year
1443        assert_eq!(
1444            range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
1445            Some("May–June 2023")
1446        );
1447    }
1448
1449    #[test]
1450    fn closed_range_season_same_year_collapses() {
1451        // given EDTF season endpoints in the same year, YearMonth form
1452        // then the start season renders without the (shared) year
1453        assert_eq!(
1454            range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
1455            Some("Spring–Summer 2023")
1456        );
1457    }
1458}