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