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::{DateRangeFormat, 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    if let Some(rendered) =
328        format_chicago_year_range(interval, form, locale, date_config, delimiter)
329    {
330        return Some(rendered);
331    }
332
333    let same_year = interval.start.year.value == interval.end.year.value;
334    let both_have_month =
335        interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
336
337    if same_year
338        && (both_have_month || matches!(form, DateForm::Year))
339        && let Some(collapsed) = format_same_year_range(
340            &interval.start,
341            &interval.end,
342            form,
343            locale,
344            date_config,
345            delimiter,
346        )
347    {
348        return Some(collapsed);
349    }
350
351    let start = format_single_date(date, form, locale, date_config);
352    let end = format_single_date(
353        &DateValue::new(interval.end.to_string()),
354        form,
355        locale,
356        date_config,
357    );
358
359    match (start, end) {
360        (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
361        (Some(s), None) => Some(s),
362        (None, Some(e)) => Some(e),
363        (None, None) => None,
364    }
365}
366
367/// Format a closed year interval with Chicago's inclusive-number abbreviation.
368///
369/// EDTF represents BCE years astronomically, so this deliberately formats the
370/// displayed historical numbers rather than relying on ascending numeric input.
371fn format_chicago_year_range(
372    interval: &citum_edtf::Interval,
373    form: &DateForm,
374    locale: &citum_schema::locale::Locale,
375    date_config: Option<&citum_schema::options::dates::DateConfig>,
376    delimiter: &str,
377) -> Option<String> {
378    if !matches!(form, DateForm::Year)
379        || !matches!(
380            date_config.map(|config| &config.range_format),
381            Some(DateRangeFormat::Chicago)
382        )
383        || interval.start.year.unspecified != UnspecifiedYear::None
384        || interval.end.year.unspecified != UnspecifiedYear::None
385        || interval.start.month_or_season.is_some()
386        || interval.end.month_or_season.is_some()
387    {
388        return None;
389    }
390
391    let start_is_bce = interval.start.year.value <= 0;
392    let end_is_bce = interval.end.year.value <= 0;
393    if start_is_bce != end_is_bce || interval.end.year.value <= interval.start.year.value {
394        return None;
395    }
396
397    let start = display_year_number(interval.start.year.value)?;
398    let end = display_year_number(interval.end.year.value)?;
399    let abbreviated_end = crate::values::number::format_chicago_range_end(start, end);
400    let era = chicago_year_range_era_suffix(start_is_bce, locale, date_config);
401    Some(format!("{start}{delimiter}{abbreviated_end}{era}"))
402}
403
404fn display_year_number(year: i64) -> Option<u32> {
405    let historical_year = if year <= 0 {
406        1_i64.checked_sub(year)?
407    } else {
408        year
409    };
410    u32::try_from(historical_year).ok()
411}
412
413fn chicago_year_range_era_suffix(
414    is_bce: bool,
415    locale: &citum_schema::locale::Locale,
416    date_config: Option<&citum_schema::options::dates::DateConfig>,
417) -> String {
418    use citum_schema::options::dates::EraLabels;
419
420    let era_labels = date_config
421        .map(|config| &config.era_labels)
422        .unwrap_or(&EraLabels::Default);
423    let label = match (is_bce, era_labels) {
424        (true, EraLabels::Default) => locale.dates.before_era.as_deref(),
425        (true, EraLabels::BcAd) => locale.dates.bc.as_deref(),
426        (true, EraLabels::BceCe) => locale.dates.bce.as_deref(),
427        (false, EraLabels::Default) => None,
428        (false, EraLabels::BcAd) => locale.dates.ad.as_deref(),
429        (false, EraLabels::BceCe) => locale.dates.ce.as_deref(),
430    };
431    label
432        .filter(|value| !value.is_empty())
433        .map(|value| format!(" {value}"))
434        .unwrap_or_default()
435}
436
437/// Format a closed range whose endpoints share a year, suppressing the
438/// redundant year on one side (e.g. "May 14–June 2, 2023").
439///
440/// Locale interval patterns receive reduced endpoints and the common year.
441/// When a locale has no pattern, the pre-existing English layouts remain the
442/// fallback for forms that already supported same-year suppression.
443fn format_same_year_range(
444    start: &citum_edtf::Date,
445    end: &citum_edtf::Date,
446    form: &DateForm,
447    locale: &citum_schema::locale::Locale,
448    date_config: Option<&citum_schema::options::dates::DateConfig>,
449    delimiter: &str,
450) -> Option<String> {
451    let start_date = DateValue::new(start.to_string());
452    let end_date = DateValue::new(end.to_string());
453    let start_fragment = format_same_year_fragment(&start_date, form, locale, date_config)?;
454    let end_fragment = format_same_year_fragment(&end_date, form, locale, date_config)?;
455    let shared_year = date_form_displays_year(form)
456        .then(|| format_single_date(&start_date, &DateForm::Year, locale, date_config))
457        .flatten();
458
459    if let Some(pattern_id) = date_range_pattern_id(form)
460        && let Some(rendered) = locale.resolve_date_range_pattern(
461            pattern_id,
462            &start_fragment,
463            &end_fragment,
464            shared_year.as_deref(),
465        )
466    {
467        return Some(rendered);
468    }
469
470    match form {
471        DateForm::Full => {
472            let end_full = format_single_date(&end_date, &DateForm::Full, locale, date_config)?;
473            Some(format!("{start_fragment}{delimiter}{end_full}"))
474        }
475        DateForm::YearMonth => {
476            let end_full =
477                format_single_date(&end_date, &DateForm::YearMonth, locale, date_config)?;
478            Some(format!("{start_fragment}{delimiter}{end_full}"))
479        }
480        DateForm::YearMonthDay => {
481            let start_full =
482                format_single_date(&start_date, &DateForm::YearMonthDay, locale, date_config)?;
483            Some(format!("{start_full}{delimiter}{end_fragment}"))
484        }
485        _ => None,
486    }
487}
488
489fn date_range_pattern_id(form: &DateForm) -> Option<&'static str> {
490    match form {
491        DateForm::Year => Some("pattern.date-range-year"),
492        DateForm::Month => Some("pattern.date-range-month"),
493        DateForm::MonthDay => Some("pattern.date-range-month-day"),
494        DateForm::YearMonth => Some("pattern.date-range-year-month"),
495        DateForm::Full => Some("pattern.date-range-full"),
496        DateForm::YearMonthDay => Some("pattern.date-range-year-month-day"),
497        DateForm::DayMonthAbbrYear => Some("pattern.date-range-day-month-abbr-year"),
498        DateForm::MonthAbbrDayYear => Some("pattern.date-range-month-abbr-day-year"),
499        _ => None,
500    }
501}
502
503fn format_same_year_fragment(
504    date: &DateValue,
505    form: &DateForm,
506    locale: &citum_schema::locale::Locale,
507    date_config: Option<&citum_schema::options::dates::DateConfig>,
508) -> Option<String> {
509    match form {
510        DateForm::Year => format_single_date(date, &DateForm::Year, locale, date_config),
511        DateForm::Month | DateForm::YearMonth => {
512            format_single_date(date, &DateForm::Month, locale, date_config)
513        }
514        DateForm::Full | DateForm::MonthDay | DateForm::YearMonthDay => {
515            format_single_date(date, &DateForm::MonthDay, locale, date_config)
516        }
517        DateForm::DayMonthAbbrYear | DateForm::MonthAbbrDayYear => {
518            format_abbreviated_month_day_fragment(date, form, locale, date_config)
519        }
520        _ => None,
521    }
522}
523
524fn format_abbreviated_month_day_fragment(
525    date: &DateValue,
526    form: &DateForm,
527    locale: &citum_schema::locale::Locale,
528    date_config: Option<&citum_schema::options::dates::DateConfig>,
529) -> Option<String> {
530    let numeric_months = date_config
531        .is_some_and(|config| config.month == citum_schema::options::MonthFormat::Numeric);
532    if numeric_months && let Some(month) = extract_month_numeric(date) {
533        return Some(match date.day() {
534            Some(day) => format!("{month}-{day:02}"),
535            None => month,
536        });
537    }
538
539    let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
540    if month.is_empty() {
541        return None;
542    }
543    match (form, date.day()) {
544        (DateForm::DayMonthAbbrYear, Some(day)) => Some(format!("{day} {month}")),
545        (DateForm::MonthAbbrDayYear, Some(day)) => Some(format!("{month} {day}")),
546        (_, None) => Some(month),
547        _ => None,
548    }
549}
550
551/// Append a date's opaque `note` (e.g. a source-calendar annotation), wrapped
552/// per `DateConfig.note_wrap`, directly after the complete formatted date —
553/// after any inlined year-suffix, before the component's own outer
554/// prefix/suffix/wrap. A no-op when the style has no `note-wrap` configured
555/// for this scope, or the date carries no note. The caller additionally
556/// skips this function entirely when the component sets
557/// `TemplateDate::suppress_note`. See
558/// `docs/specs/CALENDAR_DATE_ANNOTATIONS.md`.
559fn append_note<F: crate::render::format::OutputFormat<Output = String>>(
560    fmt: &F,
561    formatted: String,
562    date: &DateValue,
563    date_config: Option<&citum_schema::options::dates::DateConfig>,
564    reference: &Reference,
565    options: &RenderOptions<'_>,
566) -> String {
567    let Some(note) = date.note.as_deref().filter(|n| !n.is_empty()) else {
568        return formatted;
569    };
570    let Some(wrap) = date_config.and_then(|c| c.note_wrap.as_ref()) else {
571        return formatted;
572    };
573
574    let content = fmt.text(note);
575    let content = fmt.inner_affix(
576        wrap.inner_prefix.as_deref().unwrap_or_default(),
577        content,
578        wrap.inner_suffix.as_deref().unwrap_or_default(),
579    );
580    let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
581    let item_language = crate::values::effective_item_language(reference);
582    let (script, realization) = crate::values::punctuation_realization_context(
583        item_language.as_deref(),
584        options.config.multilingual.as_ref(),
585        options.locale.punctuation_realization.as_ref(),
586    );
587    let wrapped = fmt.wrap_punctuation(
588        &wrap.punctuation,
589        content,
590        &marks,
591        script,
592        realization.as_deref(),
593    );
594    format!("{formatted}{wrapped}")
595}
596
597/// Apply uncertainty and approximation markers to formatted date.
598fn apply_date_markers(
599    value: String,
600    date: &DateValue,
601    date_config: Option<&citum_schema::options::dates::DateConfig>,
602) -> String {
603    let mut result = value;
604    if date.is_approximate()
605        && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
606    {
607        let suffix = date_config
608            .and_then(|c| c.approximation_marker_suffix.as_deref())
609            .unwrap_or("");
610        result = format!("{marker}{result}{suffix}");
611    }
612    if date.is_uncertain()
613        && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
614    {
615        let prefix = date_config
616            .and_then(|c| c.uncertainty_marker_prefix.as_deref())
617            .unwrap_or("");
618        result = format!("{prefix}{result}{marker}");
619    }
620    result
621}
622
623/// Compute the disambiguation suffix for year-based citations.
624fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
625    date: &DateValue,
626    form: &DateForm,
627    hints: &ProcHints,
628    options: &RenderOptions<'_>,
629    fmt: &F,
630) -> Option<String> {
631    if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
632        compute_disamb_suffix_label(hints, options, fmt)
633    } else {
634        None
635    }
636}
637
638fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
639    hints: &ProcHints,
640    options: &RenderOptions<'_>,
641    fmt: &F,
642) -> Option<String> {
643    // Check if year suffix is enabled, resolving the processing default
644    // centrally so an unset `processing` matches the rest of the engine.
645    let use_suffix = options
646        .config
647        .effective_processing()
648        .config()
649        .disambiguate
650        .as_ref()
651        .is_some_and(|d| d.year_suffix);
652
653    if hints.disamb_condition && use_suffix {
654        int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
655    } else {
656        None
657    }
658}
659
660fn date_form_displays_year(form: &DateForm) -> bool {
661    !matches!(form, DateForm::MonthDay)
662}
663
664fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
665    let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
666        date_config.no_date_year_suffix_delimiter.as_str()
667    });
668    value.push_str(delimiter);
669    value.push_str(suffix);
670}
671
672fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
673    if year.is_empty() || suffix.is_empty() {
674        return formatted.to_string();
675    }
676
677    let year_index = match form {
678        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
679        DateForm::YearMonth
680        | DateForm::Full
681        | DateForm::DayMonthAbbrYear
682        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
683        DateForm::MonthDay => None,
684        _ => None,
685    };
686
687    let Some(index) = year_index else {
688        return format!("{formatted}{suffix}");
689    };
690
691    let year_end = index + year.len();
692    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
693    let result = format!(
694        "{}{}{}{}",
695        &formatted[..index],
696        year,
697        suffix,
698        &formatted[year_end..]
699    );
700    result
701}
702
703/// Format a single date (non-range) according to the given form.
704#[allow(
705    clippy::too_many_lines,
706    reason = "date formatting handles 6 form variants"
707)]
708fn format_single_date(
709    date: &DateValue,
710    form: &DateForm,
711    locale: &citum_schema::locale::Locale,
712    date_config: Option<&citum_schema::options::dates::DateConfig>,
713) -> Option<String> {
714    let default_era = citum_schema::options::dates::EraLabels::Default;
715    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
716    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
717    let neg_unspecified = date_config
718        .map(|c| &c.negative_unspecified_years)
719        .unwrap_or(&default_neg_unspec);
720    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
721    // `month: numeric` renders month-bearing forms as zero-padded numerals
722    // joined with hyphens (GB/T 7714, ISO 690). Dates without a real calendar
723    // month (seasons, literals) fall back to the textual path.
724    let numeric_months =
725        date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
726
727    let extract_year = |d: &DateValue| -> String {
728        match d.parse() {
729            RefDate::Edtf(edtf) => match edtf {
730                Edtf::Date(dt) => format_display_year(
731                    &dt.year,
732                    &locale.dates,
733                    era_labels,
734                    neg_unspecified,
735                    range_delimiter,
736                ),
737                Edtf::Interval(interval) => format_display_year(
738                    &interval.start.year,
739                    &locale.dates,
740                    era_labels,
741                    neg_unspecified,
742                    range_delimiter,
743                ),
744                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
745                    &dt.year,
746                    &locale.dates,
747                    era_labels,
748                    neg_unspecified,
749                    range_delimiter,
750                ),
751            },
752            RefDate::Literal(_) => String::new(),
753        }
754    };
755
756    match form {
757        DateForm::Year => {
758            let year = extract_year(date);
759            if year.is_empty() { None } else { Some(year) }
760        }
761        DateForm::YearMonth => {
762            let year = extract_year(date);
763            if year.is_empty() {
764                return None;
765            }
766            if numeric_months && let Some(month) = extract_month_numeric(date) {
767                return Some(format!("{year}-{month}"));
768            }
769            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
770            let month_opt = (!month.is_empty()).then_some(month.as_str());
771            if let Some(rendered) =
772                locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
773            {
774                return Some(rendered);
775            }
776            if month.is_empty() {
777                Some(year)
778            } else {
779                Some(format!("{month} {year}"))
780            }
781        }
782        DateForm::Month => {
783            if numeric_months && let Some(month) = extract_month_numeric(date) {
784                return Some(month);
785            }
786            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
787            if month.is_empty() { None } else { Some(month) }
788        }
789        DateForm::MonthDay => {
790            if numeric_months && let Some(month) = extract_month_numeric(date) {
791                return Some(match date.day() {
792                    Some(d) => format!("{month}-{d:02}"),
793                    None => month,
794                });
795            }
796            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
797            if month.is_empty() {
798                return None;
799            }
800            let day = date.day();
801            if let Some(rendered) =
802                locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
803            {
804                return Some(rendered);
805            }
806            match day {
807                Some(d) => Some(format!("{month} {d}")),
808                None => Some(month),
809            }
810        }
811        DateForm::Full => {
812            let year = extract_year(date);
813            if year.is_empty() {
814                return None;
815            }
816            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
817            let day = date.day();
818            let numeric_base = if numeric_months {
819                extract_month_numeric(date).map(|month| match day {
820                    Some(d) => format!("{year}-{month}-{d:02}"),
821                    None => format!("{year}-{month}"),
822                })
823            } else {
824                None
825            };
826            let base = numeric_base
827                .or_else(|| {
828                    locale.resolve_date_pattern(
829                        "pattern.date-full",
830                        Some(&year),
831                        (!month.is_empty()).then_some(month.as_str()),
832                        day,
833                    )
834                })
835                .unwrap_or_else(|| match (month.is_empty(), day) {
836                    (true, _) => year.clone(),
837                    (false, None) => format!("{month} {year}"),
838                    (false, Some(d)) => format!("{month} {d}, {year}"),
839                });
840            // Append time component if configured and present
841            if let (Some(time_fmt), Some(time)) = (
842                date_config.and_then(|c| c.time_format.as_ref()),
843                date.time(),
844            ) {
845                let show_secs = date_config.is_some_and(|c| c.show_seconds);
846                let show_tz = date_config.is_some_and(|c| c.show_timezone);
847                let time_str = format_time(
848                    time,
849                    time_fmt,
850                    show_secs,
851                    show_tz,
852                    locale.dates.am.as_deref(),
853                    locale.dates.pm.as_deref(),
854                    locale.dates.timezone_utc.as_deref(),
855                );
856                Some(format!("{base}, {time_str}"))
857            } else {
858                Some(base)
859            }
860        }
861        DateForm::YearMonthDay => {
862            let year = extract_year(date);
863            if year.is_empty() {
864                return None;
865            }
866            if numeric_months && let Some(month) = extract_month_numeric(date) {
867                return Some(match date.day() {
868                    Some(d) => format!("{year}-{month}-{d:02}"),
869                    None => format!("{year}-{month}"),
870                });
871            }
872            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
873            let day = date.day();
874            let month_opt = (!month.is_empty()).then_some(month.as_str());
875            if let Some(rendered) = locale.resolve_date_pattern(
876                "pattern.date-year-month-day",
877                Some(&year),
878                month_opt,
879                day,
880            ) {
881                return Some(rendered);
882            }
883            match (month.is_empty(), day) {
884                (true, _) => Some(year),
885                (false, None) => Some(format!("{year}, {month}")),
886                (false, Some(d)) => Some(format!("{year}, {month} {d}")),
887            }
888        }
889        DateForm::DayMonthAbbrYear => {
890            let year = extract_year(date);
891            if year.is_empty() {
892                return None;
893            }
894            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
895            let day = date.day();
896            let month_opt = (!month.is_empty()).then_some(month.as_str());
897            if let Some(rendered) = locale.resolve_date_pattern(
898                "pattern.date-day-month-abbr-year",
899                Some(&year),
900                month_opt,
901                day,
902            ) {
903                return Some(rendered);
904            }
905            match (month.is_empty(), day) {
906                (true, _) => Some(year),
907                (false, None) => Some(format!("{month} {year}")),
908                (false, Some(d)) => Some(format!("{d} {month} {year}")),
909            }
910        }
911        DateForm::MonthAbbrDayYear => {
912            let year = extract_year(date);
913            if year.is_empty() {
914                return None;
915            }
916            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
917            let day = date.day();
918            let month_opt = (!month.is_empty()).then_some(month.as_str());
919            if let Some(rendered) = locale.resolve_date_pattern(
920                "pattern.date-month-abbr-day-year",
921                Some(&year),
922                month_opt,
923                day,
924            ) {
925                return Some(rendered);
926            }
927            match (month.is_empty(), day) {
928                (true, _) => Some(year),
929                (false, None) => Some(format!("{month} {year}")),
930                (false, Some(d)) => Some(format!("{month} {d}, {year}")),
931            }
932        }
933        _ => Some(extract_year(date)),
934    }
935}
936
937/// Apply a fallback component's own `wrap`/`prefix`/`suffix` rendering.
938///
939/// `component.values()` only resolves the raw fallback string — it does not
940/// go through the generic per-component dispatch that normally applies a
941/// component's own rendering (that happens one layer up, outside the
942/// recursive fallback call). This applies it directly so e.g. `wrap:
943/// brackets` on a fallback `accessed` date isn't silently dropped. Shared by
944/// [`TemplateDate::values`] and `TemplateContributor`'s author-slot
945/// fallback (`crate::values::contributor`) — both fallback shapes need the
946/// identical post-render treatment.
947pub(crate) fn apply_fallback_component_rendering<
948    F: crate::render::format::OutputFormat<Output = String>,
949>(
950    fmt: &F,
951    value: &str,
952    pre_formatted: bool,
953    rendering: &citum_schema::template::Rendering,
954    reference: &Reference,
955    options: &RenderOptions<'_>,
956) -> F::Output {
957    let mut output = if pre_formatted {
958        fmt.join(vec![value.to_string()], "")
959    } else {
960        fmt.text(value)
961    };
962    if let Some(wrap_config) = rendering.wrap.as_ref() {
963        let (script, realization) = crate::values::punctuation_realization_context(
964            crate::values::effective_item_language(reference).as_deref(),
965            options.config.multilingual.as_ref(),
966            options.locale.punctuation_realization.as_ref(),
967        );
968        output = fmt.wrap_punctuation(
969            &wrap_config.punctuation,
970            output,
971            &crate::render::format::QuoteMarks::default(),
972            script,
973            realization.as_deref(),
974        );
975    }
976    let (script, realization) = crate::values::punctuation_realization_context(
977        crate::values::effective_item_language(reference).as_deref(),
978        options.config.multilingual.as_ref(),
979        options.locale.punctuation_realization.as_ref(),
980    );
981    let prefix = rendering
982        .prefix
983        .as_ref()
984        .map(|punctuation| {
985            crate::render::format::realize_punctuation(
986                punctuation,
987                script,
988                realization.as_deref(),
989                crate::render::format::PunctuationPosition::Prefix,
990            )
991        })
992        .unwrap_or_default();
993    let suffix = rendering
994        .suffix
995        .as_ref()
996        .map(|punctuation| {
997            crate::render::format::realize_punctuation(
998                punctuation,
999                script,
1000                realization.as_deref(),
1001                crate::render::format::PunctuationPosition::Suffix,
1002            )
1003        })
1004        .unwrap_or_default();
1005    if !prefix.is_empty() || !suffix.is_empty() {
1006        output = crate::render::format::apply_punctuation_affixes(
1007            fmt,
1008            rendering
1009                .prefix
1010                .as_ref()
1011                .map(|punctuation| (punctuation, prefix.as_ref())),
1012            output,
1013            rendering
1014                .suffix
1015                .as_ref()
1016                .map(|punctuation| (punctuation, suffix.as_ref())),
1017        );
1018    }
1019    output
1020}
1021
1022impl ComponentValues for TemplateDate {
1023    fn values<F: crate::render::format::OutputFormat<Output = String>>(
1024        &self,
1025        reference: &Reference,
1026        hints: &ProcHints,
1027        options: &RenderOptions<'_>,
1028    ) -> Option<ProcValues<F::Output>> {
1029        let fmt = F::default();
1030        let date_opt: Option<DateValue> = match self.date {
1031            TemplateDateVar::Issued => reference.effective_issued_date(),
1032            TemplateDateVar::Accessed => reference.accessed(),
1033            TemplateDateVar::OriginalPublished => reference.original_date(),
1034            TemplateDateVar::EventDate => event_date(reference),
1035            TemplateDateVar::Copyright => reference.copyright(),
1036            TemplateDateVar::Printing => reference.printing(),
1037            _ => None,
1038        };
1039
1040        let Some(date) = date_opt.filter(|d| !d.is_empty()) else {
1041            // Handle fallback if date is missing
1042            if let Some(fallbacks) = &self.fallback {
1043                for component in fallbacks {
1044                    if let Some(values) = component.values::<F>(reference, hints, options) {
1045                        let mut output = apply_fallback_component_rendering(
1046                            &fmt,
1047                            &values.value,
1048                            values.pre_formatted,
1049                            component.rendering(),
1050                            reference,
1051                            options,
1052                        );
1053                        // A `message:` fallback candidate is, by construction,
1054                        // the terminal "no data available" case in a date's
1055                        // fallback chain (e.g. GB/T 7714's `无日期`/`n.d.`
1056                        // term via `message: term.no-date`) — apply the same
1057                        // year-suffix-append convention the implicit (no
1058                        // explicit `fallback:`) no-date path below already
1059                        // uses, so explicit and implicit no-date fallbacks
1060                        // disambiguate identically. Without this, a style
1061                        // whose date components always carry an explicit
1062                        // `fallback:` chain (as GB/T author-date's do) never
1063                        // reaches the implicit branch and never gets a
1064                        // suffix at all. See csl26-6eak.
1065                        if matches!(self.date, TemplateDateVar::Issued)
1066                            && self.suppress_disamb_suffix != Some(true)
1067                            && matches!(component, TemplateComponent::Message(_))
1068                            && let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt)
1069                        {
1070                            append_no_date_disamb_suffix(&mut output, &suffix, options);
1071                        }
1072                        return Some(ProcValues {
1073                            value: output,
1074                            prefix: None,
1075                            suffix: None,
1076                            url: values.url,
1077                            substituted_key: values.substituted_key,
1078                            pre_formatted: true,
1079                        });
1080                    }
1081                }
1082                return None;
1083            }
1084            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
1085            if matches!(self.date, TemplateDateVar::Issued)
1086                && let Some(mut nd) = options.locale.resolved_general_term(
1087                    &GeneralTerm::NoDate,
1088                    &TermForm::Short,
1089                    None,
1090                )
1091            {
1092                if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
1093                    append_no_date_disamb_suffix(&mut nd, &suffix, options);
1094                }
1095                return Some(ProcValues {
1096                    value: nd,
1097                    prefix: None,
1098                    suffix: None,
1099                    url: None,
1100                    substituted_key: None,
1101                    pre_formatted: false,
1102                });
1103            }
1104            return None;
1105        };
1106
1107        let locale = options.locale;
1108        let date_config = options.config.dates.as_ref();
1109        let effective_form = self.form.clone();
1110
1111        let formatted = format_date_range(&date, &effective_form, locale, date_config);
1112
1113        // Apply uncertainty and approximation markers
1114        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
1115
1116        // Handle disambiguation suffix (a, b, c...).
1117        // Year-suffix is keyed off the issued year only; suppress it for other date
1118        // components (e.g. original-published) so a reprint template renders
1119        // `(1926/1967a)` rather than `(1926a/1967a)`.
1120        let disamb_suffix = (matches!(self.date, TemplateDateVar::Issued)
1121            && self.suppress_disamb_suffix != Some(true))
1122        .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
1123        .flatten();
1124
1125        formatted.map(|value| {
1126            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
1127                (
1128                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
1129                    None,
1130                )
1131            } else {
1132                (value, None)
1133            };
1134
1135            let value = if self.suppress_note == Some(true) {
1136                value
1137            } else {
1138                append_note(&fmt, value, &date, date_config, reference, options)
1139            };
1140
1141            ProcValues {
1142                value,
1143                prefix: None,
1144                suffix,
1145                url: crate::values::resolve_effective_url(
1146                    self.links.as_ref(),
1147                    options.config.links.as_ref(),
1148                    reference,
1149                    citum_schema::options::LinkAnchor::Component,
1150                ),
1151                substituted_key: None,
1152                pre_formatted: false,
1153            }
1154        })
1155    }
1156}
1157
1158/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
1159#[must_use]
1160pub fn int_to_letter(n: u32) -> Option<String> {
1161    if n == 0 {
1162        return None;
1163    }
1164
1165    let mut result = String::new();
1166    let mut num = n - 1;
1167
1168    loop {
1169        result.push((b'a' + (num % 26) as u8) as char);
1170        if num < 26 {
1171            break;
1172        }
1173        num = num / 26 - 1;
1174    }
1175
1176    Some(result.chars().rev().collect())
1177}
1178
1179#[cfg(test)]
1180#[allow(
1181    clippy::unwrap_used,
1182    clippy::expect_used,
1183    clippy::panic,
1184    clippy::indexing_slicing,
1185    clippy::todo,
1186    clippy::unimplemented,
1187    clippy::unreachable,
1188    clippy::get_unwrap,
1189    reason = "Panicking is acceptable and often desired in tests."
1190)]
1191mod tests {
1192    use super::*;
1193
1194    #[test]
1195    fn test_int_to_letter() {
1196        // Test basic single-letter conversions (1-26)
1197        assert_eq!(int_to_letter(1), Some("a".to_string()));
1198        assert_eq!(int_to_letter(2), Some("b".to_string()));
1199        assert_eq!(int_to_letter(26), Some("z".to_string()));
1200
1201        // Test double-letter conversions (27+)
1202        assert_eq!(int_to_letter(27), Some("aa".to_string()));
1203        assert_eq!(int_to_letter(52), Some("az".to_string()));
1204        assert_eq!(int_to_letter(53), Some("ba".to_string()));
1205
1206        // Test zero returns None
1207        assert_eq!(int_to_letter(0), None);
1208    }
1209
1210    #[test]
1211    fn test_apply_date_markers_uncertainty_suffix_only_by_default() {
1212        let date = DateValue::new("1750?");
1213        let config = citum_schema::options::dates::DateConfig::default();
1214        let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1215        assert_eq!(result, "1750?");
1216    }
1217
1218    #[test]
1219    fn test_apply_date_markers_uncertainty_paired_brackets() {
1220        let date = DateValue::new("1750?");
1221        let config = citum_schema::options::dates::DateConfig {
1222            uncertainty_marker: Some("?]".to_string()),
1223            uncertainty_marker_prefix: Some("[".to_string()),
1224            ..citum_schema::options::dates::DateConfig::default()
1225        };
1226        let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1227        assert_eq!(result, "[1750?]");
1228    }
1229}
1230
1231#[cfg(test)]
1232#[allow(
1233    clippy::unwrap_used,
1234    clippy::expect_used,
1235    clippy::panic,
1236    clippy::indexing_slicing,
1237    clippy::todo,
1238    clippy::unimplemented,
1239    clippy::unreachable,
1240    clippy::get_unwrap,
1241    reason = "Panicking is acceptable and often desired in tests."
1242)]
1243mod time_tests {
1244    use super::*;
1245    use citum_edtf::{Time, Timezone};
1246
1247    #[test]
1248    fn test_format_time_12h_utc() {
1249        let time = Time {
1250            hour: 23,
1251            minute: 20,
1252            second: 30,
1253            timezone: Some(Timezone::Utc),
1254        };
1255        let result = format_time(
1256            time,
1257            &TimeFormat::Hour12,
1258            false,
1259            true,
1260            Some("AM"),
1261            Some("PM"),
1262            Some("UTC"),
1263        );
1264        assert_eq!(result, "11:20 PM UTC");
1265    }
1266
1267    #[test]
1268    fn test_format_time_24h_utc() {
1269        let time = Time {
1270            hour: 23,
1271            minute: 20,
1272            second: 30,
1273            timezone: Some(Timezone::Utc),
1274        };
1275        let result = format_time(
1276            time,
1277            &TimeFormat::Hour24,
1278            false,
1279            true,
1280            None,
1281            None,
1282            Some("UTC"),
1283        );
1284        assert_eq!(result, "23:20 UTC");
1285    }
1286
1287    #[test]
1288    fn test_format_time_with_offset() {
1289        let time = Time {
1290            hour: 10,
1291            minute: 10,
1292            second: 10,
1293            timezone: Some(Timezone::Offset(330)),
1294        };
1295        let result = format_time(
1296            time,
1297            &TimeFormat::Hour24,
1298            false,
1299            true,
1300            None,
1301            None,
1302            Some("UTC"),
1303        );
1304        assert_eq!(result, "10:10 +05:30");
1305    }
1306
1307    #[test]
1308    fn test_format_time_no_timezone() {
1309        let time = Time {
1310            hour: 14,
1311            minute: 30,
1312            second: 0,
1313            timezone: None,
1314        };
1315        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
1316        assert_eq!(result, "14:30");
1317    }
1318}
1319
1320#[cfg(test)]
1321#[allow(
1322    clippy::unwrap_used,
1323    clippy::expect_used,
1324    clippy::panic,
1325    clippy::indexing_slicing,
1326    clippy::todo,
1327    clippy::unimplemented,
1328    clippy::unreachable,
1329    clippy::get_unwrap,
1330    reason = "Panicking is acceptable and often desired in tests."
1331)]
1332mod era_tests {
1333    use super::*;
1334    use citum_edtf::{UnspecifiedYear, Year};
1335    use citum_schema::locale::{DateTerms, Locale};
1336    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
1337
1338    fn en_terms() -> DateTerms {
1339        Locale::en_us().dates
1340    }
1341
1342    #[test]
1343    fn positive_year_default_no_suffix() {
1344        let year = Year {
1345            value: 54,
1346            unspecified: UnspecifiedYear::None,
1347        };
1348        let result = format_display_year(
1349            &year,
1350            &en_terms(),
1351            &EraLabels::Default,
1352            &NegativeUnspecifiedYears::Range,
1353            "–",
1354        );
1355        assert_eq!(result, "54");
1356    }
1357
1358    #[test]
1359    fn positive_year_bc_ad() {
1360        let year = Year {
1361            value: 54,
1362            unspecified: UnspecifiedYear::None,
1363        };
1364        let result = format_display_year(
1365            &year,
1366            &en_terms(),
1367            &EraLabels::BcAd,
1368            &NegativeUnspecifiedYears::Range,
1369            "–",
1370        );
1371        assert_eq!(result, "54 AD");
1372    }
1373
1374    #[test]
1375    fn positive_year_bce_ce() {
1376        let year = Year {
1377            value: 54,
1378            unspecified: UnspecifiedYear::None,
1379        };
1380        let result = format_display_year(
1381            &year,
1382            &en_terms(),
1383            &EraLabels::BceCe,
1384            &NegativeUnspecifiedYears::Range,
1385            "–",
1386        );
1387        assert_eq!(result, "54 CE");
1388    }
1389
1390    #[test]
1391    fn negative_year_default() {
1392        let year = Year {
1393            value: -43,
1394            unspecified: UnspecifiedYear::None,
1395        };
1396        let result = format_display_year(
1397            &year,
1398            &en_terms(),
1399            &EraLabels::Default,
1400            &NegativeUnspecifiedYears::Range,
1401            "–",
1402        );
1403        assert_eq!(result, "44 BC");
1404    }
1405
1406    #[test]
1407    fn negative_year_bc_ad() {
1408        let year = Year {
1409            value: -43,
1410            unspecified: UnspecifiedYear::None,
1411        };
1412        let result = format_display_year(
1413            &year,
1414            &en_terms(),
1415            &EraLabels::BcAd,
1416            &NegativeUnspecifiedYears::Range,
1417            "–",
1418        );
1419        assert_eq!(result, "44 BC");
1420    }
1421
1422    #[test]
1423    fn negative_year_bce_ce() {
1424        let year = Year {
1425            value: -43,
1426            unspecified: UnspecifiedYear::None,
1427        };
1428        let result = format_display_year(
1429            &year,
1430            &en_terms(),
1431            &EraLabels::BceCe,
1432            &NegativeUnspecifiedYears::Range,
1433            "–",
1434        );
1435        assert_eq!(result, "44 BCE");
1436    }
1437
1438    #[test]
1439    fn positive_unspecified_ones() {
1440        let year = Year {
1441            value: 1990,
1442            unspecified: UnspecifiedYear::One,
1443        };
1444        let result = format_display_year(
1445            &year,
1446            &en_terms(),
1447            &EraLabels::Default,
1448            &NegativeUnspecifiedYears::Range,
1449            "–",
1450        );
1451        assert_eq!(result, "199X");
1452    }
1453
1454    #[test]
1455    fn positive_unspecified_two() {
1456        let year = Year {
1457            value: 1900,
1458            unspecified: UnspecifiedYear::Two,
1459        };
1460        let result = format_display_year(
1461            &year,
1462            &en_terms(),
1463            &EraLabels::Default,
1464            &NegativeUnspecifiedYears::Range,
1465            "–",
1466        );
1467        assert_eq!(result, "19XX");
1468    }
1469
1470    #[test]
1471    fn negative_unspecified_range() {
1472        let year = Year {
1473            value: -90,
1474            unspecified: UnspecifiedYear::One,
1475        };
1476        let result = format_display_year(
1477            &year,
1478            &en_terms(),
1479            &EraLabels::Default,
1480            &NegativeUnspecifiedYears::Range,
1481            "–",
1482        );
1483        assert_eq!(result, "100–91 BC");
1484    }
1485
1486    #[test]
1487    fn negative_unspecified_century() {
1488        let year = Year {
1489            value: 0,
1490            unspecified: UnspecifiedYear::Two,
1491        };
1492        let result = format_display_year(
1493            &year,
1494            &en_terms(),
1495            &EraLabels::Default,
1496            &NegativeUnspecifiedYears::Range,
1497            "–",
1498        );
1499        assert_eq!(result, "100–1 BC");
1500    }
1501
1502    #[test]
1503    fn backwards_compat_negative_year() {
1504        let year = Year {
1505            value: -99,
1506            unspecified: UnspecifiedYear::None,
1507        };
1508        let result = format_display_year(
1509            &year,
1510            &en_terms(),
1511            &EraLabels::Default,
1512            &NegativeUnspecifiedYears::Range,
1513            "–",
1514        );
1515        assert_eq!(result, "100 BC");
1516    }
1517}
1518
1519#[cfg(test)]
1520#[allow(
1521    clippy::unwrap_used,
1522    clippy::expect_used,
1523    reason = "Panicking is acceptable in tests."
1524)]
1525mod locale_pattern_tests {
1526    use super::*;
1527    use citum_schema::locale::Locale;
1528
1529    fn en_us() -> Locale {
1530        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1531            .expect("en-US locale should parse")
1532    }
1533
1534    fn es_es() -> Locale {
1535        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1536            .expect("es-ES locale should parse")
1537    }
1538
1539    fn eu_es() -> Locale {
1540        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1541            .expect("eu-ES locale should parse")
1542    }
1543
1544    fn full(locale: &Locale, edtf: &str) -> String {
1545        format_single_date(
1546            &DateValue::new(edtf.to_string()),
1547            &DateForm::Full,
1548            locale,
1549            None,
1550        )
1551        .expect("date should render")
1552    }
1553
1554    fn month_day(locale: &Locale, edtf: &str) -> String {
1555        format_single_date(
1556            &DateValue::new(edtf.to_string()),
1557            &DateForm::MonthDay,
1558            locale,
1559            None,
1560        )
1561        .expect("date should render")
1562    }
1563
1564    #[test]
1565    fn en_us_full_unchanged_by_pattern_machinery() {
1566        // Regression: en-US declares no pattern.date-*, so the engine's
1567        // hardcoded English assembly must still produce the original output.
1568        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1569    }
1570
1571    #[test]
1572    fn en_us_month_day_unchanged_by_pattern_machinery() {
1573        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1574    }
1575
1576    #[test]
1577    fn en_us_month_form_renders_month_name_only() {
1578        // given a year-month date and the month-only form
1579        let out = format_single_date(
1580            &DateValue::new("2023-06".to_string()),
1581            &DateForm::Month,
1582            &en_us(),
1583            None,
1584        );
1585        // then only the month name renders (no year), e.g. magazines
1586        assert_eq!(out.as_deref(), Some("June"));
1587    }
1588
1589    #[test]
1590    fn en_us_month_form_renders_season_name() {
1591        // given an EDTF season date and the month-only form
1592        let out = format_single_date(
1593            &DateValue::new("2023-21".to_string()),
1594            &DateForm::Month,
1595            &en_us(),
1596            None,
1597        );
1598        // then the locale's season term renders in place of a month name
1599        assert_eq!(out.as_deref(), Some("Spring"));
1600    }
1601
1602    #[test]
1603    fn en_us_year_month_form_renders_season_and_year() {
1604        let out = format_single_date(
1605            &DateValue::new("2023-21".to_string()),
1606            &DateForm::YearMonth,
1607            &en_us(),
1608            None,
1609        );
1610        assert_eq!(out.as_deref(), Some("Spring 2023"));
1611    }
1612
1613    #[test]
1614    fn en_us_full_form_renders_season_and_year() {
1615        assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1616    }
1617
1618    #[test]
1619    fn es_es_year_month_form_renders_localized_season() {
1620        let out = format_single_date(
1621            &DateValue::new("2023-23".to_string()),
1622            &DateForm::YearMonth,
1623            &es_es(),
1624            None,
1625        );
1626        assert_eq!(out.as_deref(), Some("otoño de 2023"));
1627    }
1628
1629    #[test]
1630    fn es_es_full_uses_locale_pattern() {
1631        // Spanish day-first assembly via pattern.date-full.
1632        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1633    }
1634
1635    #[test]
1636    fn es_es_month_day_uses_locale_pattern() {
1637        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1638    }
1639
1640    #[test]
1641    fn eu_es_full_uses_locale_pattern() {
1642        // Basque genitive-absolutive shape via pattern.date-full.
1643        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
1644        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1645    }
1646
1647    #[test]
1648    fn eu_es_month_day_uses_locale_pattern() {
1649        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1650    }
1651
1652    fn year_month(locale: &Locale, edtf: &str) -> String {
1653        format_single_date(
1654            &DateValue::new(edtf.to_string()),
1655            &DateForm::YearMonth,
1656            locale,
1657            None,
1658        )
1659        .expect("date should render")
1660    }
1661
1662    fn year_month_day(locale: &Locale, edtf: &str) -> String {
1663        format_single_date(
1664            &DateValue::new(edtf.to_string()),
1665            &DateForm::YearMonthDay,
1666            locale,
1667            None,
1668        )
1669        .expect("date should render")
1670    }
1671
1672    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1673        format_single_date(
1674            &DateValue::new(edtf.to_string()),
1675            &DateForm::DayMonthAbbrYear,
1676            locale,
1677            None,
1678        )
1679        .expect("date should render")
1680    }
1681
1682    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1683        format_single_date(
1684            &DateValue::new(edtf.to_string()),
1685            &DateForm::MonthAbbrDayYear,
1686            locale,
1687            None,
1688        )
1689        .expect("date should render")
1690    }
1691
1692    #[test]
1693    fn en_us_year_month_unchanged_by_pattern_machinery() {
1694        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
1695        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1696    }
1697
1698    #[test]
1699    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1700        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1701    }
1702
1703    #[test]
1704    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1705        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1706    }
1707
1708    #[test]
1709    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1710        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1711    }
1712
1713    #[test]
1714    fn es_es_year_month_uses_locale_pattern() {
1715        // Spanish: month before year connected with "de".
1716        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1717    }
1718
1719    #[test]
1720    fn eu_es_year_month_uses_locale_pattern() {
1721        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
1722        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1723    }
1724
1725    #[test]
1726    fn year_month_missing_month_falls_back_to_year() {
1727        // Year-only EDTF: no month to pattern-assemble, returns year alone.
1728        assert_eq!(year_month(&es_es(), "2023"), "2023");
1729    }
1730
1731    #[test]
1732    fn es_es_year_month_day_uses_locale_pattern() {
1733        // Spanish: year first, then day/month connected with "de".
1734        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1735    }
1736
1737    #[test]
1738    fn es_es_year_month_day_missing_day_falls_back() {
1739        // Pattern requires $day; evaluator returns None, falls back to
1740        // hardcoded "{year}, {month}".
1741        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1742    }
1743
1744    #[test]
1745    fn es_es_day_month_abbr_year_uses_locale_pattern() {
1746        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
1747        assert_eq!(
1748            day_month_abbr_year(&es_es(), "2023-01-12"),
1749            "12 ene. de 2023"
1750        );
1751    }
1752
1753    #[test]
1754    fn es_es_day_month_abbr_year_missing_day_falls_back() {
1755        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1756        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1757    }
1758
1759    #[test]
1760    fn es_es_month_abbr_day_year_uses_locale_pattern() {
1761        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
1762        assert_eq!(
1763            month_abbr_day_year(&es_es(), "2023-01-12"),
1764            "ene. 12 de 2023"
1765        );
1766    }
1767
1768    #[test]
1769    fn es_es_month_abbr_day_year_missing_day_falls_back() {
1770        // Pattern requires $day; falls back to hardcoded "{month} {year}".
1771        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1772    }
1773
1774    #[test]
1775    fn pattern_missing_day_falls_back_to_english_assembly() {
1776        // Year-month only input: pattern.date-full requires {$day} so the
1777        // evaluator returns None, and the engine falls through to its
1778        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
1779        // can fix this for inflected locales — out of scope for this bean.)
1780        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1781    }
1782}
1783
1784#[cfg(test)]
1785#[allow(
1786    clippy::unwrap_used,
1787    clippy::expect_used,
1788    reason = "Panicking is acceptable in tests."
1789)]
1790mod numeric_month_tests {
1791    use super::*;
1792    use citum_schema::locale::Locale;
1793    use citum_schema::options::MonthFormat;
1794    use citum_schema::options::dates::DateConfig;
1795
1796    fn en_us() -> Locale {
1797        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1798            .expect("en-US locale should parse")
1799    }
1800
1801    fn numeric_config() -> DateConfig {
1802        DateConfig {
1803            month: MonthFormat::Numeric,
1804            ..Default::default()
1805        }
1806    }
1807
1808    fn render(form: DateForm, edtf: &str) -> Option<String> {
1809        format_single_date(
1810            &DateValue::new(edtf.to_string()),
1811            &form,
1812            &en_us(),
1813            Some(&numeric_config()),
1814        )
1815    }
1816
1817    #[test]
1818    fn given_month_numeric_when_year_month_day_then_iso_hyphenated() {
1819        // GB/T 7714 / ISO 690 access and update dates: [2024-01-15].
1820        assert_eq!(
1821            render(DateForm::YearMonthDay, "2024-01-15").as_deref(),
1822            Some("2024-01-15")
1823        );
1824    }
1825
1826    #[test]
1827    fn given_month_numeric_when_day_missing_then_year_month_only() {
1828        assert_eq!(
1829            render(DateForm::YearMonthDay, "2024-01").as_deref(),
1830            Some("2024-01")
1831        );
1832    }
1833
1834    #[test]
1835    fn given_month_numeric_when_year_only_then_plain_year() {
1836        assert_eq!(
1837            render(DateForm::YearMonthDay, "2024").as_deref(),
1838            Some("2024")
1839        );
1840    }
1841
1842    #[test]
1843    fn given_month_numeric_when_year_month_form_then_hyphenated() {
1844        assert_eq!(
1845            render(DateForm::YearMonth, "2024-03").as_deref(),
1846            Some("2024-03")
1847        );
1848    }
1849
1850    #[test]
1851    fn given_month_numeric_when_month_day_form_then_zero_padded() {
1852        assert_eq!(
1853            render(DateForm::MonthDay, "2024-03-05").as_deref(),
1854            Some("03-05")
1855        );
1856    }
1857
1858    #[test]
1859    fn given_month_numeric_when_full_form_then_iso_hyphenated() {
1860        assert_eq!(
1861            render(DateForm::Full, "2024-01-15").as_deref(),
1862            Some("2024-01-15")
1863        );
1864    }
1865
1866    #[test]
1867    fn given_month_numeric_when_season_date_then_textual_fallback() {
1868        // Seasons have no numeric month; the textual path must still render.
1869        assert_eq!(
1870            render(DateForm::YearMonth, "2024-22").as_deref(),
1871            Some("Summer 2024")
1872        );
1873    }
1874
1875    #[test]
1876    fn given_long_month_config_when_year_month_day_then_unchanged() {
1877        // Regression guard: the default textual assembly is untouched.
1878        let out = format_single_date(
1879            &DateValue::new("2024-01-15".to_string()),
1880            &DateForm::YearMonthDay,
1881            &en_us(),
1882            None,
1883        );
1884        assert_eq!(out.as_deref(), Some("2024, January 15"));
1885    }
1886}
1887
1888#[cfg(test)]
1889#[allow(
1890    clippy::unwrap_used,
1891    clippy::expect_used,
1892    reason = "Panicking is acceptable in tests."
1893)]
1894mod range_tests {
1895    use super::*;
1896    use citum_schema::locale::Locale;
1897    use citum_schema::options::dates::{DateConfig, EraLabels};
1898
1899    fn en_us() -> Locale {
1900        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1901            .expect("en-US locale should parse")
1902    }
1903
1904    fn es_es() -> Locale {
1905        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1906            .expect("es-ES locale should parse")
1907    }
1908
1909    fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1910        format_date_range(&DateValue::new(edtf.to_string()), &form, locale, None)
1911    }
1912
1913    fn chicago_range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1914        let config = DateConfig {
1915            range_format: DateRangeFormat::Chicago,
1916            ..Default::default()
1917        };
1918        format_date_range(
1919            &DateValue::new(edtf.to_string()),
1920            &form,
1921            locale,
1922            Some(&config),
1923        )
1924    }
1925
1926    fn range_with_config(
1927        locale: &Locale,
1928        edtf: &str,
1929        form: DateForm,
1930        config: &DateConfig,
1931    ) -> Option<String> {
1932        format_date_range(
1933            &DateValue::new(edtf.to_string()),
1934            &form,
1935            locale,
1936            Some(config),
1937        )
1938    }
1939
1940    #[test]
1941    fn closed_range_year_form_regression() {
1942        // given a closed range with distinct years and the Year form
1943        // then it renders as a plain year-to-year range (no collapse)
1944        assert_eq!(
1945            range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1946            Some("2020–2022")
1947        );
1948    }
1949
1950    #[test]
1951    fn chicago_year_range_condenses_the_end_year() {
1952        assert_eq!(
1953            chicago_range(&en_us(), "2021/2026", DateForm::Year).as_deref(),
1954            Some("2021–26")
1955        );
1956    }
1957
1958    #[test]
1959    fn chicago_range_format_keeps_cross_year_month_ranges_expanded() {
1960        assert_eq!(
1961            chicago_range(&en_us(), "2021-05/2026-06", DateForm::YearMonth).as_deref(),
1962            Some("May 2021–June 2026")
1963        );
1964    }
1965
1966    #[test]
1967    fn shared_year_month_range_uses_spanish_mf2_pattern() {
1968        assert_eq!(
1969            range(&es_es(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
1970            Some("mayo a junio, 2026")
1971        );
1972    }
1973
1974    #[test]
1975    fn shared_year_full_range_uses_spanish_mf2_pattern() {
1976        assert_eq!(
1977            range(&es_es(), "2026-05-14/2026-06-02", DateForm::Full).as_deref(),
1978            Some("14 de mayo a 2 de junio de 2026")
1979        );
1980    }
1981
1982    #[test]
1983    fn chicago_year_range_condenses_same_era_bce_years() {
1984        let config = DateConfig {
1985            range_format: DateRangeFormat::Chicago,
1986            era_labels: EraLabels::BceCe,
1987            ..Default::default()
1988        };
1989        assert_eq!(
1990            range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
1991            Some("327–21 BCE")
1992        );
1993    }
1994
1995    #[test]
1996    fn expanded_same_era_bce_years_keep_both_endpoints() {
1997        let config = DateConfig {
1998            era_labels: EraLabels::BceCe,
1999            ..Default::default()
2000        };
2001        assert_eq!(
2002            range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
2003            Some("327 BCE–21 BCE")
2004        );
2005    }
2006
2007    #[test]
2008    fn chicago_year_range_preserves_cross_era_endpoints() {
2009        let config = DateConfig {
2010            range_format: DateRangeFormat::Chicago,
2011            era_labels: EraLabels::BcAd,
2012            ..Default::default()
2013        };
2014        assert_eq!(
2015            range_with_config(&en_us(), "-0114/0010", DateForm::Year, &config).as_deref(),
2016            Some("115 BC–10 AD")
2017        );
2018    }
2019
2020    #[test]
2021    fn chicago_year_range_keeps_reversed_input_expanded() {
2022        assert_eq!(
2023            chicago_range(&en_us(), "2026/2021", DateForm::Year).as_deref(),
2024            Some("2026–2021")
2025        );
2026    }
2027
2028    #[test]
2029    fn chicago_range_format_does_not_condense_non_four_digit_or_unspecified_years() {
2030        assert_eq!(
2031            chicago_range(&en_us(), "0999/1000", DateForm::Year).as_deref(),
2032            Some("999–1000")
2033        );
2034        assert_eq!(
2035            chicago_range(&en_us(), "202u/203u", DateForm::Year).as_deref(),
2036            Some("202X–203X")
2037        );
2038    }
2039
2040    #[test]
2041    fn closed_range_full_form_different_years() {
2042        // given a closed range spanning two years, Full form
2043        // then both endpoints render in full
2044        assert_eq!(
2045            range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
2046            Some("May 14, 2023–June 2, 2024")
2047        );
2048    }
2049
2050    #[test]
2051    fn closed_range_full_form_same_year_collapses() {
2052        // given a closed range within a single year, Full form
2053        // then the start's year is suppressed and trails the end instead
2054        assert_eq!(
2055            range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
2056            Some("May 14–June 2, 2023")
2057        );
2058    }
2059
2060    #[test]
2061    fn closed_range_year_month_day_same_year_collapses() {
2062        // given a closed range within a single year, YearMonthDay form
2063        // then the leading year renders once and the end's year is suppressed
2064        assert_eq!(
2065            range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
2066            Some("2023, May 14–June 2")
2067        );
2068    }
2069
2070    #[test]
2071    fn closed_range_full_form_es_es_locale_pattern() {
2072        // given a closed range spanning two years under a locale that
2073        // declares pattern.date-full
2074        // then both endpoints render through the Spanish pattern
2075        assert_eq!(
2076            range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
2077            Some("12 de enero de 2023–3 de febrero de 2024")
2078        );
2079    }
2080
2081    #[test]
2082    fn interval_to_year_form() {
2083        // given an open-ended-from-start range ("../2020")
2084        // then it renders as the single known (end) point
2085        assert_eq!(
2086            range(&en_us(), "../2020", DateForm::Year).as_deref(),
2087            Some("2020")
2088        );
2089    }
2090
2091    #[test]
2092    fn closed_range_year_month_same_year_collapses() {
2093        // given month-only endpoints in the same year, YearMonth form
2094        // then the start month renders without the (shared) year
2095        assert_eq!(
2096            range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
2097            Some("May–June 2023")
2098        );
2099    }
2100
2101    #[test]
2102    fn chicago_range_format_keeps_same_year_month_ranges_locale_driven() {
2103        assert_eq!(
2104            chicago_range(&en_us(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
2105            Some("May–June 2026")
2106        );
2107    }
2108
2109    #[test]
2110    fn closed_range_season_same_year_collapses() {
2111        // given EDTF season endpoints in the same year, YearMonth form
2112        // then the start season renders without the (shared) year
2113        assert_eq!(
2114            range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
2115            Some("Spring–Summer 2023")
2116        );
2117    }
2118}