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::{DateValue, Reference};
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_edtf::{Edtf, Timezone, UnspecifiedYear, Year};
14use citum_schema::locale::{GeneralTerm, TermForm};
15use citum_schema::options::dates::TimeFormat;
16use citum_schema::reference::types::RefDate;
17use citum_schema::reference::{ClassExtension, WorkRelation};
18use citum_schema::template::{
19    DateForm, DateVariable as TemplateDateVar, TemplateComponent, TemplateDate,
20};
21
22fn month_to_string(month: u32, months: &[String]) -> String {
23    if month > 0 {
24        let index = month - 1;
25        if let Some(month_name) = months.get(index as usize) {
26            month_name.clone()
27        } else {
28            String::new()
29        }
30    } else {
31        String::new()
32    }
33}
34
35/// Zero-padded numeric month (`"01"`–`"12"`) for `month: numeric` rendering.
36/// Seasons and literal dates have no numeric form and return `None` so
37/// callers fall back to the textual path.
38fn extract_month_numeric(date: &DateValue) -> Option<String> {
39    let RefDate::Edtf(edtf) = date.parse() else {
40        return None;
41    };
42    let month = edtf.month()?;
43    (1..=12).contains(&month).then(|| format!("{month:02}"))
44}
45
46fn extract_month(date: &DateValue, months: &[String], seasons: &[String]) -> String {
47    let parsed_date = date.parse();
48    let edtf = match parsed_date {
49        RefDate::Edtf(edtf) => edtf,
50        RefDate::Literal(_) => return String::new(),
51    };
52    match edtf.month() {
53        Some(month) => month_to_string(month, months),
54        None => match edtf.season() {
55            Some(season) => month_to_string(season, seasons),
56            None => String::new(),
57        },
58    }
59}
60
61fn event_date(reference: &Reference) -> Option<DateValue> {
62    match reference.extension() {
63        ClassExtension::Event(event) => event.date.clone(),
64        ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
65        ClassExtension::SerialComponent(component) => {
66            embedded_event_date(component.event.as_ref()?)
67        }
68        ClassExtension::AudioVisual(audio_visual) => {
69            embedded_event_date(audio_visual.event.as_ref()?)
70        }
71        _ => None,
72    }
73}
74
75fn embedded_event_date(relation: &WorkRelation) -> Option<DateValue> {
76    let WorkRelation::Embedded(reference) = relation else {
77        return None;
78    };
79    let ClassExtension::Event(event) = reference.extension() else {
80        return None;
81    };
82    event.date.clone()
83}
84
85/// Compute the delta for unspecified year ranges.
86fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
87    match u {
88        UnspecifiedYear::None => 0,
89        UnspecifiedYear::One => 9,
90        UnspecifiedYear::Two => 99,
91        UnspecifiedYear::Three => 999,
92        UnspecifiedYear::Four => 9999,
93    }
94}
95
96/// Format a year with era-aware rendering.
97fn format_display_year(
98    year: &Year,
99    date_terms: &citum_schema::locale::DateTerms,
100    era_labels: &citum_schema::options::dates::EraLabels,
101    _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
102    range_delimiter: &str,
103) -> String {
104    // Handle positive unspecified years: normalize 'u' to 'X'
105    if year.unspecified != UnspecifiedYear::None && year.value > 0 {
106        let mut s = year.value.to_string();
107        let unspec_count = match year.unspecified {
108            UnspecifiedYear::One => 1,
109            UnspecifiedYear::Two => 2,
110            UnspecifiedYear::Three => 3,
111            UnspecifiedYear::Four => 4,
112            _ => 0,
113        };
114        for _ in 0..unspec_count {
115            if let Some(last) = s.pop()
116                && last != '0'
117            {
118                s.push('X');
119            }
120        }
121        if s.len() < year.value.to_string().len() {
122            let diff = year.value.to_string().len() - s.len();
123            for _ in 0..diff {
124                s.push('X');
125            }
126        }
127        return s;
128    }
129
130    // Handle negative unspecified years: compute historical range
131    if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
132        let delta = unspecified_year_delta(&year.unspecified);
133        let astronomical_min = year.value - delta;
134        let astronomical_max = year.value;
135        let historical_end = 1 - astronomical_max;
136        let historical_start = 1 - astronomical_min;
137
138        let era_term = match era_labels {
139            citum_schema::options::dates::EraLabels::Default => {
140                date_terms.before_era.as_deref().unwrap_or("")
141            }
142            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
143            citum_schema::options::dates::EraLabels::BceCe => {
144                date_terms.bce.as_deref().unwrap_or("")
145            }
146        };
147
148        if era_term.is_empty() {
149            format!("{historical_start}{range_delimiter}{historical_end}")
150        } else {
151            format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
152        }
153    } else if year.value <= 0 {
154        // Fully specified negative year
155        let historical_year = 1 - year.value;
156        let era_term = match era_labels {
157            citum_schema::options::dates::EraLabels::Default => {
158                date_terms.before_era.as_deref().unwrap_or("")
159            }
160            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
161            citum_schema::options::dates::EraLabels::BceCe => {
162                date_terms.bce.as_deref().unwrap_or("")
163            }
164        };
165
166        if era_term.is_empty() {
167            historical_year.to_string()
168        } else {
169            format!("{historical_year} {era_term}")
170        }
171    } else {
172        // Positive year
173        let era_term = match era_labels {
174            citum_schema::options::dates::EraLabels::Default => "",
175            citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
176            citum_schema::options::dates::EraLabels::BceCe => {
177                date_terms.ce.as_deref().unwrap_or("")
178            }
179        };
180
181        if era_term.is_empty() {
182            year.value.to_string()
183        } else {
184            format!("{} {}", year.value, era_term)
185        }
186    }
187}
188
189/// Legacy format_display_year for backwards compatibility.
190fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
191    if year.unspecified != UnspecifiedYear::None {
192        return year.to_string();
193    }
194
195    if year.value <= 0 {
196        let historical_year = 1 - year.value;
197        if let Some(term) = before_era.filter(|term| !term.is_empty()) {
198            format!("{historical_year} {term}")
199        } else {
200            historical_year.to_string()
201        }
202    } else {
203        year.value.to_string()
204    }
205}
206
207#[allow(dead_code, reason = "kept for backwards compatibility")]
208fn extract_display_year_legacy(date: &DateValue, before_era: Option<&str>) -> String {
209    match date.parse() {
210        RefDate::Edtf(edtf) => match edtf {
211            Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
212            Edtf::Interval(interval) => {
213                format_display_year_legacy(&interval.start.year, before_era)
214            }
215            Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
216                format_display_year_legacy(&date.year, before_era)
217            }
218        },
219        RefDate::Literal(_) => String::new(),
220    }
221}
222
223/// Formats a time with the specified format, optionally including seconds and timezone.
224///
225/// Converts 24-hour time to 12-hour format if specified, and appends localized
226/// AM/PM or timezone indicators as configured.
227fn format_time(
228    time: citum_edtf::Time,
229    format: &TimeFormat,
230    show_seconds: bool,
231    show_timezone: bool,
232    am_term: Option<&str>,
233    pm_term: Option<&str>,
234    utc_term: Option<&str>,
235) -> String {
236    let (display_hour, period) = match format {
237        TimeFormat::Hour12 => {
238            let (h, p) = if time.hour == 0 {
239                (12u32, am_term.unwrap_or("AM"))
240            } else if time.hour < 12 {
241                (time.hour, am_term.unwrap_or("AM"))
242            } else if time.hour == 12 {
243                (12u32, pm_term.unwrap_or("PM"))
244            } else {
245                (time.hour - 12, pm_term.unwrap_or("PM"))
246            };
247            (h, Some(p))
248        }
249        TimeFormat::Hour24 => (time.hour, None),
250    };
251
252    let time_str = if show_seconds {
253        format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
254    } else {
255        format!("{:02}:{:02}", display_hour, time.minute)
256    };
257
258    let with_period = match period {
259        Some(p) => format!("{time_str} {p}"),
260        None => time_str,
261    };
262
263    if show_timezone {
264        let tz_str = match time.timezone {
265            Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
266            Some(Timezone::Offset(mins)) => {
267                let sign = if mins >= 0 { '+' } else { '-' };
268                let abs = mins.unsigned_abs();
269                format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
270            }
271            None => String::new(),
272        };
273        if tz_str.is_empty() {
274            with_period
275        } else {
276            format!("{with_period} {tz_str}")
277        }
278    } else {
279        with_period
280    }
281}
282
283/// Format a single date or a date range (open or closed) according to the
284/// given form, delegating both endpoints of a range to
285/// [`format_single_date`] so locale patterns apply symmetrically.
286fn format_date_range(
287    date: &DateValue,
288    form: &DateForm,
289    locale: &citum_schema::locale::Locale,
290    date_config: Option<&citum_schema::options::dates::DateConfig>,
291) -> Option<String> {
292    let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
293
294    match date.parse() {
295        RefDate::Edtf(Edtf::Interval(interval)) => {
296            format_closed_range(date, &interval, form, locale, date_config, delimiter)
297        }
298        RefDate::Edtf(Edtf::IntervalFrom(_)) => {
299            // Open-ended range (e.g., "1990/..'): the accessors on the whole
300            // interval already resolve to the start point.
301            let start = format_single_date(date, form, locale, date_config)?;
302            if let Some(end_marker) = date_config
303                .and_then(|c| c.open_range_marker.as_deref())
304                .or(locale.dates.open_ended_term.as_deref())
305            {
306                Some(format!("{start}{delimiter}{end_marker}"))
307            } else {
308                Some(start)
309            }
310        }
311        // Non-range dates and open-ended-from-start ranges ("../2020") only
312        // have one known point, which the accessors already expose.
313        _ => format_single_date(date, form, locale, date_config),
314    }
315}
316
317/// Format a closed date range, collapsing the start point's year when both
318/// endpoints share a year and the form displays a month.
319fn format_closed_range(
320    date: &DateValue,
321    interval: &citum_edtf::Interval,
322    form: &DateForm,
323    locale: &citum_schema::locale::Locale,
324    date_config: Option<&citum_schema::options::dates::DateConfig>,
325    delimiter: &str,
326) -> Option<String> {
327    let same_year = interval.start.year.value == interval.end.year.value;
328    let both_have_month =
329        interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
330
331    if same_year
332        && both_have_month
333        && let Some(collapsed) = format_same_year_range(
334            &interval.start,
335            &interval.end,
336            form,
337            locale,
338            date_config,
339            delimiter,
340        )
341    {
342        return Some(collapsed);
343    }
344
345    let start = format_single_date(date, form, locale, date_config);
346    let end = format_single_date(
347        &DateValue::new(interval.end.to_string()),
348        form,
349        locale,
350        date_config,
351    );
352
353    match (start, end) {
354        (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
355        (Some(s), None) => Some(s),
356        (None, Some(e)) => Some(e),
357        (None, None) => None,
358    }
359}
360
361/// Format a closed range whose endpoints share a year, suppressing the
362/// redundant year on one side (e.g. "May 14–June 2, 2023").
363///
364/// Only forms with a defined month-suppressed companion collapse; other
365/// forms (e.g. abbreviated-month forms with no such companion) return
366/// `None` so the caller falls back to the uncollapsed rendering.
367fn format_same_year_range(
368    start: &citum_edtf::Date,
369    end: &citum_edtf::Date,
370    form: &DateForm,
371    locale: &citum_schema::locale::Locale,
372    date_config: Option<&citum_schema::options::dates::DateConfig>,
373    delimiter: &str,
374) -> Option<String> {
375    let (start_form, end_form) = match form {
376        DateForm::Full => (DateForm::MonthDay, DateForm::Full),
377        DateForm::YearMonth => (DateForm::Month, DateForm::YearMonth),
378        DateForm::YearMonthDay => (DateForm::YearMonthDay, DateForm::MonthDay),
379        _ => return None,
380    };
381
382    let start_str = format_single_date(
383        &DateValue::new(start.to_string()),
384        &start_form,
385        locale,
386        date_config,
387    )?;
388    let end_str = format_single_date(
389        &DateValue::new(end.to_string()),
390        &end_form,
391        locale,
392        date_config,
393    )?;
394    Some(format!("{start_str}{delimiter}{end_str}"))
395}
396
397/// Append a date's opaque `note` (e.g. a source-calendar annotation), wrapped
398/// per `DateConfig.note_wrap`, directly after the complete formatted date —
399/// after any inlined year-suffix, before the component's own outer
400/// prefix/suffix/wrap. A no-op when the style has no `note-wrap` configured
401/// for this scope, or the date carries no note. The caller additionally
402/// skips this function entirely when the component sets
403/// `TemplateDate::suppress_note`. See
404/// `docs/specs/CALENDAR_DATE_ANNOTATIONS.md`.
405fn append_note<F: crate::render::format::OutputFormat<Output = String>>(
406    fmt: &F,
407    formatted: String,
408    date: &DateValue,
409    date_config: Option<&citum_schema::options::dates::DateConfig>,
410    reference: &Reference,
411    options: &RenderOptions<'_>,
412) -> String {
413    let Some(note) = date.note.as_deref().filter(|n| !n.is_empty()) else {
414        return formatted;
415    };
416    let Some(wrap) = date_config.and_then(|c| c.note_wrap.as_ref()) else {
417        return formatted;
418    };
419
420    let content = fmt.text(note);
421    let content = fmt.inner_affix(
422        wrap.inner_prefix.as_deref().unwrap_or_default(),
423        content,
424        wrap.inner_suffix.as_deref().unwrap_or_default(),
425    );
426    let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
427    let item_language = crate::values::effective_item_language(reference);
428    let (script, realization) = crate::values::punctuation_realization_context(
429        item_language.as_deref(),
430        options.config.multilingual.as_ref(),
431        options.locale.punctuation_realization.as_ref(),
432    );
433    let wrapped = fmt.wrap_punctuation(
434        &wrap.punctuation,
435        content,
436        &marks,
437        script,
438        realization.as_deref(),
439    );
440    format!("{formatted}{wrapped}")
441}
442
443/// Apply uncertainty and approximation markers to formatted date.
444fn apply_date_markers(
445    value: String,
446    date: &DateValue,
447    date_config: Option<&citum_schema::options::dates::DateConfig>,
448) -> String {
449    let mut result = value;
450    if date.is_approximate()
451        && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
452    {
453        let suffix = date_config
454            .and_then(|c| c.approximation_marker_suffix.as_deref())
455            .unwrap_or("");
456        result = format!("{marker}{result}{suffix}");
457    }
458    if date.is_uncertain()
459        && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
460    {
461        result = format!("{result}{marker}");
462    }
463    result
464}
465
466/// Compute the disambiguation suffix for year-based citations.
467fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
468    date: &DateValue,
469    form: &DateForm,
470    hints: &ProcHints,
471    options: &RenderOptions<'_>,
472    fmt: &F,
473) -> Option<String> {
474    if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
475        compute_disamb_suffix_label(hints, options, fmt)
476    } else {
477        None
478    }
479}
480
481fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
482    hints: &ProcHints,
483    options: &RenderOptions<'_>,
484    fmt: &F,
485) -> Option<String> {
486    // Check if year suffix is enabled, resolving the processing default
487    // centrally so an unset `processing` matches the rest of the engine.
488    let use_suffix = options
489        .config
490        .effective_processing()
491        .config()
492        .disambiguate
493        .as_ref()
494        .is_some_and(|d| d.year_suffix);
495
496    if hints.disamb_condition && use_suffix {
497        int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
498    } else {
499        None
500    }
501}
502
503fn date_form_displays_year(form: &DateForm) -> bool {
504    !matches!(form, DateForm::MonthDay)
505}
506
507fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
508    let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
509        date_config.no_date_year_suffix_delimiter.as_str()
510    });
511    value.push_str(delimiter);
512    value.push_str(suffix);
513}
514
515fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
516    if year.is_empty() || suffix.is_empty() {
517        return formatted.to_string();
518    }
519
520    let year_index = match form {
521        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
522        DateForm::YearMonth
523        | DateForm::Full
524        | DateForm::DayMonthAbbrYear
525        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
526        DateForm::MonthDay => None,
527        _ => None,
528    };
529
530    let Some(index) = year_index else {
531        return format!("{formatted}{suffix}");
532    };
533
534    let year_end = index + year.len();
535    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
536    let result = format!(
537        "{}{}{}{}",
538        &formatted[..index],
539        year,
540        suffix,
541        &formatted[year_end..]
542    );
543    result
544}
545
546/// Format a single date (non-range) according to the given form.
547#[allow(
548    clippy::too_many_lines,
549    reason = "date formatting handles 6 form variants"
550)]
551fn format_single_date(
552    date: &DateValue,
553    form: &DateForm,
554    locale: &citum_schema::locale::Locale,
555    date_config: Option<&citum_schema::options::dates::DateConfig>,
556) -> Option<String> {
557    let default_era = citum_schema::options::dates::EraLabels::Default;
558    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
559    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
560    let neg_unspecified = date_config
561        .map(|c| &c.negative_unspecified_years)
562        .unwrap_or(&default_neg_unspec);
563    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
564    // `month: numeric` renders month-bearing forms as zero-padded numerals
565    // joined with hyphens (GB/T 7714, ISO 690). Dates without a real calendar
566    // month (seasons, literals) fall back to the textual path.
567    let numeric_months =
568        date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
569
570    let extract_year = |d: &DateValue| -> String {
571        match d.parse() {
572            RefDate::Edtf(edtf) => match edtf {
573                Edtf::Date(dt) => format_display_year(
574                    &dt.year,
575                    &locale.dates,
576                    era_labels,
577                    neg_unspecified,
578                    range_delimiter,
579                ),
580                Edtf::Interval(interval) => format_display_year(
581                    &interval.start.year,
582                    &locale.dates,
583                    era_labels,
584                    neg_unspecified,
585                    range_delimiter,
586                ),
587                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
588                    &dt.year,
589                    &locale.dates,
590                    era_labels,
591                    neg_unspecified,
592                    range_delimiter,
593                ),
594            },
595            RefDate::Literal(_) => String::new(),
596        }
597    };
598
599    match form {
600        DateForm::Year => {
601            let year = extract_year(date);
602            if year.is_empty() { None } else { Some(year) }
603        }
604        DateForm::YearMonth => {
605            let year = extract_year(date);
606            if year.is_empty() {
607                return None;
608            }
609            if numeric_months && let Some(month) = extract_month_numeric(date) {
610                return Some(format!("{year}-{month}"));
611            }
612            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
613            let month_opt = (!month.is_empty()).then_some(month.as_str());
614            if let Some(rendered) =
615                locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
616            {
617                return Some(rendered);
618            }
619            if month.is_empty() {
620                Some(year)
621            } else {
622                Some(format!("{month} {year}"))
623            }
624        }
625        DateForm::Month => {
626            if numeric_months && let Some(month) = extract_month_numeric(date) {
627                return Some(month);
628            }
629            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
630            if month.is_empty() { None } else { Some(month) }
631        }
632        DateForm::MonthDay => {
633            if numeric_months && let Some(month) = extract_month_numeric(date) {
634                return Some(match date.day() {
635                    Some(d) => format!("{month}-{d:02}"),
636                    None => month,
637                });
638            }
639            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
640            if month.is_empty() {
641                return None;
642            }
643            let day = date.day();
644            if let Some(rendered) =
645                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
646            {
647                return Some(rendered);
648            }
649            match day {
650                Some(d) => Some(format!("{month} {d}")),
651                None => Some(month),
652            }
653        }
654        DateForm::Full => {
655            let year = extract_year(date);
656            if year.is_empty() {
657                return None;
658            }
659            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
660            let day = date.day();
661            let numeric_base = if numeric_months {
662                extract_month_numeric(date).map(|month| match day {
663                    Some(d) => format!("{year}-{month}-{d:02}"),
664                    None => format!("{year}-{month}"),
665                })
666            } else {
667                None
668            };
669            let base = numeric_base
670                .or_else(|| {
671                    locale.resolve_date_pattern(
672                        "pattern.date-full",
673                        Some(&year),
674                        (!month.is_empty()).then_some(month.as_str()),
675                        day,
676                    )
677                })
678                .unwrap_or_else(|| match (month.is_empty(), day) {
679                    (true, _) => year.clone(),
680                    (false, None) => format!("{month} {year}"),
681                    (false, Some(d)) => format!("{month} {d}, {year}"),
682                });
683            // Append time component if configured and present
684            if let (Some(time_fmt), Some(time)) = (
685                date_config.and_then(|c| c.time_format.as_ref()),
686                date.time(),
687            ) {
688                let show_secs = date_config.is_some_and(|c| c.show_seconds);
689                let show_tz = date_config.is_some_and(|c| c.show_timezone);
690                let time_str = format_time(
691                    time,
692                    time_fmt,
693                    show_secs,
694                    show_tz,
695                    locale.dates.am.as_deref(),
696                    locale.dates.pm.as_deref(),
697                    locale.dates.timezone_utc.as_deref(),
698                );
699                Some(format!("{base}, {time_str}"))
700            } else {
701                Some(base)
702            }
703        }
704        DateForm::YearMonthDay => {
705            let year = extract_year(date);
706            if year.is_empty() {
707                return None;
708            }
709            if numeric_months && let Some(month) = extract_month_numeric(date) {
710                return Some(match date.day() {
711                    Some(d) => format!("{year}-{month}-{d:02}"),
712                    None => format!("{year}-{month}"),
713                });
714            }
715            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
716            let day = date.day();
717            let month_opt = (!month.is_empty()).then_some(month.as_str());
718            if let Some(rendered) = locale.resolve_date_pattern(
719                "pattern.date-year-month-day",
720                Some(&year),
721                month_opt,
722                day,
723            ) {
724                return Some(rendered);
725            }
726            match (month.is_empty(), day) {
727                (true, _) => Some(year),
728                (false, None) => Some(format!("{year}, {month}")),
729                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
730            }
731        }
732        DateForm::DayMonthAbbrYear => {
733            let year = extract_year(date);
734            if year.is_empty() {
735                return None;
736            }
737            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
738            let day = date.day();
739            let month_opt = (!month.is_empty()).then_some(month.as_str());
740            if let Some(rendered) = locale.resolve_date_pattern(
741                "pattern.date-day-month-abbr-year",
742                Some(&year),
743                month_opt,
744                day,
745            ) {
746                return Some(rendered);
747            }
748            match (month.is_empty(), day) {
749                (true, _) => Some(year),
750                (false, None) => Some(format!("{month} {year}")),
751                (false, Some(d)) => Some(format!("{d} {month} {year}")),
752            }
753        }
754        DateForm::MonthAbbrDayYear => {
755            let year = extract_year(date);
756            if year.is_empty() {
757                return None;
758            }
759            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
760            let day = date.day();
761            let month_opt = (!month.is_empty()).then_some(month.as_str());
762            if let Some(rendered) = locale.resolve_date_pattern(
763                "pattern.date-month-abbr-day-year",
764                Some(&year),
765                month_opt,
766                day,
767            ) {
768                return Some(rendered);
769            }
770            match (month.is_empty(), day) {
771                (true, _) => Some(year),
772                (false, None) => Some(format!("{month} {year}")),
773                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
774            }
775        }
776        _ => Some(extract_year(date)),
777    }
778}
779
780/// Apply a fallback component's own `wrap`/`prefix`/`suffix` rendering.
781///
782/// `component.values()` only resolves the raw fallback string — it does not
783/// go through the generic per-component dispatch that normally applies a
784/// component's own rendering (that happens one layer up, outside the
785/// recursive fallback call). This applies it directly so e.g. `wrap:
786/// brackets` on a fallback `accessed` date isn't silently dropped. Shared by
787/// [`TemplateDate::values`] and `TemplateContributor`'s author-slot
788/// fallback (`crate::values::contributor`) — both fallback shapes need the
789/// identical post-render treatment.
790pub(crate) fn apply_fallback_component_rendering<
791    F: crate::render::format::OutputFormat<Output = String>,
792>(
793    fmt: &F,
794    value: &str,
795    pre_formatted: bool,
796    rendering: &citum_schema::template::Rendering,
797    reference: &Reference,
798    options: &RenderOptions<'_>,
799) -> F::Output {
800    let mut output = if pre_formatted {
801        fmt.join(vec![value.to_string()], "")
802    } else {
803        fmt.text(value)
804    };
805    if let Some(wrap_config) = rendering.wrap.as_ref() {
806        let (script, realization) = crate::values::punctuation_realization_context(
807            crate::values::effective_item_language(reference).as_deref(),
808            options.config.multilingual.as_ref(),
809            options.locale.punctuation_realization.as_ref(),
810        );
811        output = fmt.wrap_punctuation(
812            &wrap_config.punctuation,
813            output,
814            &crate::render::format::QuoteMarks::default(),
815            script,
816            realization.as_deref(),
817        );
818    }
819    let (script, realization) = crate::values::punctuation_realization_context(
820        crate::values::effective_item_language(reference).as_deref(),
821        options.config.multilingual.as_ref(),
822        options.locale.punctuation_realization.as_ref(),
823    );
824    let prefix = rendering
825        .prefix
826        .as_ref()
827        .map(|punctuation| {
828            crate::render::format::realize_punctuation(
829                punctuation,
830                script,
831                realization.as_deref(),
832                crate::render::format::PunctuationPosition::Prefix,
833            )
834        })
835        .unwrap_or_default();
836    let suffix = rendering
837        .suffix
838        .as_ref()
839        .map(|punctuation| {
840            crate::render::format::realize_punctuation(
841                punctuation,
842                script,
843                realization.as_deref(),
844                crate::render::format::PunctuationPosition::Suffix,
845            )
846        })
847        .unwrap_or_default();
848    if !prefix.is_empty() || !suffix.is_empty() {
849        output = crate::render::format::apply_punctuation_affixes(
850            fmt,
851            rendering
852                .prefix
853                .as_ref()
854                .map(|punctuation| (punctuation, prefix.as_ref())),
855            output,
856            rendering
857                .suffix
858                .as_ref()
859                .map(|punctuation| (punctuation, suffix.as_ref())),
860        );
861    }
862    output
863}
864
865impl ComponentValues for TemplateDate {
866    fn values<F: crate::render::format::OutputFormat<Output = String>>(
867        &self,
868        reference: &Reference,
869        hints: &ProcHints,
870        options: &RenderOptions<'_>,
871    ) -> Option<ProcValues<F::Output>> {
872        let fmt = F::default();
873        let date_opt: Option<DateValue> = match self.date {
874            TemplateDateVar::Issued => reference.effective_issued_date(),
875            TemplateDateVar::Accessed => reference.accessed(),
876            TemplateDateVar::OriginalPublished => reference.original_date(),
877            TemplateDateVar::EventDate => event_date(reference),
878            TemplateDateVar::Copyright => reference.copyright(),
879            TemplateDateVar::Printing => reference.printing(),
880            _ => None,
881        };
882
883        let Some(date) = date_opt.filter(|d| !d.is_empty()) else {
884            // Handle fallback if date is missing
885            if let Some(fallbacks) = &self.fallback {
886                for component in fallbacks {
887                    if let Some(values) = component.values::<F>(reference, hints, options) {
888                        let mut output = apply_fallback_component_rendering(
889                            &fmt,
890                            &values.value,
891                            values.pre_formatted,
892                            component.rendering(),
893                            reference,
894                            options,
895                        );
896                        // A `message:` fallback candidate is, by construction,
897                        // the terminal "no data available" case in a date's
898                        // fallback chain (e.g. GB/T 7714's `无日期`/`n.d.`
899                        // term via `message: term.no-date`) — apply the same
900                        // year-suffix-append convention the implicit (no
901                        // explicit `fallback:`) no-date path below already
902                        // uses, so explicit and implicit no-date fallbacks
903                        // disambiguate identically. Without this, a style
904                        // whose date components always carry an explicit
905                        // `fallback:` chain (as GB/T author-date's do) never
906                        // reaches the implicit branch and never gets a
907                        // suffix at all. See csl26-6eak.
908                        if matches!(self.date, TemplateDateVar::Issued)
909                            && self.suppress_disamb_suffix != Some(true)
910                            && matches!(component, TemplateComponent::Message(_))
911                            && let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt)
912                        {
913                            append_no_date_disamb_suffix(&mut output, &suffix, options);
914                        }
915                        return Some(ProcValues {
916                            value: output,
917                            prefix: None,
918                            suffix: None,
919                            url: values.url,
920                            substituted_key: values.substituted_key,
921                            pre_formatted: true,
922                        });
923                    }
924                }
925                return None;
926            }
927            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
928            if matches!(self.date, TemplateDateVar::Issued)
929                && let Some(mut nd) = options.locale.resolved_general_term(
930                    &GeneralTerm::NoDate,
931                    &TermForm::Short,
932                    None,
933                )
934            {
935                if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
936                    append_no_date_disamb_suffix(&mut nd, &suffix, options);
937                }
938                return Some(ProcValues {
939                    value: nd,
940                    prefix: None,
941                    suffix: None,
942                    url: None,
943                    substituted_key: None,
944                    pre_formatted: false,
945                });
946            }
947            return None;
948        };
949
950        let locale = options.locale;
951        let date_config = options.config.dates.as_ref();
952        let effective_form = self.form.clone();
953
954        let formatted = format_date_range(&date, &effective_form, locale, date_config);
955
956        // Apply uncertainty and approximation markers
957        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
958
959        // Handle disambiguation suffix (a, b, c...).
960        // Year-suffix is keyed off the issued year only; suppress it for other date
961        // components (e.g. original-published) so a reprint template renders
962        // `(1926/1967a)` rather than `(1926a/1967a)`.
963        let disamb_suffix = (matches!(self.date, TemplateDateVar::Issued)
964            && self.suppress_disamb_suffix != Some(true))
965        .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
966        .flatten();
967
968        formatted.map(|value| {
969            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
970                (
971                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
972                    None,
973                )
974            } else {
975                (value, None)
976            };
977
978            let value = if self.suppress_note == Some(true) {
979                value
980            } else {
981                append_note(&fmt, value, &date, date_config, reference, options)
982            };
983
984            ProcValues {
985                value,
986                prefix: None,
987                suffix,
988                url: crate::values::resolve_effective_url(
989                    self.links.as_ref(),
990                    options.config.links.as_ref(),
991                    reference,
992                    citum_schema::options::LinkAnchor::Component,
993                ),
994                substituted_key: None,
995                pre_formatted: false,
996            }
997        })
998    }
999}
1000
1001/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
1002#[must_use]
1003pub fn int_to_letter(n: u32) -> Option<String> {
1004    if n == 0 {
1005        return None;
1006    }
1007
1008    let mut result = String::new();
1009    let mut num = n - 1;
1010
1011    loop {
1012        result.push((b'a' + (num % 26) as u8) as char);
1013        if num < 26 {
1014            break;
1015        }
1016        num = num / 26 - 1;
1017    }
1018
1019    Some(result.chars().rev().collect())
1020}
1021
1022#[cfg(test)]
1023#[allow(
1024    clippy::unwrap_used,
1025    clippy::expect_used,
1026    clippy::panic,
1027    clippy::indexing_slicing,
1028    clippy::todo,
1029    clippy::unimplemented,
1030    clippy::unreachable,
1031    clippy::get_unwrap,
1032    reason = "Panicking is acceptable and often desired in tests."
1033)]
1034mod tests {
1035    use super::*;
1036
1037    #[test]
1038    fn test_int_to_letter() {
1039        // Test basic single-letter conversions (1-26)
1040        assert_eq!(int_to_letter(1), Some("a".to_string()));
1041        assert_eq!(int_to_letter(2), Some("b".to_string()));
1042        assert_eq!(int_to_letter(26), Some("z".to_string()));
1043
1044        // Test double-letter conversions (27+)
1045        assert_eq!(int_to_letter(27), Some("aa".to_string()));
1046        assert_eq!(int_to_letter(52), Some("az".to_string()));
1047        assert_eq!(int_to_letter(53), Some("ba".to_string()));
1048
1049        // Test zero returns None
1050        assert_eq!(int_to_letter(0), None);
1051    }
1052}
1053
1054#[cfg(test)]
1055#[allow(
1056    clippy::unwrap_used,
1057    clippy::expect_used,
1058    clippy::panic,
1059    clippy::indexing_slicing,
1060    clippy::todo,
1061    clippy::unimplemented,
1062    clippy::unreachable,
1063    clippy::get_unwrap,
1064    reason = "Panicking is acceptable and often desired in tests."
1065)]
1066mod time_tests {
1067    use super::*;
1068    use citum_edtf::{Time, Timezone};
1069
1070    #[test]
1071    fn test_format_time_12h_utc() {
1072        let time = Time {
1073            hour: 23,
1074            minute: 20,
1075            second: 30,
1076            timezone: Some(Timezone::Utc),
1077        };
1078        let result = format_time(
1079            time,
1080            &TimeFormat::Hour12,
1081            false,
1082            true,
1083            Some("AM"),
1084            Some("PM"),
1085            Some("UTC"),
1086        );
1087        assert_eq!(result, "11:20 PM UTC");
1088    }
1089
1090    #[test]
1091    fn test_format_time_24h_utc() {
1092        let time = Time {
1093            hour: 23,
1094            minute: 20,
1095            second: 30,
1096            timezone: Some(Timezone::Utc),
1097        };
1098        let result = format_time(
1099            time,
1100            &TimeFormat::Hour24,
1101            false,
1102            true,
1103            None,
1104            None,
1105            Some("UTC"),
1106        );
1107        assert_eq!(result, "23:20 UTC");
1108    }
1109
1110    #[test]
1111    fn test_format_time_with_offset() {
1112        let time = Time {
1113            hour: 10,
1114            minute: 10,
1115            second: 10,
1116            timezone: Some(Timezone::Offset(330)),
1117        };
1118        let result = format_time(
1119            time,
1120            &TimeFormat::Hour24,
1121            false,
1122            true,
1123            None,
1124            None,
1125            Some("UTC"),
1126        );
1127        assert_eq!(result, "10:10 +05:30");
1128    }
1129
1130    #[test]
1131    fn test_format_time_no_timezone() {
1132        let time = Time {
1133            hour: 14,
1134            minute: 30,
1135            second: 0,
1136            timezone: None,
1137        };
1138        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
1139        assert_eq!(result, "14:30");
1140    }
1141}
1142
1143#[cfg(test)]
1144#[allow(
1145    clippy::unwrap_used,
1146    clippy::expect_used,
1147    clippy::panic,
1148    clippy::indexing_slicing,
1149    clippy::todo,
1150    clippy::unimplemented,
1151    clippy::unreachable,
1152    clippy::get_unwrap,
1153    reason = "Panicking is acceptable and often desired in tests."
1154)]
1155mod era_tests {
1156    use super::*;
1157    use citum_edtf::{UnspecifiedYear, Year};
1158    use citum_schema::locale::{DateTerms, Locale};
1159    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
1160
1161    fn en_terms() -> DateTerms {
1162        Locale::en_us().dates
1163    }
1164
1165    #[test]
1166    fn positive_year_default_no_suffix() {
1167        let year = Year {
1168            value: 54,
1169            unspecified: UnspecifiedYear::None,
1170        };
1171        let result = format_display_year(
1172            &year,
1173            &en_terms(),
1174            &EraLabels::Default,
1175            &NegativeUnspecifiedYears::Range,
1176            "–",
1177        );
1178        assert_eq!(result, "54");
1179    }
1180
1181    #[test]
1182    fn positive_year_bc_ad() {
1183        let year = Year {
1184            value: 54,
1185            unspecified: UnspecifiedYear::None,
1186        };
1187        let result = format_display_year(
1188            &year,
1189            &en_terms(),
1190            &EraLabels::BcAd,
1191            &NegativeUnspecifiedYears::Range,
1192            "–",
1193        );
1194        assert_eq!(result, "54 AD");
1195    }
1196
1197    #[test]
1198    fn positive_year_bce_ce() {
1199        let year = Year {
1200            value: 54,
1201            unspecified: UnspecifiedYear::None,
1202        };
1203        let result = format_display_year(
1204            &year,
1205            &en_terms(),
1206            &EraLabels::BceCe,
1207            &NegativeUnspecifiedYears::Range,
1208            "–",
1209        );
1210        assert_eq!(result, "54 CE");
1211    }
1212
1213    #[test]
1214    fn negative_year_default() {
1215        let year = Year {
1216            value: -43,
1217            unspecified: UnspecifiedYear::None,
1218        };
1219        let result = format_display_year(
1220            &year,
1221            &en_terms(),
1222            &EraLabels::Default,
1223            &NegativeUnspecifiedYears::Range,
1224            "–",
1225        );
1226        assert_eq!(result, "44 BC");
1227    }
1228
1229    #[test]
1230    fn negative_year_bc_ad() {
1231        let year = Year {
1232            value: -43,
1233            unspecified: UnspecifiedYear::None,
1234        };
1235        let result = format_display_year(
1236            &year,
1237            &en_terms(),
1238            &EraLabels::BcAd,
1239            &NegativeUnspecifiedYears::Range,
1240            "–",
1241        );
1242        assert_eq!(result, "44 BC");
1243    }
1244
1245    #[test]
1246    fn negative_year_bce_ce() {
1247        let year = Year {
1248            value: -43,
1249            unspecified: UnspecifiedYear::None,
1250        };
1251        let result = format_display_year(
1252            &year,
1253            &en_terms(),
1254            &EraLabels::BceCe,
1255            &NegativeUnspecifiedYears::Range,
1256            "–",
1257        );
1258        assert_eq!(result, "44 BCE");
1259    }
1260
1261    #[test]
1262    fn positive_unspecified_ones() {
1263        let year = Year {
1264            value: 1990,
1265            unspecified: UnspecifiedYear::One,
1266        };
1267        let result = format_display_year(
1268            &year,
1269            &en_terms(),
1270            &EraLabels::Default,
1271            &NegativeUnspecifiedYears::Range,
1272            "–",
1273        );
1274        assert_eq!(result, "199X");
1275    }
1276
1277    #[test]
1278    fn positive_unspecified_two() {
1279        let year = Year {
1280            value: 1900,
1281            unspecified: UnspecifiedYear::Two,
1282        };
1283        let result = format_display_year(
1284            &year,
1285            &en_terms(),
1286            &EraLabels::Default,
1287            &NegativeUnspecifiedYears::Range,
1288            "–",
1289        );
1290        assert_eq!(result, "19XX");
1291    }
1292
1293    #[test]
1294    fn negative_unspecified_range() {
1295        let year = Year {
1296            value: -90,
1297            unspecified: UnspecifiedYear::One,
1298        };
1299        let result = format_display_year(
1300            &year,
1301            &en_terms(),
1302            &EraLabels::Default,
1303            &NegativeUnspecifiedYears::Range,
1304            "–",
1305        );
1306        assert_eq!(result, "100–91 BC");
1307    }
1308
1309    #[test]
1310    fn negative_unspecified_century() {
1311        let year = Year {
1312            value: 0,
1313            unspecified: UnspecifiedYear::Two,
1314        };
1315        let result = format_display_year(
1316            &year,
1317            &en_terms(),
1318            &EraLabels::Default,
1319            &NegativeUnspecifiedYears::Range,
1320            "–",
1321        );
1322        assert_eq!(result, "100–1 BC");
1323    }
1324
1325    #[test]
1326    fn backwards_compat_negative_year() {
1327        let year = Year {
1328            value: -99,
1329            unspecified: UnspecifiedYear::None,
1330        };
1331        let result = format_display_year(
1332            &year,
1333            &en_terms(),
1334            &EraLabels::Default,
1335            &NegativeUnspecifiedYears::Range,
1336            "–",
1337        );
1338        assert_eq!(result, "100 BC");
1339    }
1340}
1341
1342#[cfg(test)]
1343#[allow(
1344    clippy::unwrap_used,
1345    clippy::expect_used,
1346    reason = "Panicking is acceptable in tests."
1347)]
1348mod locale_pattern_tests {
1349    use super::*;
1350    use citum_schema::locale::Locale;
1351
1352    fn en_us() -> Locale {
1353        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1354            .expect("en-US locale should parse")
1355    }
1356
1357    fn es_es() -> Locale {
1358        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1359            .expect("es-ES locale should parse")
1360    }
1361
1362    fn eu_es() -> Locale {
1363        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1364            .expect("eu-ES locale should parse")
1365    }
1366
1367    fn full(locale: &Locale, edtf: &str) -> String {
1368        format_single_date(
1369            &DateValue::new(edtf.to_string()),
1370            &DateForm::Full,
1371            locale,
1372            None,
1373        )
1374        .expect("date should render")
1375    }
1376
1377    fn month_day(locale: &Locale, edtf: &str) -> String {
1378        format_single_date(
1379            &DateValue::new(edtf.to_string()),
1380            &DateForm::MonthDay,
1381            locale,
1382            None,
1383        )
1384        .expect("date should render")
1385    }
1386
1387    #[test]
1388    fn en_us_full_unchanged_by_pattern_machinery() {
1389        // Regression: en-US declares no pattern.date-*, so the engine's
1390        // hardcoded English assembly must still produce the original output.
1391        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1392    }
1393
1394    #[test]
1395    fn en_us_month_day_unchanged_by_pattern_machinery() {
1396        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1397    }
1398
1399    #[test]
1400    fn en_us_month_form_renders_month_name_only() {
1401        // given a year-month date and the month-only form
1402        let out = format_single_date(
1403            &DateValue::new("2023-06".to_string()),
1404            &DateForm::Month,
1405            &en_us(),
1406            None,
1407        );
1408        // then only the month name renders (no year), e.g. magazines
1409        assert_eq!(out.as_deref(), Some("June"));
1410    }
1411
1412    #[test]
1413    fn en_us_month_form_renders_season_name() {
1414        // given an EDTF season date and the month-only form
1415        let out = format_single_date(
1416            &DateValue::new("2023-21".to_string()),
1417            &DateForm::Month,
1418            &en_us(),
1419            None,
1420        );
1421        // then the locale's season term renders in place of a month name
1422        assert_eq!(out.as_deref(), Some("Spring"));
1423    }
1424
1425    #[test]
1426    fn en_us_year_month_form_renders_season_and_year() {
1427        let out = format_single_date(
1428            &DateValue::new("2023-21".to_string()),
1429            &DateForm::YearMonth,
1430            &en_us(),
1431            None,
1432        );
1433        assert_eq!(out.as_deref(), Some("Spring 2023"));
1434    }
1435
1436    #[test]
1437    fn en_us_full_form_renders_season_and_year() {
1438        assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1439    }
1440
1441    #[test]
1442    fn es_es_year_month_form_renders_localized_season() {
1443        let out = format_single_date(
1444            &DateValue::new("2023-23".to_string()),
1445            &DateForm::YearMonth,
1446            &es_es(),
1447            None,
1448        );
1449        assert_eq!(out.as_deref(), Some("otoño de 2023"));
1450    }
1451
1452    #[test]
1453    fn es_es_full_uses_locale_pattern() {
1454        // Spanish day-first assembly via pattern.date-full.
1455        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1456    }
1457
1458    #[test]
1459    fn es_es_month_day_uses_locale_pattern() {
1460        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1461    }
1462
1463    #[test]
1464    fn eu_es_full_uses_locale_pattern() {
1465        // Basque genitive-absolutive shape via pattern.date-full.
1466        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
1467        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1468    }
1469
1470    #[test]
1471    fn eu_es_month_day_uses_locale_pattern() {
1472        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1473    }
1474
1475    fn year_month(locale: &Locale, edtf: &str) -> String {
1476        format_single_date(
1477            &DateValue::new(edtf.to_string()),
1478            &DateForm::YearMonth,
1479            locale,
1480            None,
1481        )
1482        .expect("date should render")
1483    }
1484
1485    fn year_month_day(locale: &Locale, edtf: &str) -> String {
1486        format_single_date(
1487            &DateValue::new(edtf.to_string()),
1488            &DateForm::YearMonthDay,
1489            locale,
1490            None,
1491        )
1492        .expect("date should render")
1493    }
1494
1495    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1496        format_single_date(
1497            &DateValue::new(edtf.to_string()),
1498            &DateForm::DayMonthAbbrYear,
1499            locale,
1500            None,
1501        )
1502        .expect("date should render")
1503    }
1504
1505    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1506        format_single_date(
1507            &DateValue::new(edtf.to_string()),
1508            &DateForm::MonthAbbrDayYear,
1509            locale,
1510            None,
1511        )
1512        .expect("date should render")
1513    }
1514
1515    #[test]
1516    fn en_us_year_month_unchanged_by_pattern_machinery() {
1517        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
1518        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1519    }
1520
1521    #[test]
1522    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1523        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1524    }
1525
1526    #[test]
1527    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1528        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1529    }
1530
1531    #[test]
1532    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1533        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1534    }
1535
1536    #[test]
1537    fn es_es_year_month_uses_locale_pattern() {
1538        // Spanish: month before year connected with "de".
1539        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1540    }
1541
1542    #[test]
1543    fn eu_es_year_month_uses_locale_pattern() {
1544        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
1545        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1546    }
1547
1548    #[test]
1549    fn year_month_missing_month_falls_back_to_year() {
1550        // Year-only EDTF: no month to pattern-assemble, returns year alone.
1551        assert_eq!(year_month(&es_es(), "2023"), "2023");
1552    }
1553
1554    #[test]
1555    fn es_es_year_month_day_uses_locale_pattern() {
1556        // Spanish: year first, then day/month connected with "de".
1557        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1558    }
1559
1560    #[test]
1561    fn es_es_year_month_day_missing_day_falls_back() {
1562        // Pattern requires $day; evaluator returns None, falls back to
1563        // hardcoded "{year}, {month}".
1564        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1565    }
1566
1567    #[test]
1568    fn es_es_day_month_abbr_year_uses_locale_pattern() {
1569        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
1570        assert_eq!(
1571            day_month_abbr_year(&es_es(), "2023-01-12"),
1572            "12 ene. de 2023"
1573        );
1574    }
1575
1576    #[test]
1577    fn es_es_day_month_abbr_year_missing_day_falls_back() {
1578        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1579        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1580    }
1581
1582    #[test]
1583    fn es_es_month_abbr_day_year_uses_locale_pattern() {
1584        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
1585        assert_eq!(
1586            month_abbr_day_year(&es_es(), "2023-01-12"),
1587            "ene. 12 de 2023"
1588        );
1589    }
1590
1591    #[test]
1592    fn es_es_month_abbr_day_year_missing_day_falls_back() {
1593        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1594        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1595    }
1596
1597    #[test]
1598    fn pattern_missing_day_falls_back_to_english_assembly() {
1599        // Year-month only input: pattern.date-full requires {$day} so the
1600        // evaluator returns None, and the engine falls through to its
1601        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
1602        // can fix this for inflected locales — out of scope for this bean.)
1603        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1604    }
1605}
1606
1607#[cfg(test)]
1608#[allow(
1609    clippy::unwrap_used,
1610    clippy::expect_used,
1611    reason = "Panicking is acceptable in tests."
1612)]
1613mod numeric_month_tests {
1614    use super::*;
1615    use citum_schema::locale::Locale;
1616    use citum_schema::options::MonthFormat;
1617    use citum_schema::options::dates::DateConfig;
1618
1619    fn en_us() -> Locale {
1620        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1621            .expect("en-US locale should parse")
1622    }
1623
1624    fn numeric_config() -> DateConfig {
1625        DateConfig {
1626            month: MonthFormat::Numeric,
1627            ..Default::default()
1628        }
1629    }
1630
1631    fn render(form: DateForm, edtf: &str) -> Option<String> {
1632        format_single_date(
1633            &DateValue::new(edtf.to_string()),
1634            &form,
1635            &en_us(),
1636            Some(&numeric_config()),
1637        )
1638    }
1639
1640    #[test]
1641    fn given_month_numeric_when_year_month_day_then_iso_hyphenated() {
1642        // GB/T 7714 / ISO 690 access and update dates: [2024-01-15].
1643        assert_eq!(
1644            render(DateForm::YearMonthDay, "2024-01-15").as_deref(),
1645            Some("2024-01-15")
1646        );
1647    }
1648
1649    #[test]
1650    fn given_month_numeric_when_day_missing_then_year_month_only() {
1651        assert_eq!(
1652            render(DateForm::YearMonthDay, "2024-01").as_deref(),
1653            Some("2024-01")
1654        );
1655    }
1656
1657    #[test]
1658    fn given_month_numeric_when_year_only_then_plain_year() {
1659        assert_eq!(
1660            render(DateForm::YearMonthDay, "2024").as_deref(),
1661            Some("2024")
1662        );
1663    }
1664
1665    #[test]
1666    fn given_month_numeric_when_year_month_form_then_hyphenated() {
1667        assert_eq!(
1668            render(DateForm::YearMonth, "2024-03").as_deref(),
1669            Some("2024-03")
1670        );
1671    }
1672
1673    #[test]
1674    fn given_month_numeric_when_month_day_form_then_zero_padded() {
1675        assert_eq!(
1676            render(DateForm::MonthDay, "2024-03-05").as_deref(),
1677            Some("03-05")
1678        );
1679    }
1680
1681    #[test]
1682    fn given_month_numeric_when_full_form_then_iso_hyphenated() {
1683        assert_eq!(
1684            render(DateForm::Full, "2024-01-15").as_deref(),
1685            Some("2024-01-15")
1686        );
1687    }
1688
1689    #[test]
1690    fn given_month_numeric_when_season_date_then_textual_fallback() {
1691        // Seasons have no numeric month; the textual path must still render.
1692        assert_eq!(
1693            render(DateForm::YearMonth, "2024-22").as_deref(),
1694            Some("Summer 2024")
1695        );
1696    }
1697
1698    #[test]
1699    fn given_long_month_config_when_year_month_day_then_unchanged() {
1700        // Regression guard: the default textual assembly is untouched.
1701        let out = format_single_date(
1702            &DateValue::new("2024-01-15".to_string()),
1703            &DateForm::YearMonthDay,
1704            &en_us(),
1705            None,
1706        );
1707        assert_eq!(out.as_deref(), Some("2024, January 15"));
1708    }
1709}
1710
1711#[cfg(test)]
1712#[allow(
1713    clippy::unwrap_used,
1714    clippy::expect_used,
1715    reason = "Panicking is acceptable in tests."
1716)]
1717mod range_tests {
1718    use super::*;
1719    use citum_schema::locale::Locale;
1720
1721    fn en_us() -> Locale {
1722        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1723            .expect("en-US locale should parse")
1724    }
1725
1726    fn es_es() -> Locale {
1727        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1728            .expect("es-ES locale should parse")
1729    }
1730
1731    fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1732        format_date_range(&DateValue::new(edtf.to_string()), &form, locale, None)
1733    }
1734
1735    #[test]
1736    fn closed_range_year_form_regression() {
1737        // given a closed range with distinct years and the Year form
1738        // then it renders as a plain year-to-year range (no collapse)
1739        assert_eq!(
1740            range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1741            Some("2020–2022")
1742        );
1743    }
1744
1745    #[test]
1746    fn closed_range_full_form_different_years() {
1747        // given a closed range spanning two years, Full form
1748        // then both endpoints render in full
1749        assert_eq!(
1750            range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
1751            Some("May 14, 2023–June 2, 2024")
1752        );
1753    }
1754
1755    #[test]
1756    fn closed_range_full_form_same_year_collapses() {
1757        // given a closed range within a single year, Full form
1758        // then the start's year is suppressed and trails the end instead
1759        assert_eq!(
1760            range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
1761            Some("May 14–June 2, 2023")
1762        );
1763    }
1764
1765    #[test]
1766    fn closed_range_year_month_day_same_year_collapses() {
1767        // given a closed range within a single year, YearMonthDay form
1768        // then the leading year renders once and the end's year is suppressed
1769        assert_eq!(
1770            range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
1771            Some("2023, May 14–June 2")
1772        );
1773    }
1774
1775    #[test]
1776    fn closed_range_full_form_es_es_locale_pattern() {
1777        // given a closed range spanning two years under a locale that
1778        // declares pattern.date-full
1779        // then both endpoints render through the Spanish pattern
1780        assert_eq!(
1781            range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
1782            Some("12 de enero de 2023–3 de febrero de 2024")
1783        );
1784    }
1785
1786    #[test]
1787    fn interval_to_year_form() {
1788        // given an open-ended-from-start range ("../2020")
1789        // then it renders as the single known (end) point
1790        assert_eq!(
1791            range(&en_us(), "../2020", DateForm::Year).as_deref(),
1792            Some("2020")
1793        );
1794    }
1795
1796    #[test]
1797    fn closed_range_year_month_same_year_collapses() {
1798        // given month-only endpoints in the same year, YearMonth form
1799        // then the start month renders without the (shared) year
1800        assert_eq!(
1801            range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
1802            Some("May–June 2023")
1803        );
1804    }
1805
1806    #[test]
1807    fn closed_range_season_same_year_collapses() {
1808        // given EDTF season endpoints in the same year, YearMonth form
1809        // then the start season renders without the (shared) year
1810        assert_eq!(
1811            range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
1812            Some("Spring–Summer 2023")
1813        );
1814    }
1815}