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