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