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
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            if month.is_empty() {
594                Some(year)
595            } else {
596                Some(format!("{month} {year}"))
597            }
598        }
599        DateForm::MonthDay => {
600            let month = extract_month(date, &locale.dates.months.long);
601            if month.is_empty() {
602                return None;
603            }
604            let day = date.day();
605            if let Some(rendered) =
606                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
607            {
608                return Some(rendered);
609            }
610            match day {
611                Some(d) => Some(format!("{month} {d}")),
612                None => Some(month),
613            }
614        }
615        DateForm::Full => {
616            let year = extract_year(date);
617            if year.is_empty() {
618                return None;
619            }
620            let month = extract_month(date, &locale.dates.months.long);
621            let day = date.day();
622            let base = locale
623                .resolve_date_pattern(
624                    "pattern.date-full",
625                    Some(&year),
626                    (!month.is_empty()).then_some(month.as_str()),
627                    day,
628                )
629                .unwrap_or_else(|| match (month.is_empty(), day) {
630                    (true, _) => year.clone(),
631                    (false, None) => format!("{month} {year}"),
632                    (false, Some(d)) => format!("{month} {d}, {year}"),
633                });
634            // Append time component if configured and present
635            if let (Some(time_fmt), Some(time)) = (
636                date_config.and_then(|c| c.time_format.as_ref()),
637                date.time(),
638            ) {
639                let show_secs = date_config.is_some_and(|c| c.show_seconds);
640                let show_tz = date_config.is_some_and(|c| c.show_timezone);
641                let time_str = format_time(
642                    time,
643                    time_fmt,
644                    show_secs,
645                    show_tz,
646                    locale.dates.am.as_deref(),
647                    locale.dates.pm.as_deref(),
648                    locale.dates.timezone_utc.as_deref(),
649                );
650                Some(format!("{base}, {time_str}"))
651            } else {
652                Some(base)
653            }
654        }
655        DateForm::YearMonthDay => {
656            let year = extract_year(date);
657            if year.is_empty() {
658                return None;
659            }
660            let month = extract_month(date, &locale.dates.months.long);
661            let day = date.day();
662            match (month.is_empty(), day) {
663                (true, _) => Some(year),
664                (false, None) => Some(format!("{year}, {month}")),
665                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
666            }
667        }
668        DateForm::DayMonthAbbrYear => {
669            let year = extract_year(date);
670            if year.is_empty() {
671                return None;
672            }
673            let month = extract_month(date, &locale.dates.months.short);
674            let day = date.day();
675            match (month.is_empty(), day) {
676                (true, _) => Some(year),
677                (false, None) => Some(format!("{month} {year}")),
678                (false, Some(d)) => Some(format!("{d} {month} {year}")),
679            }
680        }
681        DateForm::MonthAbbrDayYear => {
682            let year = extract_year(date);
683            if year.is_empty() {
684                return None;
685            }
686            let month = extract_month(date, &locale.dates.months.short);
687            let day = date.day();
688            match (month.is_empty(), day) {
689                (true, _) => Some(year),
690                (false, None) => Some(format!("{month} {year}")),
691                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
692            }
693        }
694        _ => Some(extract_year(date)),
695    }
696}
697
698impl ComponentValues for TemplateDate {
699    fn values<F: crate::render::format::OutputFormat<Output = String>>(
700        &self,
701        reference: &Reference,
702        hints: &ProcHints,
703        options: &RenderOptions<'_>,
704    ) -> Option<ProcValues<F::Output>> {
705        let fmt = F::default();
706        let date_opt: Option<EdtfString> = match self.date {
707            TemplateDateVar::Issued => reference.csl_issued_date(),
708            TemplateDateVar::Accessed => reference.accessed(),
709            TemplateDateVar::OriginalPublished => reference.original_date(),
710            _ => None,
711        };
712
713        let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
714            // Handle fallback if date is missing
715            if let Some(fallbacks) = &self.fallback {
716                for component in fallbacks {
717                    if let Some(values) = component.values::<F>(reference, hints, options) {
718                        return Some(values);
719                    }
720                }
721            }
722            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
723            if matches!(self.date, TemplateDateVar::Issued)
724                && let Some(nd) = options.locale.resolved_general_term(
725                    &GeneralTerm::NoDate,
726                    &TermForm::Short,
727                    None,
728                )
729            {
730                return Some(ProcValues {
731                    value: nd,
732                    prefix: None,
733                    suffix: None,
734                    url: None,
735                    substituted_key: None,
736                    pre_formatted: false,
737                });
738            }
739            return None;
740        };
741
742        let locale = options.locale;
743        let date_config = options.config.dates.as_ref();
744        let effective_form = self.form.clone();
745
746        let formatted = if date.is_range() {
747            // Handle date ranges
748            let start = format_range_start(&date, &effective_form, locale, date_config);
749            format_date_range(start, &date, locale, date_config)
750        } else {
751            // Single date (not a range)
752            format_single_date(&date, &effective_form, locale, date_config)
753        };
754
755        // Apply uncertainty and approximation markers
756        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
757
758        // Handle disambiguation suffix (a, b, c...)
759        let disamb_suffix = compute_disamb_suffix(&date, &effective_form, hints, options, &fmt);
760
761        formatted.map(|value| {
762            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
763                (
764                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
765                    None,
766                )
767            } else {
768                (value, None)
769            };
770
771            ProcValues {
772                value,
773                prefix: None,
774                suffix,
775                url: crate::values::resolve_effective_url(
776                    self.links.as_ref(),
777                    options.config.links.as_ref(),
778                    reference,
779                    citum_schema::options::LinkAnchor::Component,
780                ),
781                substituted_key: None,
782                pre_formatted: false,
783            }
784        })
785    }
786}
787
788/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
789#[must_use]
790pub fn int_to_letter(n: u32) -> Option<String> {
791    if n == 0 {
792        return None;
793    }
794
795    let mut result = String::new();
796    let mut num = n - 1;
797
798    loop {
799        result.push((b'a' + (num % 26) as u8) as char);
800        if num < 26 {
801            break;
802        }
803        num = num / 26 - 1;
804    }
805
806    Some(result.chars().rev().collect())
807}
808
809#[cfg(test)]
810#[allow(
811    clippy::unwrap_used,
812    clippy::expect_used,
813    clippy::panic,
814    clippy::indexing_slicing,
815    clippy::todo,
816    clippy::unimplemented,
817    clippy::unreachable,
818    clippy::get_unwrap,
819    reason = "Panicking is acceptable and often desired in tests."
820)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn test_int_to_letter() {
826        // Test basic single-letter conversions (1-26)
827        assert_eq!(int_to_letter(1), Some("a".to_string()));
828        assert_eq!(int_to_letter(2), Some("b".to_string()));
829        assert_eq!(int_to_letter(26), Some("z".to_string()));
830
831        // Test double-letter conversions (27+)
832        assert_eq!(int_to_letter(27), Some("aa".to_string()));
833        assert_eq!(int_to_letter(52), Some("az".to_string()));
834        assert_eq!(int_to_letter(53), Some("ba".to_string()));
835
836        // Test zero returns None
837        assert_eq!(int_to_letter(0), None);
838    }
839}
840
841#[cfg(test)]
842#[allow(
843    clippy::unwrap_used,
844    clippy::expect_used,
845    clippy::panic,
846    clippy::indexing_slicing,
847    clippy::todo,
848    clippy::unimplemented,
849    clippy::unreachable,
850    clippy::get_unwrap,
851    reason = "Panicking is acceptable and often desired in tests."
852)]
853mod time_tests {
854    use super::*;
855    use citum_edtf::{Time, Timezone};
856
857    #[test]
858    fn test_format_time_12h_utc() {
859        let time = Time {
860            hour: 23,
861            minute: 20,
862            second: 30,
863            timezone: Some(Timezone::Utc),
864        };
865        let result = format_time(
866            time,
867            &TimeFormat::Hour12,
868            false,
869            true,
870            Some("AM"),
871            Some("PM"),
872            Some("UTC"),
873        );
874        assert_eq!(result, "11:20 PM UTC");
875    }
876
877    #[test]
878    fn test_format_time_24h_utc() {
879        let time = Time {
880            hour: 23,
881            minute: 20,
882            second: 30,
883            timezone: Some(Timezone::Utc),
884        };
885        let result = format_time(
886            time,
887            &TimeFormat::Hour24,
888            false,
889            true,
890            None,
891            None,
892            Some("UTC"),
893        );
894        assert_eq!(result, "23:20 UTC");
895    }
896
897    #[test]
898    fn test_format_time_with_offset() {
899        let time = Time {
900            hour: 10,
901            minute: 10,
902            second: 10,
903            timezone: Some(Timezone::Offset(330)),
904        };
905        let result = format_time(
906            time,
907            &TimeFormat::Hour24,
908            false,
909            true,
910            None,
911            None,
912            Some("UTC"),
913        );
914        assert_eq!(result, "10:10 +05:30");
915    }
916
917    #[test]
918    fn test_format_time_no_timezone() {
919        let time = Time {
920            hour: 14,
921            minute: 30,
922            second: 0,
923            timezone: None,
924        };
925        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
926        assert_eq!(result, "14:30");
927    }
928}
929
930#[cfg(test)]
931#[allow(
932    clippy::unwrap_used,
933    clippy::expect_used,
934    clippy::panic,
935    clippy::indexing_slicing,
936    clippy::todo,
937    clippy::unimplemented,
938    clippy::unreachable,
939    clippy::get_unwrap,
940    reason = "Panicking is acceptable and often desired in tests."
941)]
942mod era_tests {
943    use super::*;
944    use citum_edtf::{UnspecifiedYear, Year};
945    use citum_schema::locale::DateTerms;
946    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
947
948    fn en_terms() -> DateTerms {
949        DateTerms::en_us()
950    }
951
952    #[test]
953    fn positive_year_default_no_suffix() {
954        let year = Year {
955            value: 54,
956            unspecified: UnspecifiedYear::None,
957        };
958        let result = format_display_year(
959            &year,
960            &en_terms(),
961            &EraLabels::Default,
962            &NegativeUnspecifiedYears::Range,
963            "–",
964        );
965        assert_eq!(result, "54");
966    }
967
968    #[test]
969    fn positive_year_bc_ad() {
970        let year = Year {
971            value: 54,
972            unspecified: UnspecifiedYear::None,
973        };
974        let result = format_display_year(
975            &year,
976            &en_terms(),
977            &EraLabels::BcAd,
978            &NegativeUnspecifiedYears::Range,
979            "–",
980        );
981        assert_eq!(result, "54 AD");
982    }
983
984    #[test]
985    fn positive_year_bce_ce() {
986        let year = Year {
987            value: 54,
988            unspecified: UnspecifiedYear::None,
989        };
990        let result = format_display_year(
991            &year,
992            &en_terms(),
993            &EraLabels::BceCe,
994            &NegativeUnspecifiedYears::Range,
995            "–",
996        );
997        assert_eq!(result, "54 CE");
998    }
999
1000    #[test]
1001    fn negative_year_default() {
1002        let year = Year {
1003            value: -43,
1004            unspecified: UnspecifiedYear::None,
1005        };
1006        let result = format_display_year(
1007            &year,
1008            &en_terms(),
1009            &EraLabels::Default,
1010            &NegativeUnspecifiedYears::Range,
1011            "–",
1012        );
1013        assert_eq!(result, "44 BC");
1014    }
1015
1016    #[test]
1017    fn negative_year_bc_ad() {
1018        let year = Year {
1019            value: -43,
1020            unspecified: UnspecifiedYear::None,
1021        };
1022        let result = format_display_year(
1023            &year,
1024            &en_terms(),
1025            &EraLabels::BcAd,
1026            &NegativeUnspecifiedYears::Range,
1027            "–",
1028        );
1029        assert_eq!(result, "44 BC");
1030    }
1031
1032    #[test]
1033    fn negative_year_bce_ce() {
1034        let year = Year {
1035            value: -43,
1036            unspecified: UnspecifiedYear::None,
1037        };
1038        let result = format_display_year(
1039            &year,
1040            &en_terms(),
1041            &EraLabels::BceCe,
1042            &NegativeUnspecifiedYears::Range,
1043            "–",
1044        );
1045        assert_eq!(result, "44 BCE");
1046    }
1047
1048    #[test]
1049    fn positive_unspecified_ones() {
1050        let year = Year {
1051            value: 1990,
1052            unspecified: UnspecifiedYear::One,
1053        };
1054        let result = format_display_year(
1055            &year,
1056            &en_terms(),
1057            &EraLabels::Default,
1058            &NegativeUnspecifiedYears::Range,
1059            "–",
1060        );
1061        assert_eq!(result, "199X");
1062    }
1063
1064    #[test]
1065    fn positive_unspecified_two() {
1066        let year = Year {
1067            value: 1900,
1068            unspecified: UnspecifiedYear::Two,
1069        };
1070        let result = format_display_year(
1071            &year,
1072            &en_terms(),
1073            &EraLabels::Default,
1074            &NegativeUnspecifiedYears::Range,
1075            "–",
1076        );
1077        assert_eq!(result, "19XX");
1078    }
1079
1080    #[test]
1081    fn negative_unspecified_range() {
1082        let year = Year {
1083            value: -90,
1084            unspecified: UnspecifiedYear::One,
1085        };
1086        let result = format_display_year(
1087            &year,
1088            &en_terms(),
1089            &EraLabels::Default,
1090            &NegativeUnspecifiedYears::Range,
1091            "–",
1092        );
1093        assert_eq!(result, "100–91 BC");
1094    }
1095
1096    #[test]
1097    fn negative_unspecified_century() {
1098        let year = Year {
1099            value: 0,
1100            unspecified: UnspecifiedYear::Two,
1101        };
1102        let result = format_display_year(
1103            &year,
1104            &en_terms(),
1105            &EraLabels::Default,
1106            &NegativeUnspecifiedYears::Range,
1107            "–",
1108        );
1109        assert_eq!(result, "100–1 BC");
1110    }
1111
1112    #[test]
1113    fn backwards_compat_negative_year() {
1114        let year = Year {
1115            value: -99,
1116            unspecified: UnspecifiedYear::None,
1117        };
1118        let result = format_display_year(
1119            &year,
1120            &en_terms(),
1121            &EraLabels::Default,
1122            &NegativeUnspecifiedYears::Range,
1123            "–",
1124        );
1125        assert_eq!(result, "100 BC");
1126    }
1127}
1128
1129#[cfg(test)]
1130#[allow(
1131    clippy::unwrap_used,
1132    clippy::expect_used,
1133    reason = "Panicking is acceptable in tests."
1134)]
1135mod locale_pattern_tests {
1136    use super::*;
1137    use citum_schema::locale::Locale;
1138
1139    fn en_us() -> Locale {
1140        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1141            .expect("en-US locale should parse")
1142    }
1143
1144    fn es_es() -> Locale {
1145        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1146            .expect("es-ES locale should parse")
1147    }
1148
1149    fn eu_es() -> Locale {
1150        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1151            .expect("eu-ES locale should parse")
1152    }
1153
1154    fn full(locale: &Locale, edtf: &str) -> String {
1155        format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1156            .expect("date should render")
1157    }
1158
1159    fn month_day(locale: &Locale, edtf: &str) -> String {
1160        format_single_date(
1161            &EdtfString(edtf.to_string()),
1162            &DateForm::MonthDay,
1163            locale,
1164            None,
1165        )
1166        .expect("date should render")
1167    }
1168
1169    #[test]
1170    fn en_us_full_unchanged_by_pattern_machinery() {
1171        // Regression: en-US declares no pattern.date-*, so the engine's
1172        // hardcoded English assembly must still produce the original output.
1173        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1174    }
1175
1176    #[test]
1177    fn en_us_month_day_unchanged_by_pattern_machinery() {
1178        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1179    }
1180
1181    #[test]
1182    fn es_es_full_uses_locale_pattern() {
1183        // Spanish day-first assembly via pattern.date-full.
1184        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1185    }
1186
1187    #[test]
1188    fn es_es_month_day_uses_locale_pattern() {
1189        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1190    }
1191
1192    #[test]
1193    fn eu_es_full_uses_locale_pattern() {
1194        // Basque genitive-absolutive shape via pattern.date-full.
1195        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
1196        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1197    }
1198
1199    #[test]
1200    fn eu_es_month_day_uses_locale_pattern() {
1201        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1202    }
1203
1204    #[test]
1205    fn pattern_missing_day_falls_back_to_english_assembly() {
1206        // Year-month only input: pattern.date-full requires {$day} so the
1207        // evaluator returns None, and the engine falls through to its
1208        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
1209        // can fix this for inflected locales — out of scope for this bean.)
1210        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1211    }
1212}