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