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