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, SubYearCode, TermForm};
15use citum_schema::options::dates::{DateRangeFormat, TimeFormat};
16use citum_schema::options::{Config, DateSubstituteCandidate};
17use citum_schema::reference::types::RefDate;
18use citum_schema::reference::{ClassExtension, WorkRelation};
19use citum_schema::template::{
20    DateForm, DateVariable as TemplateDateVar, Rendering, TemplateComponent, TemplateDate,
21};
22use std::borrow::Cow;
23use std::collections::BTreeMap;
24
25/// Zero-pad a rendered day when `zero_pad` is set, otherwise render it as-is.
26fn format_day(day: u32, zero_pad: bool) -> String {
27    if zero_pad {
28        format!("{day:02}")
29    } else {
30        day.to_string()
31    }
32}
33
34fn code_to_string(code: u32, names: &BTreeMap<SubYearCode, String>) -> String {
35    u8::try_from(code)
36        .ok()
37        .and_then(SubYearCode::new)
38        .and_then(|c| names.get(&c))
39        .cloned()
40        .unwrap_or_default()
41}
42
43/// Zero-padded numeric month (`"01"`–`"12"`) for `month: numeric` rendering.
44/// Seasons and literal dates have no numeric form and return `None` so
45/// callers fall back to the textual path.
46fn extract_month_numeric(date: &DateValue) -> Option<String> {
47    let RefDate::Edtf(edtf) = date.parse() else {
48        return None;
49    };
50    let month = edtf.month()?;
51    (1..=12).contains(&month).then(|| format!("{month:02}"))
52}
53
54fn extract_month(
55    date: &DateValue,
56    months: &BTreeMap<SubYearCode, String>,
57    seasons: &BTreeMap<SubYearCode, String>,
58) -> String {
59    let parsed_date = date.parse();
60    let edtf = match parsed_date {
61        RefDate::Edtf(edtf) => edtf,
62        RefDate::Literal(_) => return String::new(),
63    };
64    match edtf.month() {
65        Some(month) => code_to_string(month, months),
66        None => match edtf.season_code() {
67            Some(code) => code_to_string(code, seasons),
68            None => String::new(),
69        },
70    }
71}
72
73/// Resolve the reference-level date value a `TemplateDateVar` addresses.
74///
75/// Shared by `TemplateDate::values` (rendering) and `Disambiguator`
76/// (collision-key discrimination, `csl26-huuz`) so both read the date
77/// variable → reference-field mapping from one place.
78pub(crate) fn resolve_date_variable(
79    variable: &TemplateDateVar,
80    reference: &Reference,
81) -> Option<DateValue> {
82    match variable {
83        TemplateDateVar::Issued => reference.effective_issued_date(),
84        TemplateDateVar::Accessed => reference.accessed(),
85        TemplateDateVar::OriginalPublished => reference.original_date(),
86        TemplateDateVar::EventDate => event_date(reference),
87        TemplateDateVar::Copyright => reference.copyright(),
88        TemplateDateVar::Printing => reference.printing(),
89        _ => None,
90    }
91}
92
93/// Effective source for the identity date's fallback candidates.
94///
95/// The variants preserve the semantic distinction between omitted options,
96/// an authored policy with no matching selector, and a matched selector whose
97/// candidate list may intentionally be empty.
98pub(crate) enum EffectiveDateCandidateSource<'a> {
99    /// `date-substitute` was omitted; preserve inline or implicit behavior.
100    Inline(Option<&'a [TemplateComponent]>),
101    /// A policy exists but no selector matched; preserve inline or implicit behavior.
102    Unmatched(Option<&'a [TemplateComponent]>),
103    /// A selector matched; its list replaces the inline chain as a whole.
104    Matched(&'a [DateSubstituteCandidate]),
105}
106
107impl<'a> EffectiveDateCandidateSource<'a> {
108    /// Materialize the effective source as ordinary template components.
109    pub(crate) fn template_components(&self) -> Option<Cow<'a, [TemplateComponent]>> {
110        match self {
111            Self::Inline(inline) | Self::Unmatched(inline) => inline.map(Cow::Borrowed),
112            Self::Matched(candidates) => Some(Cow::Owned(
113                candidates
114                    .iter()
115                    .map(DateSubstituteCandidate::to_template_component)
116                    .collect(),
117            )),
118        }
119    }
120}
121
122/// Resolve the effective candidate source for an identity date component.
123pub(crate) fn effective_date_candidate_source<'a>(
124    component: &'a TemplateDate,
125    config: &'a Config,
126    ref_type: &str,
127) -> EffectiveDateCandidateSource<'a> {
128    let inline = component.fallback.as_deref();
129    let Some(policy) = config.date_substitute.as_ref() else {
130        return EffectiveDateCandidateSource::Inline(inline);
131    };
132    policy.candidates_for(ref_type).map_or_else(
133        || EffectiveDateCandidateSource::Unmatched(inline),
134        EffectiveDateCandidateSource::Matched,
135    )
136}
137
138/// Materialize an options-level candidate chain on the first eligible date.
139///
140/// Traversal is recursive and authored-order preserving. Only the first date
141/// whose `suppress-disamb-suffix` is not true is the identity slot; later
142/// dates retain their inline fallbacks.
143pub(crate) fn materialize_identity_date_substitute<'a>(
144    template: &'a [TemplateComponent],
145    config: &Config,
146    ref_type: &str,
147) -> Cow<'a, [TemplateComponent]> {
148    let Some(identity_date) = first_identity_date(template) else {
149        return Cow::Borrowed(template);
150    };
151    let EffectiveDateCandidateSource::Matched(candidates) =
152        effective_date_candidate_source(identity_date, config, ref_type)
153    else {
154        return Cow::Borrowed(template);
155    };
156
157    let mut resolved = template.to_vec();
158    replace_first_identity_date(&mut resolved, candidates);
159    Cow::Owned(resolved)
160}
161
162fn first_identity_date(template: &[TemplateComponent]) -> Option<&TemplateDate> {
163    for component in template {
164        match component {
165            TemplateComponent::Date(date) if date.suppress_disamb_suffix != Some(true) => {
166                return Some(date);
167            }
168            TemplateComponent::Group(group) => {
169                if let Some(date) = first_identity_date(&group.group) {
170                    return Some(date);
171                }
172            }
173            _ => {}
174        }
175    }
176    None
177}
178
179fn replace_first_identity_date(
180    template: &mut [TemplateComponent],
181    candidates: &[DateSubstituteCandidate],
182) -> bool {
183    for component in template {
184        match component {
185            TemplateComponent::Date(date) if date.suppress_disamb_suffix != Some(true) => {
186                date.fallback = Some(
187                    candidates
188                        .iter()
189                        .map(DateSubstituteCandidate::to_template_component)
190                        .collect(),
191                );
192                return true;
193            }
194            TemplateComponent::Group(group) => {
195                if replace_first_identity_date(&mut group.group, candidates) {
196                    return true;
197                }
198            }
199            _ => {}
200        }
201    }
202    false
203}
204
205/// The same date-text formatting `TemplateDate::values` applies to a
206/// resolved date value — `form`-restricted range/single-date formatting plus
207/// uncertainty/approximation markers — before any year-suffix disambiguation
208/// is layered on. Exposed so the disambiguator's collision-key discriminant
209/// reads the text a reference will actually render, not the raw stored
210/// value (whose `Display` is the unformatted EDTF/literal string and can
211/// carry more precision than `form` shows, e.g. a day-precision `copyright`
212/// date under `form: year`). See csl26-huuz.
213pub(crate) fn formatted_date_text(
214    date: &DateValue,
215    form: &DateForm,
216    locale: &citum_schema::locale::Locale,
217    date_config: Option<&citum_schema::options::dates::DateConfig>,
218) -> Option<String> {
219    format_date_range(date, form, locale, date_config)
220        .map(|value| apply_date_markers(value, date, date_config))
221}
222
223/// Text uniquely identifying what a date component renders for a specific
224/// reference, for collision-key purposes: the `form`-restricted formatted
225/// value (`formatted_date_text`) plus the candidate's visible rendering
226/// configuration and the resolved value's `note` — the same extra text
227/// `render_fallback_component`/`append_note` add to the bare value
228/// during real rendering. Two candidates whose rendered text differs only in
229/// these respects (e.g. a `c`-prefixed `copyright` year and a
230/// `印刷`-suffixed `printing` year that happen to share the same bare year)
231/// must not discriminant to the same text.
232///
233/// This does not need to *look like* the rendered text — it only needs the
234/// invariant "same render inputs ⟺ same discriminant" — so it Debug-formats
235/// the visible rendering config rather than running the full punctuation-
236/// realization pipeline, which would require threading a complete
237/// `RenderOptions` and `OutputFormat` into `Disambiguator` for no
238/// observable benefit. See csl26-huuz, flagged in PR review.
239pub(crate) fn fallback_candidate_discriminant(
240    date: &DateValue,
241    form: &DateForm,
242    rendering: &Rendering,
243    suppress_note: Option<bool>,
244    locale: &citum_schema::locale::Locale,
245    date_config: Option<&citum_schema::options::dates::DateConfig>,
246) -> Option<String> {
247    let formatted = formatted_date_text(date, form, locale, date_config)?;
248    let note = (suppress_note != Some(true)
249        && date_config.is_some_and(|config| config.note_wrap.is_some()))
250    .then_some(date.note.as_deref())
251    .flatten()
252    .filter(|note| !note.is_empty())
253    .unwrap_or_default();
254    Some(format!(
255        "{formatted}|{note}|{}",
256        visible_rendering_discriminant(rendering)
257    ))
258}
259
260/// Resolve the visible collision-key text for a message fallback candidate.
261pub(crate) fn fallback_message_discriminant(
262    message: &citum_schema::template::TemplateMessage,
263    locale: &citum_schema::locale::Locale,
264    config: &Config,
265) -> Option<String> {
266    if message.rendering.suppress == Some(true) {
267        return None;
268    }
269    let value = crate::values::message::resolve_template_message_value(message, config, locale)?;
270    Some(format!(
271        "{value}|{}",
272        visible_rendering_discriminant(&message.rendering)
273    ))
274}
275
276fn visible_rendering_discriminant(rendering: &Rendering) -> String {
277    format!(
278        "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
279        rendering.emph,
280        rendering.quote,
281        rendering.strong,
282        rendering.small_caps,
283        rendering.vertical_align,
284        rendering.prefix,
285        rendering.suffix,
286        rendering.wrap
287    )
288}
289
290fn event_date(reference: &Reference) -> Option<DateValue> {
291    match reference.extension() {
292        ClassExtension::Event(event) => event.date.clone(),
293        ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
294        ClassExtension::SerialComponent(component) => {
295            embedded_event_date(component.event.as_ref()?)
296        }
297        ClassExtension::AudioVisual(audio_visual) => {
298            embedded_event_date(audio_visual.event.as_ref()?)
299        }
300        _ => None,
301    }
302}
303
304fn embedded_event_date(relation: &WorkRelation) -> Option<DateValue> {
305    let WorkRelation::Embedded(reference) = relation else {
306        return None;
307    };
308    let ClassExtension::Event(event) = reference.extension() else {
309        return None;
310    };
311    event.date.clone()
312}
313
314/// Compute the delta for unspecified year ranges.
315fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
316    match u {
317        UnspecifiedYear::None => 0,
318        UnspecifiedYear::One => 9,
319        UnspecifiedYear::Two => 99,
320        UnspecifiedYear::Three => 999,
321        UnspecifiedYear::Four => 9999,
322    }
323}
324
325/// Format a year with era-aware rendering.
326fn format_display_year(
327    year: &Year,
328    date_terms: &citum_schema::locale::DateTerms,
329    era_labels: &citum_schema::options::dates::EraLabels,
330    _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
331    range_delimiter: &str,
332) -> String {
333    // Handle positive unspecified years: normalize 'u' to 'X'
334    if year.unspecified != UnspecifiedYear::None && year.value > 0 {
335        let mut s = year.value.to_string();
336        let unspec_count = match year.unspecified {
337            UnspecifiedYear::One => 1,
338            UnspecifiedYear::Two => 2,
339            UnspecifiedYear::Three => 3,
340            UnspecifiedYear::Four => 4,
341            _ => 0,
342        };
343        for _ in 0..unspec_count {
344            if let Some(last) = s.pop()
345                && last != '0'
346            {
347                s.push('X');
348            }
349        }
350        if s.len() < year.value.to_string().len() {
351            let diff = year.value.to_string().len() - s.len();
352            for _ in 0..diff {
353                s.push('X');
354            }
355        }
356        return s;
357    }
358
359    // Handle negative unspecified years: compute historical range
360    if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
361        let delta = unspecified_year_delta(&year.unspecified);
362        let astronomical_min = year.value - delta;
363        let astronomical_max = year.value;
364        let historical_end = 1 - astronomical_max;
365        let historical_start = 1 - astronomical_min;
366
367        let era_term = match era_labels {
368            citum_schema::options::dates::EraLabels::Default => {
369                date_terms.before_era.as_deref().unwrap_or("")
370            }
371            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
372            citum_schema::options::dates::EraLabels::BceCe => {
373                date_terms.bce.as_deref().unwrap_or("")
374            }
375        };
376
377        if era_term.is_empty() {
378            format!("{historical_start}{range_delimiter}{historical_end}")
379        } else {
380            format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
381        }
382    } else if year.value <= 0 {
383        // Fully specified negative year
384        let historical_year = 1 - year.value;
385        let era_term = match era_labels {
386            citum_schema::options::dates::EraLabels::Default => {
387                date_terms.before_era.as_deref().unwrap_or("")
388            }
389            citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
390            citum_schema::options::dates::EraLabels::BceCe => {
391                date_terms.bce.as_deref().unwrap_or("")
392            }
393        };
394
395        if era_term.is_empty() {
396            historical_year.to_string()
397        } else {
398            format!("{historical_year} {era_term}")
399        }
400    } else {
401        // Positive year
402        let era_term = match era_labels {
403            citum_schema::options::dates::EraLabels::Default => "",
404            citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
405            citum_schema::options::dates::EraLabels::BceCe => {
406                date_terms.ce.as_deref().unwrap_or("")
407            }
408        };
409
410        if era_term.is_empty() {
411            year.value.to_string()
412        } else {
413            format!("{} {}", year.value, era_term)
414        }
415    }
416}
417
418/// Legacy format_display_year for backwards compatibility.
419fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
420    if year.unspecified != UnspecifiedYear::None {
421        return year.to_string();
422    }
423
424    if year.value <= 0 {
425        let historical_year = 1 - year.value;
426        if let Some(term) = before_era.filter(|term| !term.is_empty()) {
427            format!("{historical_year} {term}")
428        } else {
429            historical_year.to_string()
430        }
431    } else {
432        year.value.to_string()
433    }
434}
435
436#[allow(dead_code, reason = "kept for backwards compatibility")]
437fn extract_display_year_legacy(date: &DateValue, before_era: Option<&str>) -> String {
438    match date.parse() {
439        RefDate::Edtf(edtf) => match edtf {
440            Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
441            Edtf::Interval(interval) => {
442                format_display_year_legacy(&interval.start.year, before_era)
443            }
444            Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
445                format_display_year_legacy(&date.year, before_era)
446            }
447        },
448        RefDate::Literal(_) => String::new(),
449    }
450}
451
452/// Formats a time with the specified format, optionally including seconds and timezone.
453///
454/// Converts 24-hour time to 12-hour format if specified, and appends localized
455/// AM/PM or timezone indicators as configured.
456fn format_time(
457    time: citum_edtf::Time,
458    format: &TimeFormat,
459    show_seconds: bool,
460    show_timezone: bool,
461    am_term: Option<&str>,
462    pm_term: Option<&str>,
463    utc_term: Option<&str>,
464) -> String {
465    let (display_hour, period) = match format {
466        TimeFormat::Hour12 => {
467            let (h, p) = if time.hour == 0 {
468                (12u32, am_term.unwrap_or("AM"))
469            } else if time.hour < 12 {
470                (time.hour, am_term.unwrap_or("AM"))
471            } else if time.hour == 12 {
472                (12u32, pm_term.unwrap_or("PM"))
473            } else {
474                (time.hour - 12, pm_term.unwrap_or("PM"))
475            };
476            (h, Some(p))
477        }
478        TimeFormat::Hour24 => (time.hour, None),
479    };
480
481    let time_str = if show_seconds {
482        format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
483    } else {
484        format!("{:02}:{:02}", display_hour, time.minute)
485    };
486
487    let with_period = match period {
488        Some(p) => format!("{time_str} {p}"),
489        None => time_str,
490    };
491
492    if show_timezone {
493        let tz_str = match time.timezone {
494            Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
495            Some(Timezone::Offset(mins)) => {
496                let sign = if mins >= 0 { '+' } else { '-' };
497                let abs = mins.unsigned_abs();
498                format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
499            }
500            None => String::new(),
501        };
502        if tz_str.is_empty() {
503            with_period
504        } else {
505            format!("{with_period} {tz_str}")
506        }
507    } else {
508        with_period
509    }
510}
511
512/// Format a single date or a date range (open or closed) according to the
513/// given form, delegating both endpoints of a range to
514/// [`format_single_date`] so locale patterns apply symmetrically.
515fn format_date_range(
516    date: &DateValue,
517    form: &DateForm,
518    locale: &citum_schema::locale::Locale,
519    date_config: Option<&citum_schema::options::dates::DateConfig>,
520) -> Option<String> {
521    let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
522
523    match date.parse() {
524        RefDate::Edtf(Edtf::Interval(interval)) => {
525            format_closed_range(date, &interval, form, locale, date_config, delimiter)
526        }
527        RefDate::Edtf(Edtf::IntervalFrom(_)) => {
528            // Open-ended range (e.g., "1990/..'): the accessors on the whole
529            // interval already resolve to the start point.
530            let start = format_single_date(date, form, locale, date_config)?;
531            if let Some(end_marker) = date_config
532                .and_then(|c| c.open_range_marker.as_deref())
533                .or(locale.dates.open_ended_term.as_deref())
534            {
535                Some(format!("{start}{delimiter}{end_marker}"))
536            } else {
537                Some(start)
538            }
539        }
540        // Non-range dates and open-ended-from-start ranges ("../2020") only
541        // have one known point, which the accessors already expose.
542        _ => format_single_date(date, form, locale, date_config),
543    }
544}
545
546/// Format a closed date range, collapsing the start point's year when both
547/// endpoints share a year and the form displays a month.
548fn format_closed_range(
549    date: &DateValue,
550    interval: &citum_edtf::Interval,
551    form: &DateForm,
552    locale: &citum_schema::locale::Locale,
553    date_config: Option<&citum_schema::options::dates::DateConfig>,
554    delimiter: &str,
555) -> Option<String> {
556    if let Some(rendered) =
557        format_chicago_year_range(interval, form, locale, date_config, delimiter)
558    {
559        return Some(rendered);
560    }
561
562    let same_year = interval.start.year.value == interval.end.year.value;
563    let both_have_month =
564        interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
565
566    if same_year
567        && (both_have_month || matches!(form, DateForm::Year))
568        && let Some(collapsed) = format_same_year_range(
569            &interval.start,
570            &interval.end,
571            form,
572            locale,
573            date_config,
574            delimiter,
575        )
576    {
577        return Some(collapsed);
578    }
579
580    let start = format_single_date(date, form, locale, date_config);
581    let end = format_single_date(
582        &DateValue::new(interval.end.to_string()),
583        form,
584        locale,
585        date_config,
586    );
587
588    match (start, end) {
589        (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
590        (Some(s), None) => Some(s),
591        (None, Some(e)) => Some(e),
592        (None, None) => None,
593    }
594}
595
596/// Format a closed year interval with Chicago's inclusive-number abbreviation.
597///
598/// EDTF represents BCE years astronomically, so this deliberately formats the
599/// displayed historical numbers rather than relying on ascending numeric input.
600fn format_chicago_year_range(
601    interval: &citum_edtf::Interval,
602    form: &DateForm,
603    locale: &citum_schema::locale::Locale,
604    date_config: Option<&citum_schema::options::dates::DateConfig>,
605    delimiter: &str,
606) -> Option<String> {
607    if !matches!(form, DateForm::Year)
608        || !matches!(
609            date_config.map(|config| &config.range_format),
610            Some(DateRangeFormat::Chicago)
611        )
612        || interval.start.year.unspecified != UnspecifiedYear::None
613        || interval.end.year.unspecified != UnspecifiedYear::None
614        || interval.start.month_or_season.is_some()
615        || interval.end.month_or_season.is_some()
616    {
617        return None;
618    }
619
620    let start_is_bce = interval.start.year.value <= 0;
621    let end_is_bce = interval.end.year.value <= 0;
622    if start_is_bce != end_is_bce || interval.end.year.value <= interval.start.year.value {
623        return None;
624    }
625
626    let start = display_year_number(interval.start.year.value)?;
627    let end = display_year_number(interval.end.year.value)?;
628    let abbreviated_end = crate::values::number::format_chicago_range_end(start, end);
629    let era = chicago_year_range_era_suffix(start_is_bce, locale, date_config);
630    Some(format!("{start}{delimiter}{abbreviated_end}{era}"))
631}
632
633fn display_year_number(year: i64) -> Option<u32> {
634    let historical_year = if year <= 0 {
635        1_i64.checked_sub(year)?
636    } else {
637        year
638    };
639    u32::try_from(historical_year).ok()
640}
641
642fn chicago_year_range_era_suffix(
643    is_bce: bool,
644    locale: &citum_schema::locale::Locale,
645    date_config: Option<&citum_schema::options::dates::DateConfig>,
646) -> String {
647    use citum_schema::options::dates::EraLabels;
648
649    let era_labels = date_config
650        .map(|config| &config.era_labels)
651        .unwrap_or(&EraLabels::Default);
652    let label = match (is_bce, era_labels) {
653        (true, EraLabels::Default) => locale.dates.before_era.as_deref(),
654        (true, EraLabels::BcAd) => locale.dates.bc.as_deref(),
655        (true, EraLabels::BceCe) => locale.dates.bce.as_deref(),
656        (false, EraLabels::Default) => None,
657        (false, EraLabels::BcAd) => locale.dates.ad.as_deref(),
658        (false, EraLabels::BceCe) => locale.dates.ce.as_deref(),
659    };
660    label
661        .filter(|value| !value.is_empty())
662        .map(|value| format!(" {value}"))
663        .unwrap_or_default()
664}
665
666/// Format a closed range whose endpoints share a year, suppressing the
667/// redundant year on one side (e.g. "May 14–June 2, 2023").
668///
669/// Locale interval patterns receive reduced endpoints and the common year.
670/// When a locale has no pattern, the pre-existing English layouts remain the
671/// fallback for forms that already supported same-year suppression.
672fn format_same_year_range(
673    start: &citum_edtf::Date,
674    end: &citum_edtf::Date,
675    form: &DateForm,
676    locale: &citum_schema::locale::Locale,
677    date_config: Option<&citum_schema::options::dates::DateConfig>,
678    delimiter: &str,
679) -> Option<String> {
680    let start_date = DateValue::new(start.to_string());
681    let end_date = DateValue::new(end.to_string());
682    let start_fragment = format_same_year_fragment(&start_date, form, locale, date_config)?;
683    let end_fragment = format_same_year_fragment(&end_date, form, locale, date_config)?;
684    let shared_year = date_form_displays_year(form)
685        .then(|| format_single_date(&start_date, &DateForm::Year, locale, date_config))
686        .flatten();
687
688    if let Some(pattern_id) = date_range_pattern_id(form)
689        && let Some(rendered) = locale.resolve_date_range_pattern(
690            pattern_id,
691            &start_fragment,
692            &end_fragment,
693            shared_year.as_deref(),
694        )
695    {
696        return Some(rendered);
697    }
698
699    match form {
700        DateForm::Full => {
701            let end_full = format_single_date(&end_date, &DateForm::Full, locale, date_config)?;
702            Some(format!("{start_fragment}{delimiter}{end_full}"))
703        }
704        DateForm::YearMonth => {
705            let end_full =
706                format_single_date(&end_date, &DateForm::YearMonth, locale, date_config)?;
707            Some(format!("{start_fragment}{delimiter}{end_full}"))
708        }
709        DateForm::YearMonthDay => {
710            let start_full =
711                format_single_date(&start_date, &DateForm::YearMonthDay, locale, date_config)?;
712            Some(format!("{start_full}{delimiter}{end_fragment}"))
713        }
714        _ => None,
715    }
716}
717
718fn date_range_pattern_id(form: &DateForm) -> Option<&'static str> {
719    match form {
720        DateForm::Year => Some("pattern.date-range-year"),
721        DateForm::Month => Some("pattern.date-range-month"),
722        DateForm::MonthDay => Some("pattern.date-range-month-day"),
723        DateForm::YearMonth => Some("pattern.date-range-year-month"),
724        DateForm::Full => Some("pattern.date-range-full"),
725        DateForm::YearMonthDay => Some("pattern.date-range-year-month-day"),
726        DateForm::DayMonthAbbrYear => Some("pattern.date-range-day-month-abbr-year"),
727        DateForm::MonthAbbrDayYear => Some("pattern.date-range-month-abbr-day-year"),
728        _ => None,
729    }
730}
731
732fn format_same_year_fragment(
733    date: &DateValue,
734    form: &DateForm,
735    locale: &citum_schema::locale::Locale,
736    date_config: Option<&citum_schema::options::dates::DateConfig>,
737) -> Option<String> {
738    match form {
739        DateForm::Year => format_single_date(date, &DateForm::Year, locale, date_config),
740        DateForm::Month | DateForm::YearMonth => {
741            format_single_date(date, &DateForm::Month, locale, date_config)
742        }
743        DateForm::Full | DateForm::MonthDay | DateForm::YearMonthDay => {
744            format_single_date(date, &DateForm::MonthDay, locale, date_config)
745        }
746        DateForm::DayMonthAbbrYear | DateForm::MonthAbbrDayYear => {
747            format_abbreviated_month_day_fragment(date, form, locale, date_config)
748        }
749        _ => None,
750    }
751}
752
753fn format_abbreviated_month_day_fragment(
754    date: &DateValue,
755    form: &DateForm,
756    locale: &citum_schema::locale::Locale,
757    date_config: Option<&citum_schema::options::dates::DateConfig>,
758) -> Option<String> {
759    let numeric_months = date_config
760        .is_some_and(|config| config.month == citum_schema::options::MonthFormat::Numeric);
761    if numeric_months && let Some(month) = extract_month_numeric(date) {
762        return Some(match date.day() {
763            Some(day) => format!("{month}-{day:02}"),
764            None => month,
765        });
766    }
767
768    let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
769    if month.is_empty() {
770        return None;
771    }
772    let zero_pad_day = date_config.is_some_and(|c| c.day_zero_pad);
773    match (form, date.day()) {
774        (DateForm::DayMonthAbbrYear, Some(day)) => {
775            Some(format!("{} {month}", format_day(day, zero_pad_day)))
776        }
777        (DateForm::MonthAbbrDayYear, Some(day)) => {
778            Some(format!("{month} {}", format_day(day, zero_pad_day)))
779        }
780        (_, None) => Some(month),
781        _ => None,
782    }
783}
784
785/// Append a date's opaque `note` (e.g. a source-calendar annotation), wrapped
786/// per `DateConfig.note_wrap`, directly after the complete formatted date —
787/// after any inlined year-suffix, before the component's own outer
788/// prefix/suffix/wrap. A no-op when the style has no `note-wrap` configured
789/// for this scope, or the date carries no note. The caller additionally
790/// skips this function entirely when the component sets
791/// `TemplateDate::suppress_note`. See
792/// `docs/specs/CALENDAR_DATE_ANNOTATIONS.md`.
793fn append_note<F: crate::render::format::OutputFormat<Output = String>>(
794    fmt: &F,
795    formatted: String,
796    date: &DateValue,
797    date_config: Option<&citum_schema::options::dates::DateConfig>,
798    reference: &Reference,
799    options: &RenderOptions<'_>,
800) -> String {
801    let Some(note) = date.note.as_deref().filter(|n| !n.is_empty()) else {
802        return formatted;
803    };
804    let Some(wrap) = date_config.and_then(|c| c.note_wrap.as_ref()) else {
805        return formatted;
806    };
807
808    let content = fmt.text(note);
809    let content = fmt.inner_affix(
810        wrap.inner_prefix.as_deref().unwrap_or_default(),
811        content,
812        wrap.inner_suffix.as_deref().unwrap_or_default(),
813    );
814    let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
815    let item_language = crate::values::effective_item_language(reference);
816    let (script, realization) = crate::values::punctuation_realization_context(
817        item_language.as_deref(),
818        options.config.multilingual.as_ref(),
819        options.locale.punctuation_realization.as_ref(),
820    );
821    let wrapped = fmt.wrap_punctuation(
822        &wrap.punctuation,
823        content,
824        &marks,
825        script,
826        realization.as_deref(),
827    );
828    format!("{formatted}{wrapped}")
829}
830
831/// Apply uncertainty and approximation markers to formatted date.
832fn apply_date_markers(
833    value: String,
834    date: &DateValue,
835    date_config: Option<&citum_schema::options::dates::DateConfig>,
836) -> String {
837    let mut result = value;
838    if date.is_approximate()
839        && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
840    {
841        let suffix = date_config
842            .and_then(|c| c.approximation_marker_suffix.as_deref())
843            .unwrap_or("");
844        result = format!("{marker}{result}{suffix}");
845    }
846    if date.is_uncertain()
847        && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
848    {
849        let prefix = date_config
850            .and_then(|c| c.uncertainty_marker_prefix.as_deref())
851            .unwrap_or("");
852        result = format!("{prefix}{result}{marker}");
853    }
854    result
855}
856
857/// Compute the disambiguation suffix for year-based citations.
858fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
859    date: &DateValue,
860    form: &DateForm,
861    hints: &ProcHints,
862    options: &RenderOptions<'_>,
863    fmt: &F,
864) -> Option<String> {
865    if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
866        compute_disamb_suffix_label(hints, options, fmt)
867    } else {
868        None
869    }
870}
871
872fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
873    hints: &ProcHints,
874    options: &RenderOptions<'_>,
875    fmt: &F,
876) -> Option<String> {
877    // Check if year suffix is enabled, resolving the processing default
878    // centrally so an unset `processing` matches the rest of the engine.
879    let use_suffix = options
880        .config
881        .effective_processing()
882        .config()
883        .disambiguate
884        .as_ref()
885        .is_some_and(|d| d.year_suffix);
886
887    if hints.disamb_condition && use_suffix {
888        int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
889    } else {
890        None
891    }
892}
893
894fn date_form_displays_year(form: &DateForm) -> bool {
895    !matches!(form, DateForm::MonthDay)
896}
897
898fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
899    let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
900        date_config.no_date_year_suffix_delimiter.as_str()
901    });
902    value.push_str(delimiter);
903    value.push_str(suffix);
904}
905
906fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
907    if year.is_empty() || suffix.is_empty() {
908        return formatted.to_string();
909    }
910
911    let year_index = match form {
912        DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
913        DateForm::YearMonth
914        | DateForm::Full
915        | DateForm::DayMonthAbbrYear
916        | DateForm::MonthAbbrDayYear => formatted.rfind(year),
917        DateForm::MonthDay => None,
918        _ => None,
919    };
920
921    let Some(index) = year_index else {
922        return format!("{formatted}{suffix}");
923    };
924
925    let year_end = index + year.len();
926    #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
927    let result = format!(
928        "{}{}{}{}",
929        &formatted[..index],
930        year,
931        suffix,
932        &formatted[year_end..]
933    );
934    result
935}
936
937/// Format a single date (non-range) according to the given form.
938#[allow(
939    clippy::too_many_lines,
940    reason = "date formatting handles 6 form variants"
941)]
942fn format_single_date(
943    date: &DateValue,
944    form: &DateForm,
945    locale: &citum_schema::locale::Locale,
946    date_config: Option<&citum_schema::options::dates::DateConfig>,
947) -> Option<String> {
948    let default_era = citum_schema::options::dates::EraLabels::Default;
949    let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
950    let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
951    let neg_unspecified = date_config
952        .map(|c| &c.negative_unspecified_years)
953        .unwrap_or(&default_neg_unspec);
954    let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
955    // `month: numeric` renders month-bearing forms as zero-padded numerals
956    // joined with hyphens (GB/T 7714, ISO 690). Dates without a real calendar
957    // month (seasons, literals) fall back to the textual path.
958    let numeric_months =
959        date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
960    // Independent of `numeric_months`: those numeral paths already
961    // zero-pad the day unconditionally as part of their fixed format.
962    let zero_pad_day = date_config.is_some_and(|c| c.day_zero_pad);
963
964    let extract_year = |d: &DateValue| -> String {
965        match d.parse() {
966            RefDate::Edtf(edtf) => match edtf {
967                Edtf::Date(dt) => format_display_year(
968                    &dt.year,
969                    &locale.dates,
970                    era_labels,
971                    neg_unspecified,
972                    range_delimiter,
973                ),
974                Edtf::Interval(interval) => format_display_year(
975                    &interval.start.year,
976                    &locale.dates,
977                    era_labels,
978                    neg_unspecified,
979                    range_delimiter,
980                ),
981                Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
982                    &dt.year,
983                    &locale.dates,
984                    era_labels,
985                    neg_unspecified,
986                    range_delimiter,
987                ),
988            },
989            RefDate::Literal(_) => String::new(),
990        }
991    };
992
993    match form {
994        DateForm::Year => {
995            let year = extract_year(date);
996            if year.is_empty() { None } else { Some(year) }
997        }
998        DateForm::YearMonth => {
999            let year = extract_year(date);
1000            if year.is_empty() {
1001                return None;
1002            }
1003            if numeric_months && let Some(month) = extract_month_numeric(date) {
1004                return Some(format!("{year}-{month}"));
1005            }
1006            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
1007            let month_opt = (!month.is_empty()).then_some(month.as_str());
1008            if let Some(rendered) = locale.resolve_date_pattern(
1009                "pattern.date-year-month",
1010                Some(&year),
1011                month_opt,
1012                None,
1013                zero_pad_day,
1014            ) {
1015                return Some(rendered);
1016            }
1017            if month.is_empty() {
1018                Some(year)
1019            } else {
1020                Some(format!("{month} {year}"))
1021            }
1022        }
1023        DateForm::Month => {
1024            if numeric_months && let Some(month) = extract_month_numeric(date) {
1025                return Some(month);
1026            }
1027            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
1028            if month.is_empty() { None } else { Some(month) }
1029        }
1030        DateForm::MonthDay => {
1031            if numeric_months && let Some(month) = extract_month_numeric(date) {
1032                return Some(match date.day() {
1033                    Some(d) => format!("{month}-{d:02}"),
1034                    None => month,
1035                });
1036            }
1037            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
1038            if month.is_empty() {
1039                return None;
1040            }
1041            let day = date.day();
1042            if let Some(rendered) = locale.resolve_date_pattern(
1043                "pattern.date-month-day",
1044                None,
1045                Some(&month),
1046                day,
1047                zero_pad_day,
1048            ) {
1049                return Some(rendered);
1050            }
1051            match day {
1052                Some(d) => Some(format!("{month} {}", format_day(d, zero_pad_day))),
1053                None => Some(month),
1054            }
1055        }
1056        DateForm::Full => {
1057            let year = extract_year(date);
1058            if year.is_empty() {
1059                return None;
1060            }
1061            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
1062            let day = date.day();
1063            let numeric_base = if numeric_months {
1064                extract_month_numeric(date).map(|month| match day {
1065                    Some(d) => format!("{year}-{month}-{d:02}"),
1066                    None => format!("{year}-{month}"),
1067                })
1068            } else {
1069                None
1070            };
1071            let base = numeric_base
1072                .or_else(|| {
1073                    locale.resolve_date_pattern(
1074                        "pattern.date-full",
1075                        Some(&year),
1076                        (!month.is_empty()).then_some(month.as_str()),
1077                        day,
1078                        zero_pad_day,
1079                    )
1080                })
1081                .unwrap_or_else(|| match (month.is_empty(), day) {
1082                    (true, _) => year.clone(),
1083                    (false, None) => format!("{month} {year}"),
1084                    (false, Some(d)) => {
1085                        format!("{month} {}, {year}", format_day(d, zero_pad_day))
1086                    }
1087                });
1088            // Append time component if configured and present
1089            if let (Some(time_fmt), Some(time)) = (
1090                date_config.and_then(|c| c.time_format.as_ref()),
1091                date.time(),
1092            ) {
1093                let show_secs = date_config.is_some_and(|c| c.show_seconds);
1094                let show_tz = date_config.is_some_and(|c| c.show_timezone);
1095                let time_str = format_time(
1096                    time,
1097                    time_fmt,
1098                    show_secs,
1099                    show_tz,
1100                    locale.dates.am.as_deref(),
1101                    locale.dates.pm.as_deref(),
1102                    locale.dates.timezone_utc.as_deref(),
1103                );
1104                Some(format!("{base}, {time_str}"))
1105            } else {
1106                Some(base)
1107            }
1108        }
1109        DateForm::YearMonthDay => {
1110            let year = extract_year(date);
1111            if year.is_empty() {
1112                return None;
1113            }
1114            if numeric_months && let Some(month) = extract_month_numeric(date) {
1115                return Some(match date.day() {
1116                    Some(d) => format!("{year}-{month}-{d:02}"),
1117                    None => format!("{year}-{month}"),
1118                });
1119            }
1120            let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
1121            let day = date.day();
1122            let month_opt = (!month.is_empty()).then_some(month.as_str());
1123            if let Some(rendered) = locale.resolve_date_pattern(
1124                "pattern.date-year-month-day",
1125                Some(&year),
1126                month_opt,
1127                day,
1128                zero_pad_day,
1129            ) {
1130                return Some(rendered);
1131            }
1132            match (month.is_empty(), day) {
1133                (true, _) => Some(year),
1134                (false, None) => Some(format!("{year}, {month}")),
1135                (false, Some(d)) => {
1136                    Some(format!("{year}, {month} {}", format_day(d, zero_pad_day)))
1137                }
1138            }
1139        }
1140        DateForm::DayMonthAbbrYear => {
1141            let year = extract_year(date);
1142            if year.is_empty() {
1143                return None;
1144            }
1145            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
1146            let day = date.day();
1147            let month_opt = (!month.is_empty()).then_some(month.as_str());
1148            if let Some(rendered) = locale.resolve_date_pattern(
1149                "pattern.date-day-month-abbr-year",
1150                Some(&year),
1151                month_opt,
1152                day,
1153                zero_pad_day,
1154            ) {
1155                return Some(rendered);
1156            }
1157            match (month.is_empty(), day) {
1158                (true, _) => Some(year),
1159                (false, None) => Some(format!("{month} {year}")),
1160                (false, Some(d)) => Some(format!("{} {month} {year}", format_day(d, zero_pad_day))),
1161            }
1162        }
1163        DateForm::MonthAbbrDayYear => {
1164            let year = extract_year(date);
1165            if year.is_empty() {
1166                return None;
1167            }
1168            let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
1169            let day = date.day();
1170            let month_opt = (!month.is_empty()).then_some(month.as_str());
1171            if let Some(rendered) = locale.resolve_date_pattern(
1172                "pattern.date-month-abbr-day-year",
1173                Some(&year),
1174                month_opt,
1175                day,
1176                zero_pad_day,
1177            ) {
1178                return Some(rendered);
1179            }
1180            match (month.is_empty(), day) {
1181                (true, _) => Some(year),
1182                (false, None) => Some(format!("{month} {year}")),
1183                (false, Some(d)) => {
1184                    Some(format!("{month} {}, {year}", format_day(d, zero_pad_day)))
1185                }
1186            }
1187        }
1188        _ => Some(extract_year(date)),
1189    }
1190}
1191
1192/// Render a resolved fallback component through the central component renderer.
1193///
1194/// `component.values()` only resolves the raw fallback string — it does not
1195/// go through the generic per-component dispatch that normally applies a
1196/// component's rendering. Routing the resolved value back through the central
1197/// renderer preserves the ordinary component contract, including suppression,
1198/// emphasis, quotes, strong, small caps, vertical alignment, wrapping, and
1199/// affixes. Shared by date and terminal contributor fallback chains.
1200pub(crate) fn render_fallback_component<F: crate::render::format::OutputFormat<Output = String>>(
1201    fmt: &F,
1202    component: &TemplateComponent,
1203    values: ProcValues<String>,
1204    reference: &Reference,
1205    options: &RenderOptions<'_>,
1206) -> F::Output {
1207    let proc_item = crate::render::ProcTemplateComponent {
1208        template_component: component.clone(),
1209        template_index: options.current_template_index,
1210        value: values.value,
1211        prefix: values.prefix,
1212        suffix: values.suffix,
1213        url: values.url,
1214        ref_type: Some(reference.ref_type()),
1215        config: Some(options.config.clone()),
1216        bibliography_config: options.bibliography_config.clone(),
1217        item_language: crate::values::effective_component_language(reference, component),
1218        quote_marks: crate::render::format::QuoteMarks::from(options.locale),
1219        sentence_initial: false,
1220        pre_formatted: values.pre_formatted,
1221    };
1222    crate::render::render_component_with_format_and_renderer::<F>(
1223        &proc_item,
1224        fmt,
1225        options.show_semantics,
1226    )
1227}
1228
1229/// Render a date component's fallback chain when its own date variable is
1230/// missing or empty.
1231///
1232/// Tries each fallback candidate in order and returns the first that
1233/// renders. A `message:` candidate (the terminal "no data available" case,
1234/// e.g. GB/T 7714's `无日期`/`n.d.` term via `message: term.no-date`) and a
1235/// `date:` candidate (e.g. GB/T's access-year fallback, rendering
1236/// `Anon,[2020a]`) both need the same year-suffix-append convention the
1237/// implicit (no explicit `fallback:`) no-date path uses, so every path
1238/// disambiguates identically. Without this, a style whose date components
1239/// always carry an explicit `fallback:` chain (as GB/T author-date's do)
1240/// never reaches the implicit branch and never gets a suffix at all. See
1241/// csl26-6eak, csl26-huuz.
1242///
1243/// For a `date:` candidate, the letter must land inside that candidate's own
1244/// wrap (e.g. brackets) — so it is inlined into the raw formatted text
1245/// *before* `render_fallback_component` applies the wrap, not
1246/// appended to the already-wrapped output the way the `message:` case is.
1247/// A candidate that is itself `date: issued` cannot reach this function with
1248/// a resolvable value: `disamb_eligible` above requires the *outer*
1249/// component's own `.date == Issued`, and an `issued`-typed fallback
1250/// candidate resolves the identical reference field — which, precisely
1251/// because we're in the missing-date branch at all, is already known empty.
1252/// `inline_disamb_suffix` no-ops whenever `year` is empty, so no double
1253/// suffix can occur; investigated for PR review, no fix needed.
1254///
1255/// If nothing in the chain renders anything (an explicit `fallback: []`, or
1256/// every candidate resolves empty), the date slot itself contributes no
1257/// text, but the collision group this reference belongs to may still need
1258/// its year-suffix letter rendered standalone (upstream's bare
1259/// `<text variable="year-suffix"/>` after an empty date; oracle:
1260/// "Anon,b."). Without this, an entry whose date slot is entirely empty
1261/// silently loses its disambiguator rather than getting the wrong one.
1262fn render_date_fallback_chain<F: crate::render::format::OutputFormat<Output = String>>(
1263    date_component: &TemplateDate,
1264    fallbacks: &[TemplateComponent],
1265    reference: &Reference,
1266    hints: &ProcHints,
1267    options: &RenderOptions<'_>,
1268    fmt: &F,
1269) -> Option<ProcValues<F::Output>> {
1270    let disamb_eligible = matches!(date_component.date, TemplateDateVar::Issued)
1271        && date_component.suppress_disamb_suffix != Some(true);
1272
1273    for component in fallbacks {
1274        let Some(mut values) = component.values::<F>(reference, hints, options) else {
1275            continue;
1276        };
1277        let substituted_key = values.substituted_key.clone();
1278        let suffix_label = disamb_eligible
1279            .then(|| compute_disamb_suffix_label(hints, options, fmt))
1280            .flatten();
1281
1282        let inlined = match (component, suffix_label.as_deref()) {
1283            (TemplateComponent::Date(inner), Some(suffix)) => {
1284                let year = resolve_date_variable(&inner.date, reference)
1285                    .map(|d| d.year())
1286                    .unwrap_or_default();
1287                values.value = inline_disamb_suffix(&values.value, &inner.form, &year, suffix);
1288                true
1289            }
1290            _ => false,
1291        };
1292
1293        let mut output = render_fallback_component(fmt, component, values, reference, options);
1294        if output.trim().is_empty() {
1295            continue;
1296        }
1297        if !inlined
1298            && matches!(component, TemplateComponent::Message(_))
1299            && let Some(suffix) = suffix_label.as_deref()
1300        {
1301            append_no_date_disamb_suffix(&mut output, suffix, options);
1302        }
1303        return Some(ProcValues {
1304            value: output,
1305            prefix: None,
1306            suffix: None,
1307            url: None,
1308            substituted_key,
1309            pre_formatted: true,
1310        });
1311    }
1312
1313    disamb_eligible
1314        .then(|| compute_disamb_suffix_label(hints, options, fmt))
1315        .flatten()
1316        .map(|suffix| ProcValues {
1317            value: suffix,
1318            prefix: None,
1319            suffix: None,
1320            url: None,
1321            substituted_key: None,
1322            pre_formatted: true,
1323        })
1324}
1325
1326impl ComponentValues for TemplateDate {
1327    fn values<F: crate::render::format::OutputFormat<Output = String>>(
1328        &self,
1329        reference: &Reference,
1330        hints: &ProcHints,
1331        options: &RenderOptions<'_>,
1332    ) -> Option<ProcValues<F::Output>> {
1333        let fmt = F::default();
1334        let date_opt: Option<DateValue> = resolve_date_variable(&self.date, reference);
1335
1336        let Some(date) = date_opt.filter(|d| !d.is_empty()) else {
1337            // Handle fallback if date is missing
1338            if let Some(fallbacks) = &self.fallback {
1339                return render_date_fallback_chain::<F>(
1340                    self, fallbacks, reference, hints, options, &fmt,
1341                );
1342            }
1343            // For issued dates, substitute the locale's "no-date" term (e.g. "n.d.")
1344            if matches!(self.date, TemplateDateVar::Issued)
1345                && let Some(mut nd) = options.locale.resolved_general_term(
1346                    &GeneralTerm::NoDate,
1347                    &TermForm::Short,
1348                    None,
1349                )
1350            {
1351                if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
1352                    append_no_date_disamb_suffix(&mut nd, &suffix, options);
1353                }
1354                return Some(ProcValues {
1355                    value: nd,
1356                    prefix: None,
1357                    suffix: None,
1358                    url: None,
1359                    substituted_key: None,
1360                    pre_formatted: false,
1361                });
1362            }
1363            // No fallback and no term to substitute (e.g. the locale defines
1364            // no "no date" term at all) — the date position renders nothing,
1365            // but this reference's collision group may still need its
1366            // year-suffix letter rendered standalone rather than silently
1367            // dropped.
1368            let disamb_eligible = matches!(self.date, TemplateDateVar::Issued)
1369                && self.suppress_disamb_suffix != Some(true);
1370            return disamb_eligible
1371                .then(|| compute_disamb_suffix_label(hints, options, &fmt))
1372                .flatten()
1373                .map(|suffix| ProcValues {
1374                    value: suffix,
1375                    prefix: None,
1376                    suffix: None,
1377                    url: None,
1378                    substituted_key: None,
1379                    pre_formatted: true,
1380                });
1381        };
1382
1383        let locale = options.locale;
1384        let date_config = options.config.dates.as_ref();
1385        let effective_form = self.form.clone();
1386
1387        let formatted = format_date_range(&date, &effective_form, locale, date_config);
1388
1389        // Apply uncertainty and approximation markers
1390        let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
1391
1392        // Handle disambiguation suffix (a, b, c...).
1393        // Year-suffix is keyed off the issued year only; suppress it for other date
1394        // components (e.g. original-published) so a reprint template renders
1395        // `(1926/1967a)` rather than `(1926a/1967a)`.
1396        let disamb_suffix = (matches!(self.date, TemplateDateVar::Issued)
1397            && self.suppress_disamb_suffix != Some(true))
1398        .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
1399        .flatten();
1400
1401        formatted.map(|value| {
1402            let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
1403                (
1404                    inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
1405                    None,
1406                )
1407            } else {
1408                (value, None)
1409            };
1410
1411            let value = if self.suppress_note == Some(true) {
1412                value
1413            } else {
1414                append_note(&fmt, value, &date, date_config, reference, options)
1415            };
1416
1417            ProcValues {
1418                value,
1419                prefix: None,
1420                suffix,
1421                url: crate::values::resolve_effective_url(
1422                    self.links.as_ref(),
1423                    options.config.links.as_ref(),
1424                    reference,
1425                    citum_schema::options::LinkAnchor::Component,
1426                ),
1427                substituted_key: None,
1428                pre_formatted: false,
1429            }
1430        })
1431    }
1432}
1433
1434/// Convert a 1-based index into an alphabetic suffix (`1 -> "a"`, `27 -> "aa"`).
1435#[must_use]
1436pub fn int_to_letter(n: u32) -> Option<String> {
1437    if n == 0 {
1438        return None;
1439    }
1440
1441    let mut result = String::new();
1442    let mut num = n - 1;
1443
1444    loop {
1445        result.push((b'a' + (num % 26) as u8) as char);
1446        if num < 26 {
1447            break;
1448        }
1449        num = num / 26 - 1;
1450    }
1451
1452    Some(result.chars().rev().collect())
1453}
1454
1455#[cfg(test)]
1456#[allow(
1457    clippy::unwrap_used,
1458    clippy::expect_used,
1459    clippy::panic,
1460    clippy::indexing_slicing,
1461    clippy::todo,
1462    clippy::unimplemented,
1463    clippy::unreachable,
1464    clippy::get_unwrap,
1465    reason = "Panicking is acceptable and often desired in tests."
1466)]
1467mod tests {
1468    use super::*;
1469
1470    #[test]
1471    fn unmatched_date_substitute_policy_keeps_the_template_borrowed() {
1472        let config: Config = serde_yaml::from_str(
1473            r#"
1474date-substitute:
1475  book: []
1476"#,
1477        )
1478        .expect("date-substitute config should parse");
1479        let template = vec![TemplateComponent::Date(TemplateDate {
1480            date: TemplateDateVar::Issued,
1481            form: DateForm::Year,
1482            ..TemplateDate::default()
1483        })];
1484
1485        let resolved = materialize_identity_date_substitute(&template, &config, "report");
1486
1487        assert!(matches!(resolved, Cow::Borrowed(_)));
1488    }
1489
1490    #[test]
1491    fn matched_date_substitute_policy_materializes_an_owned_template() {
1492        let config: Config = serde_yaml::from_str(
1493            r#"
1494date-substitute:
1495  book: []
1496"#,
1497        )
1498        .expect("date-substitute config should parse");
1499        let template = vec![TemplateComponent::Date(TemplateDate {
1500            date: TemplateDateVar::Issued,
1501            form: DateForm::Year,
1502            ..TemplateDate::default()
1503        })];
1504
1505        let resolved = materialize_identity_date_substitute(&template, &config, "book");
1506
1507        assert!(matches!(resolved, Cow::Owned(_)));
1508    }
1509
1510    #[test]
1511    fn test_int_to_letter() {
1512        // Test basic single-letter conversions (1-26)
1513        assert_eq!(int_to_letter(1), Some("a".to_string()));
1514        assert_eq!(int_to_letter(2), Some("b".to_string()));
1515        assert_eq!(int_to_letter(26), Some("z".to_string()));
1516
1517        // Test double-letter conversions (27+)
1518        assert_eq!(int_to_letter(27), Some("aa".to_string()));
1519        assert_eq!(int_to_letter(52), Some("az".to_string()));
1520        assert_eq!(int_to_letter(53), Some("ba".to_string()));
1521
1522        // Test zero returns None
1523        assert_eq!(int_to_letter(0), None);
1524    }
1525
1526    #[test]
1527    fn test_apply_date_markers_uncertainty_suffix_only_by_default() {
1528        let date = DateValue::new("1750?");
1529        let config = citum_schema::options::dates::DateConfig::default();
1530        let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1531        assert_eq!(result, "1750?");
1532    }
1533
1534    #[test]
1535    fn test_apply_date_markers_uncertainty_paired_brackets() {
1536        let date = DateValue::new("1750?");
1537        let config = citum_schema::options::dates::DateConfig {
1538            uncertainty_marker: Some("?]".to_string()),
1539            uncertainty_marker_prefix: Some("[".to_string()),
1540            ..citum_schema::options::dates::DateConfig::default()
1541        };
1542        let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1543        assert_eq!(result, "[1750?]");
1544    }
1545}
1546
1547#[cfg(test)]
1548#[allow(
1549    clippy::unwrap_used,
1550    clippy::expect_used,
1551    clippy::panic,
1552    clippy::indexing_slicing,
1553    clippy::todo,
1554    clippy::unimplemented,
1555    clippy::unreachable,
1556    clippy::get_unwrap,
1557    reason = "Panicking is acceptable and often desired in tests."
1558)]
1559mod time_tests {
1560    use super::*;
1561    use citum_edtf::{Time, Timezone};
1562
1563    #[test]
1564    fn test_format_time_12h_utc() {
1565        let time = Time {
1566            hour: 23,
1567            minute: 20,
1568            second: 30,
1569            timezone: Some(Timezone::Utc),
1570        };
1571        let result = format_time(
1572            time,
1573            &TimeFormat::Hour12,
1574            false,
1575            true,
1576            Some("AM"),
1577            Some("PM"),
1578            Some("UTC"),
1579        );
1580        assert_eq!(result, "11:20 PM UTC");
1581    }
1582
1583    #[test]
1584    fn test_format_time_24h_utc() {
1585        let time = Time {
1586            hour: 23,
1587            minute: 20,
1588            second: 30,
1589            timezone: Some(Timezone::Utc),
1590        };
1591        let result = format_time(
1592            time,
1593            &TimeFormat::Hour24,
1594            false,
1595            true,
1596            None,
1597            None,
1598            Some("UTC"),
1599        );
1600        assert_eq!(result, "23:20 UTC");
1601    }
1602
1603    #[test]
1604    fn test_format_time_with_offset() {
1605        let time = Time {
1606            hour: 10,
1607            minute: 10,
1608            second: 10,
1609            timezone: Some(Timezone::Offset(330)),
1610        };
1611        let result = format_time(
1612            time,
1613            &TimeFormat::Hour24,
1614            false,
1615            true,
1616            None,
1617            None,
1618            Some("UTC"),
1619        );
1620        assert_eq!(result, "10:10 +05:30");
1621    }
1622
1623    #[test]
1624    fn test_format_time_no_timezone() {
1625        let time = Time {
1626            hour: 14,
1627            minute: 30,
1628            second: 0,
1629            timezone: None,
1630        };
1631        let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
1632        assert_eq!(result, "14:30");
1633    }
1634}
1635
1636#[cfg(test)]
1637#[allow(
1638    clippy::unwrap_used,
1639    clippy::expect_used,
1640    clippy::panic,
1641    clippy::indexing_slicing,
1642    clippy::todo,
1643    clippy::unimplemented,
1644    clippy::unreachable,
1645    clippy::get_unwrap,
1646    reason = "Panicking is acceptable and often desired in tests."
1647)]
1648mod era_tests {
1649    use super::*;
1650    use citum_edtf::{UnspecifiedYear, Year};
1651    use citum_schema::locale::{DateTerms, Locale};
1652    use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
1653
1654    fn en_terms() -> DateTerms {
1655        Locale::en_us().dates
1656    }
1657
1658    #[test]
1659    fn positive_year_default_no_suffix() {
1660        let year = Year {
1661            value: 54,
1662            unspecified: UnspecifiedYear::None,
1663        };
1664        let result = format_display_year(
1665            &year,
1666            &en_terms(),
1667            &EraLabels::Default,
1668            &NegativeUnspecifiedYears::Range,
1669            "–",
1670        );
1671        assert_eq!(result, "54");
1672    }
1673
1674    #[test]
1675    fn positive_year_bc_ad() {
1676        let year = Year {
1677            value: 54,
1678            unspecified: UnspecifiedYear::None,
1679        };
1680        let result = format_display_year(
1681            &year,
1682            &en_terms(),
1683            &EraLabels::BcAd,
1684            &NegativeUnspecifiedYears::Range,
1685            "–",
1686        );
1687        assert_eq!(result, "54 AD");
1688    }
1689
1690    #[test]
1691    fn positive_year_bce_ce() {
1692        let year = Year {
1693            value: 54,
1694            unspecified: UnspecifiedYear::None,
1695        };
1696        let result = format_display_year(
1697            &year,
1698            &en_terms(),
1699            &EraLabels::BceCe,
1700            &NegativeUnspecifiedYears::Range,
1701            "–",
1702        );
1703        assert_eq!(result, "54 CE");
1704    }
1705
1706    #[test]
1707    fn negative_year_default() {
1708        let year = Year {
1709            value: -43,
1710            unspecified: UnspecifiedYear::None,
1711        };
1712        let result = format_display_year(
1713            &year,
1714            &en_terms(),
1715            &EraLabels::Default,
1716            &NegativeUnspecifiedYears::Range,
1717            "–",
1718        );
1719        assert_eq!(result, "44 BC");
1720    }
1721
1722    #[test]
1723    fn negative_year_bc_ad() {
1724        let year = Year {
1725            value: -43,
1726            unspecified: UnspecifiedYear::None,
1727        };
1728        let result = format_display_year(
1729            &year,
1730            &en_terms(),
1731            &EraLabels::BcAd,
1732            &NegativeUnspecifiedYears::Range,
1733            "–",
1734        );
1735        assert_eq!(result, "44 BC");
1736    }
1737
1738    #[test]
1739    fn negative_year_bce_ce() {
1740        let year = Year {
1741            value: -43,
1742            unspecified: UnspecifiedYear::None,
1743        };
1744        let result = format_display_year(
1745            &year,
1746            &en_terms(),
1747            &EraLabels::BceCe,
1748            &NegativeUnspecifiedYears::Range,
1749            "–",
1750        );
1751        assert_eq!(result, "44 BCE");
1752    }
1753
1754    #[test]
1755    fn positive_unspecified_ones() {
1756        let year = Year {
1757            value: 1990,
1758            unspecified: UnspecifiedYear::One,
1759        };
1760        let result = format_display_year(
1761            &year,
1762            &en_terms(),
1763            &EraLabels::Default,
1764            &NegativeUnspecifiedYears::Range,
1765            "–",
1766        );
1767        assert_eq!(result, "199X");
1768    }
1769
1770    #[test]
1771    fn positive_unspecified_two() {
1772        let year = Year {
1773            value: 1900,
1774            unspecified: UnspecifiedYear::Two,
1775        };
1776        let result = format_display_year(
1777            &year,
1778            &en_terms(),
1779            &EraLabels::Default,
1780            &NegativeUnspecifiedYears::Range,
1781            "–",
1782        );
1783        assert_eq!(result, "19XX");
1784    }
1785
1786    #[test]
1787    fn negative_unspecified_range() {
1788        let year = Year {
1789            value: -90,
1790            unspecified: UnspecifiedYear::One,
1791        };
1792        let result = format_display_year(
1793            &year,
1794            &en_terms(),
1795            &EraLabels::Default,
1796            &NegativeUnspecifiedYears::Range,
1797            "–",
1798        );
1799        assert_eq!(result, "100–91 BC");
1800    }
1801
1802    #[test]
1803    fn negative_unspecified_century() {
1804        let year = Year {
1805            value: 0,
1806            unspecified: UnspecifiedYear::Two,
1807        };
1808        let result = format_display_year(
1809            &year,
1810            &en_terms(),
1811            &EraLabels::Default,
1812            &NegativeUnspecifiedYears::Range,
1813            "–",
1814        );
1815        assert_eq!(result, "100–1 BC");
1816    }
1817
1818    #[test]
1819    fn backwards_compat_negative_year() {
1820        let year = Year {
1821            value: -99,
1822            unspecified: UnspecifiedYear::None,
1823        };
1824        let result = format_display_year(
1825            &year,
1826            &en_terms(),
1827            &EraLabels::Default,
1828            &NegativeUnspecifiedYears::Range,
1829            "–",
1830        );
1831        assert_eq!(result, "100 BC");
1832    }
1833}
1834
1835#[cfg(test)]
1836#[allow(
1837    clippy::unwrap_used,
1838    clippy::expect_used,
1839    reason = "Panicking is acceptable in tests."
1840)]
1841mod locale_pattern_tests {
1842    use super::*;
1843    use citum_schema::locale::Locale;
1844
1845    fn en_us() -> Locale {
1846        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1847            .expect("en-US locale should parse")
1848    }
1849
1850    fn es_es() -> Locale {
1851        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1852            .expect("es-ES locale should parse")
1853    }
1854
1855    fn eu_es() -> Locale {
1856        Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1857            .expect("eu-ES locale should parse")
1858    }
1859
1860    fn full(locale: &Locale, edtf: &str) -> String {
1861        format_single_date(
1862            &DateValue::new(edtf.to_string()),
1863            &DateForm::Full,
1864            locale,
1865            None,
1866        )
1867        .expect("date should render")
1868    }
1869
1870    fn month_day(locale: &Locale, edtf: &str) -> String {
1871        format_single_date(
1872            &DateValue::new(edtf.to_string()),
1873            &DateForm::MonthDay,
1874            locale,
1875            None,
1876        )
1877        .expect("date should render")
1878    }
1879
1880    #[test]
1881    fn en_us_full_unchanged_by_pattern_machinery() {
1882        // Regression: en-US declares no pattern.date-*, so the engine's
1883        // hardcoded English assembly must still produce the original output.
1884        assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1885    }
1886
1887    #[test]
1888    fn en_us_month_day_unchanged_by_pattern_machinery() {
1889        assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1890    }
1891
1892    #[test]
1893    fn en_us_month_form_renders_month_name_only() {
1894        // given a year-month date and the month-only form
1895        let out = format_single_date(
1896            &DateValue::new("2023-06".to_string()),
1897            &DateForm::Month,
1898            &en_us(),
1899            None,
1900        );
1901        // then only the month name renders (no year), e.g. magazines
1902        assert_eq!(out.as_deref(), Some("June"));
1903    }
1904
1905    #[test]
1906    fn en_us_month_form_renders_season_name() {
1907        // given an EDTF season date and the month-only form
1908        let out = format_single_date(
1909            &DateValue::new("2023-21".to_string()),
1910            &DateForm::Month,
1911            &en_us(),
1912            None,
1913        );
1914        // then the locale's season term renders in place of a month name
1915        assert_eq!(out.as_deref(), Some("Spring"));
1916    }
1917
1918    #[test]
1919    fn en_us_year_month_form_renders_season_and_year() {
1920        let out = format_single_date(
1921            &DateValue::new("2023-21".to_string()),
1922            &DateForm::YearMonth,
1923            &en_us(),
1924            None,
1925        );
1926        assert_eq!(out.as_deref(), Some("Spring 2023"));
1927    }
1928
1929    #[test]
1930    fn en_us_full_form_renders_season_and_year() {
1931        assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1932    }
1933
1934    /// `day-zero-pad` is off by default, so a plain `format_single_date` call
1935    /// (no `DateConfig`) renders the day unpadded — the pre-existing
1936    /// behavior this option must not change unless a style opts in.
1937    #[test]
1938    fn day_zero_pad_defaults_to_unpadded_day() {
1939        assert_eq!(full(&en_us(), "2023-02-07"), "February 7, 2023");
1940    }
1941
1942    /// `day-zero-pad: true` zero-pads the day across every day-bearing
1943    /// single-date form, not only `Full`.
1944    #[test]
1945    fn day_zero_pad_true_pads_day_in_full_and_month_day_forms() {
1946        let config = citum_schema::options::dates::DateConfig {
1947            day_zero_pad: true,
1948            ..Default::default()
1949        };
1950        assert_eq!(
1951            format_single_date(
1952                &DateValue::new("2023-02-07".to_string()),
1953                &DateForm::Full,
1954                &en_us(),
1955                Some(&config)
1956            ),
1957            Some("February 07, 2023".to_string())
1958        );
1959        assert_eq!(
1960            format_single_date(
1961                &DateValue::new("2023-02-07".to_string()),
1962                &DateForm::MonthDay,
1963                &en_us(),
1964                Some(&config)
1965            ),
1966            Some("February 07".to_string())
1967        );
1968    }
1969
1970    /// The range-fragment path (`format_abbreviated_month_day_fragment`,
1971    /// used by same-year date-range rendering) is a separate code path from
1972    /// `format_single_date` and must honor `day-zero-pad` independently —
1973    /// this is the surface most likely to be missed when wiring the option.
1974    #[test]
1975    fn day_zero_pad_true_pads_day_in_abbreviated_range_fragment() {
1976        let config = citum_schema::options::dates::DateConfig {
1977            day_zero_pad: true,
1978            ..Default::default()
1979        };
1980        let date = DateValue::new("2023-02-07".to_string());
1981        assert_eq!(
1982            format_abbreviated_month_day_fragment(
1983                &date,
1984                &DateForm::DayMonthAbbrYear,
1985                &en_us(),
1986                Some(&config)
1987            ),
1988            Some("07 Feb.".to_string())
1989        );
1990        assert_eq!(
1991            format_abbreviated_month_day_fragment(
1992                &date,
1993                &DateForm::MonthAbbrDayYear,
1994                &en_us(),
1995                Some(&config)
1996            ),
1997            Some("Feb. 07".to_string())
1998        );
1999    }
2000
2001    /// The bean's own reported example: a `LocaleOverride` replacing a
2002    /// single short-month abbreviation, combined with `day-zero-pad`,
2003    /// renders "Jul. 13, 2021" instead of the base locale's "July 13, 2021"
2004    /// — without redeclaring the other eleven months.
2005    #[test]
2006    fn locale_override_month_abbreviation_with_day_zero_pad_matches_bean_example() {
2007        use citum_schema::locale::{DateNameOverride, LocaleOverride, MonthNames, SubYearCode};
2008
2009        let mut locale = en_us();
2010        let july = SubYearCode::new(7).expect("valid month code");
2011        assert_eq!(
2012            locale.dates.months.short.get(&july).map(String::as_str),
2013            Some("July"),
2014            "sanity: base locale renders July's short form unabbreviated"
2015        );
2016
2017        // Override to IEEE's tighter form and confirm June is untouched.
2018        let ov = LocaleOverride {
2019            dates: DateNameOverride {
2020                months: MonthNames {
2021                    long: std::collections::BTreeMap::new(),
2022                    short: [(july, "Jul.".to_string())].into(),
2023                },
2024                seasons: std::collections::BTreeMap::new(),
2025            },
2026            ..Default::default()
2027        };
2028        locale.apply_override(&ov);
2029
2030        let config = citum_schema::options::dates::DateConfig {
2031            day_zero_pad: true,
2032            ..Default::default()
2033        };
2034        assert_eq!(
2035            format_single_date(
2036                &DateValue::new("2021-07-13".to_string()),
2037                &DateForm::MonthAbbrDayYear,
2038                &locale,
2039                Some(&config)
2040            ),
2041            Some("Jul. 13, 2021".to_string())
2042        );
2043        let june = SubYearCode::new(6).expect("valid month code");
2044        assert_eq!(
2045            locale.dates.months.short.get(&june).map(String::as_str),
2046            Some("June")
2047        );
2048    }
2049
2050    #[test]
2051    fn es_es_year_month_form_renders_localized_season() {
2052        let out = format_single_date(
2053            &DateValue::new("2023-23".to_string()),
2054            &DateForm::YearMonth,
2055            &es_es(),
2056            None,
2057        );
2058        assert_eq!(out.as_deref(), Some("otoño de 2023"));
2059    }
2060
2061    #[test]
2062    fn es_es_full_uses_locale_pattern() {
2063        // Spanish day-first assembly via pattern.date-full.
2064        assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
2065    }
2066
2067    #[test]
2068    fn es_es_month_day_uses_locale_pattern() {
2069        assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
2070    }
2071
2072    #[test]
2073    fn eu_es_full_uses_locale_pattern() {
2074        // Basque genitive-absolutive shape via pattern.date-full.
2075        // Content is PROVISIONAL — see locales/eu-ES.yaml header comment.
2076        assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
2077    }
2078
2079    #[test]
2080    fn eu_es_month_day_uses_locale_pattern() {
2081        assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
2082    }
2083
2084    fn year_month(locale: &Locale, edtf: &str) -> String {
2085        format_single_date(
2086            &DateValue::new(edtf.to_string()),
2087            &DateForm::YearMonth,
2088            locale,
2089            None,
2090        )
2091        .expect("date should render")
2092    }
2093
2094    fn year_month_day(locale: &Locale, edtf: &str) -> String {
2095        format_single_date(
2096            &DateValue::new(edtf.to_string()),
2097            &DateForm::YearMonthDay,
2098            locale,
2099            None,
2100        )
2101        .expect("date should render")
2102    }
2103
2104    fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
2105        format_single_date(
2106            &DateValue::new(edtf.to_string()),
2107            &DateForm::DayMonthAbbrYear,
2108            locale,
2109            None,
2110        )
2111        .expect("date should render")
2112    }
2113
2114    fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
2115        format_single_date(
2116            &DateValue::new(edtf.to_string()),
2117            &DateForm::MonthAbbrDayYear,
2118            locale,
2119            None,
2120        )
2121        .expect("date should render")
2122    }
2123
2124    #[test]
2125    fn en_us_year_month_unchanged_by_pattern_machinery() {
2126        // en-US has no pattern.date-year-month, so hardcoded assembly must hold.
2127        assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
2128    }
2129
2130    #[test]
2131    fn en_us_year_month_day_unchanged_by_pattern_machinery() {
2132        assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
2133    }
2134
2135    #[test]
2136    fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
2137        assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
2138    }
2139
2140    #[test]
2141    fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
2142        assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
2143    }
2144
2145    #[test]
2146    fn es_es_year_month_uses_locale_pattern() {
2147        // Spanish: month before year connected with "de".
2148        assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
2149    }
2150
2151    #[test]
2152    fn eu_es_year_month_uses_locale_pattern() {
2153        // Basque: year-first genitive shape. PROVISIONAL — see locales/eu-ES.yaml.
2154        assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
2155    }
2156
2157    #[test]
2158    fn year_month_missing_month_falls_back_to_year() {
2159        // Year-only EDTF: no month to pattern-assemble, returns year alone.
2160        assert_eq!(year_month(&es_es(), "2023"), "2023");
2161    }
2162
2163    #[test]
2164    fn es_es_year_month_day_uses_locale_pattern() {
2165        // Spanish: year first, then day/month connected with "de".
2166        assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
2167    }
2168
2169    #[test]
2170    fn es_es_year_month_day_missing_day_falls_back() {
2171        // Pattern requires $day; evaluator returns None, falls back to
2172        // hardcoded "{year}, {month}".
2173        assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
2174    }
2175
2176    #[test]
2177    fn es_es_day_month_abbr_year_uses_locale_pattern() {
2178        // Spanish abbreviated form: "12 ene. de 2023" via pattern.
2179        assert_eq!(
2180            day_month_abbr_year(&es_es(), "2023-01-12"),
2181            "12 ene. de 2023"
2182        );
2183    }
2184
2185    #[test]
2186    fn es_es_day_month_abbr_year_missing_day_falls_back() {
2187        // Pattern requires $day; falls back to hardcoded "{month} {year}".
2188        assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
2189    }
2190
2191    #[test]
2192    fn es_es_month_abbr_day_year_uses_locale_pattern() {
2193        // Spanish abbreviated form: "ene. 12 de 2023" via pattern.
2194        assert_eq!(
2195            month_abbr_day_year(&es_es(), "2023-01-12"),
2196            "ene. 12 de 2023"
2197        );
2198    }
2199
2200    #[test]
2201    fn es_es_month_abbr_day_year_missing_day_falls_back() {
2202        // Pattern requires $day; falls back to hardcoded "{month} {year}".
2203        assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
2204    }
2205
2206    #[test]
2207    fn pattern_missing_day_falls_back_to_english_assembly() {
2208        // Year-month only input: pattern.date-full requires {$day} so the
2209        // evaluator returns None, and the engine falls through to its
2210        // hardcoded `{month} {year}` assembly. (A future pattern.date-year-month
2211        // can fix this for inflected locales — out of scope for this bean.)
2212        assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
2213    }
2214}
2215
2216#[cfg(test)]
2217#[allow(
2218    clippy::unwrap_used,
2219    clippy::expect_used,
2220    reason = "Panicking is acceptable in tests."
2221)]
2222mod numeric_month_tests {
2223    use super::*;
2224    use citum_schema::locale::Locale;
2225    use citum_schema::options::MonthFormat;
2226    use citum_schema::options::dates::DateConfig;
2227
2228    fn en_us() -> Locale {
2229        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
2230            .expect("en-US locale should parse")
2231    }
2232
2233    fn numeric_config() -> DateConfig {
2234        DateConfig {
2235            month: MonthFormat::Numeric,
2236            ..Default::default()
2237        }
2238    }
2239
2240    fn render(form: DateForm, edtf: &str) -> Option<String> {
2241        format_single_date(
2242            &DateValue::new(edtf.to_string()),
2243            &form,
2244            &en_us(),
2245            Some(&numeric_config()),
2246        )
2247    }
2248
2249    #[test]
2250    fn given_month_numeric_when_year_month_day_then_iso_hyphenated() {
2251        // GB/T 7714 / ISO 690 access and update dates: [2024-01-15].
2252        assert_eq!(
2253            render(DateForm::YearMonthDay, "2024-01-15").as_deref(),
2254            Some("2024-01-15")
2255        );
2256    }
2257
2258    #[test]
2259    fn given_month_numeric_when_day_missing_then_year_month_only() {
2260        assert_eq!(
2261            render(DateForm::YearMonthDay, "2024-01").as_deref(),
2262            Some("2024-01")
2263        );
2264    }
2265
2266    #[test]
2267    fn given_month_numeric_when_year_only_then_plain_year() {
2268        assert_eq!(
2269            render(DateForm::YearMonthDay, "2024").as_deref(),
2270            Some("2024")
2271        );
2272    }
2273
2274    #[test]
2275    fn given_month_numeric_when_year_month_form_then_hyphenated() {
2276        assert_eq!(
2277            render(DateForm::YearMonth, "2024-03").as_deref(),
2278            Some("2024-03")
2279        );
2280    }
2281
2282    #[test]
2283    fn given_month_numeric_when_month_day_form_then_zero_padded() {
2284        assert_eq!(
2285            render(DateForm::MonthDay, "2024-03-05").as_deref(),
2286            Some("03-05")
2287        );
2288    }
2289
2290    #[test]
2291    fn given_month_numeric_when_full_form_then_iso_hyphenated() {
2292        assert_eq!(
2293            render(DateForm::Full, "2024-01-15").as_deref(),
2294            Some("2024-01-15")
2295        );
2296    }
2297
2298    #[test]
2299    fn given_month_numeric_when_season_date_then_textual_fallback() {
2300        // Seasons have no numeric month; the textual path must still render.
2301        assert_eq!(
2302            render(DateForm::YearMonth, "2024-22").as_deref(),
2303            Some("Summer 2024")
2304        );
2305    }
2306
2307    #[test]
2308    fn given_long_month_config_when_year_month_day_then_unchanged() {
2309        // Regression guard: the default textual assembly is untouched.
2310        let out = format_single_date(
2311            &DateValue::new("2024-01-15".to_string()),
2312            &DateForm::YearMonthDay,
2313            &en_us(),
2314            None,
2315        );
2316        assert_eq!(out.as_deref(), Some("2024, January 15"));
2317    }
2318}
2319
2320#[cfg(test)]
2321#[allow(
2322    clippy::unwrap_used,
2323    clippy::expect_used,
2324    reason = "Panicking is acceptable in tests."
2325)]
2326mod range_tests {
2327    use super::*;
2328    use citum_schema::locale::Locale;
2329    use citum_schema::options::dates::{DateConfig, EraLabels};
2330
2331    fn en_us() -> Locale {
2332        Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
2333            .expect("en-US locale should parse")
2334    }
2335
2336    fn es_es() -> Locale {
2337        Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
2338            .expect("es-ES locale should parse")
2339    }
2340
2341    fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
2342        format_date_range(&DateValue::new(edtf.to_string()), &form, locale, None)
2343    }
2344
2345    fn chicago_range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
2346        let config = DateConfig {
2347            range_format: DateRangeFormat::Chicago,
2348            ..Default::default()
2349        };
2350        format_date_range(
2351            &DateValue::new(edtf.to_string()),
2352            &form,
2353            locale,
2354            Some(&config),
2355        )
2356    }
2357
2358    fn range_with_config(
2359        locale: &Locale,
2360        edtf: &str,
2361        form: DateForm,
2362        config: &DateConfig,
2363    ) -> Option<String> {
2364        format_date_range(
2365            &DateValue::new(edtf.to_string()),
2366            &form,
2367            locale,
2368            Some(config),
2369        )
2370    }
2371
2372    #[test]
2373    fn closed_range_year_form_regression() {
2374        // given a closed range with distinct years and the Year form
2375        // then it renders as a plain year-to-year range (no collapse)
2376        assert_eq!(
2377            range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
2378            Some("2020–2022")
2379        );
2380    }
2381
2382    #[test]
2383    fn chicago_year_range_condenses_the_end_year() {
2384        assert_eq!(
2385            chicago_range(&en_us(), "2021/2026", DateForm::Year).as_deref(),
2386            Some("2021–26")
2387        );
2388    }
2389
2390    #[test]
2391    fn chicago_range_format_keeps_cross_year_month_ranges_expanded() {
2392        assert_eq!(
2393            chicago_range(&en_us(), "2021-05/2026-06", DateForm::YearMonth).as_deref(),
2394            Some("May 2021–June 2026")
2395        );
2396    }
2397
2398    #[test]
2399    fn shared_year_month_range_uses_spanish_mf2_pattern() {
2400        assert_eq!(
2401            range(&es_es(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
2402            Some("mayo a junio, 2026")
2403        );
2404    }
2405
2406    #[test]
2407    fn shared_year_full_range_uses_spanish_mf2_pattern() {
2408        assert_eq!(
2409            range(&es_es(), "2026-05-14/2026-06-02", DateForm::Full).as_deref(),
2410            Some("14 de mayo a 2 de junio de 2026")
2411        );
2412    }
2413
2414    #[test]
2415    fn chicago_year_range_condenses_same_era_bce_years() {
2416        let config = DateConfig {
2417            range_format: DateRangeFormat::Chicago,
2418            era_labels: EraLabels::BceCe,
2419            ..Default::default()
2420        };
2421        assert_eq!(
2422            range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
2423            Some("327–21 BCE")
2424        );
2425    }
2426
2427    #[test]
2428    fn expanded_same_era_bce_years_keep_both_endpoints() {
2429        let config = DateConfig {
2430            era_labels: EraLabels::BceCe,
2431            ..Default::default()
2432        };
2433        assert_eq!(
2434            range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
2435            Some("327 BCE–21 BCE")
2436        );
2437    }
2438
2439    #[test]
2440    fn chicago_year_range_preserves_cross_era_endpoints() {
2441        let config = DateConfig {
2442            range_format: DateRangeFormat::Chicago,
2443            era_labels: EraLabels::BcAd,
2444            ..Default::default()
2445        };
2446        assert_eq!(
2447            range_with_config(&en_us(), "-0114/0010", DateForm::Year, &config).as_deref(),
2448            Some("115 BC–10 AD")
2449        );
2450    }
2451
2452    #[test]
2453    fn chicago_year_range_keeps_reversed_input_expanded() {
2454        assert_eq!(
2455            chicago_range(&en_us(), "2026/2021", DateForm::Year).as_deref(),
2456            Some("2026–2021")
2457        );
2458    }
2459
2460    #[test]
2461    fn chicago_range_format_does_not_condense_non_four_digit_or_unspecified_years() {
2462        assert_eq!(
2463            chicago_range(&en_us(), "0999/1000", DateForm::Year).as_deref(),
2464            Some("999–1000")
2465        );
2466        assert_eq!(
2467            chicago_range(&en_us(), "202u/203u", DateForm::Year).as_deref(),
2468            Some("202X–203X")
2469        );
2470    }
2471
2472    #[test]
2473    fn closed_range_full_form_different_years() {
2474        // given a closed range spanning two years, Full form
2475        // then both endpoints render in full
2476        assert_eq!(
2477            range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
2478            Some("May 14, 2023–June 2, 2024")
2479        );
2480    }
2481
2482    #[test]
2483    fn closed_range_full_form_same_year_collapses() {
2484        // given a closed range within a single year, Full form
2485        // then the start's year is suppressed and trails the end instead
2486        assert_eq!(
2487            range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
2488            Some("May 14–June 2, 2023")
2489        );
2490    }
2491
2492    #[test]
2493    fn closed_range_year_month_day_same_year_collapses() {
2494        // given a closed range within a single year, YearMonthDay form
2495        // then the leading year renders once and the end's year is suppressed
2496        assert_eq!(
2497            range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
2498            Some("2023, May 14–June 2")
2499        );
2500    }
2501
2502    #[test]
2503    fn closed_range_full_form_es_es_locale_pattern() {
2504        // given a closed range spanning two years under a locale that
2505        // declares pattern.date-full
2506        // then both endpoints render through the Spanish pattern
2507        assert_eq!(
2508            range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
2509            Some("12 de enero de 2023–3 de febrero de 2024")
2510        );
2511    }
2512
2513    #[test]
2514    fn interval_to_year_form() {
2515        // given an open-ended-from-start range ("../2020")
2516        // then it renders as the single known (end) point
2517        assert_eq!(
2518            range(&en_us(), "../2020", DateForm::Year).as_deref(),
2519            Some("2020")
2520        );
2521    }
2522
2523    #[test]
2524    fn closed_range_year_month_same_year_collapses() {
2525        // given month-only endpoints in the same year, YearMonth form
2526        // then the start month renders without the (shared) year
2527        assert_eq!(
2528            range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
2529            Some("May–June 2023")
2530        );
2531    }
2532
2533    #[test]
2534    fn chicago_range_format_keeps_same_year_month_ranges_locale_driven() {
2535        assert_eq!(
2536            chicago_range(&en_us(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
2537            Some("May–June 2026")
2538        );
2539    }
2540
2541    #[test]
2542    fn closed_range_season_same_year_collapses() {
2543        // given EDTF season endpoints in the same year, YearMonth form
2544        // then the start season renders without the (shared) year
2545        assert_eq!(
2546            range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
2547            Some("Spring–Summer 2023")
2548        );
2549    }
2550}