1use 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
25fn 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
43fn 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
73pub(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
93pub(crate) enum EffectiveDateCandidateSource<'a> {
99 Inline(Option<&'a [TemplateComponent]>),
101 Unmatched(Option<&'a [TemplateComponent]>),
103 Matched(&'a [DateSubstituteCandidate]),
105}
106
107impl<'a> EffectiveDateCandidateSource<'a> {
108 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
122pub(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
138pub(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
205pub(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
223pub(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
260pub(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
314fn 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
325fn 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 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 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 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 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
418fn 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
452fn 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
512fn 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 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 _ => format_single_date(date, form, locale, date_config),
543 }
544}
545
546fn 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
596fn 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
666fn 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
785fn 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
831fn 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
857fn 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 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#[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 let numeric_months =
959 date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
960 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 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
1192pub(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
1229fn 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 if let Some(fallbacks) = &self.fallback {
1339 return render_date_fallback_chain::<F>(
1340 self, fallbacks, reference, hints, options, &fmt,
1341 );
1342 }
1343 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 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 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
1391
1392 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#[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 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 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 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 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 let out = format_single_date(
1896 &DateValue::new("2023-06".to_string()),
1897 &DateForm::Month,
1898 &en_us(),
1899 None,
1900 );
1901 assert_eq!(out.as_deref(), Some("June"));
1903 }
1904
1905 #[test]
1906 fn en_us_month_form_renders_season_name() {
1907 let out = format_single_date(
1909 &DateValue::new("2023-21".to_string()),
1910 &DateForm::Month,
1911 &en_us(),
1912 None,
1913 );
1914 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 assert_eq!(year_month(&es_es(), "2023"), "2023");
2161 }
2162
2163 #[test]
2164 fn es_es_year_month_day_uses_locale_pattern() {
2165 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
2546 range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
2547 Some("Spring–Summer 2023")
2548 );
2549 }
2550}