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