1use crate::reference::{EdtfString, 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(date: &EdtfString, months: &[String], seasons: &[String]) -> String {
34 let parsed_date = date.parse();
35 let edtf = match parsed_date {
36 RefDate::Edtf(edtf) => edtf,
37 RefDate::Literal(_) => return String::new(),
38 };
39 match edtf.month() {
40 Some(month) => month_to_string(month, months),
41 None => match edtf.season() {
42 Some(season) => month_to_string(season, seasons),
43 None => String::new(),
44 },
45 }
46}
47
48fn event_date(reference: &Reference) -> Option<EdtfString> {
49 match reference.extension() {
50 ClassExtension::Event(event) => event.date.clone(),
51 ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
52 ClassExtension::SerialComponent(component) => {
53 embedded_event_date(component.event.as_ref()?)
54 }
55 ClassExtension::AudioVisual(audio_visual) => {
56 embedded_event_date(audio_visual.event.as_ref()?)
57 }
58 _ => None,
59 }
60}
61
62fn embedded_event_date(relation: &WorkRelation) -> Option<EdtfString> {
63 let WorkRelation::Embedded(reference) = relation else {
64 return None;
65 };
66 let ClassExtension::Event(event) = reference.extension() else {
67 return None;
68 };
69 event.date.clone()
70}
71
72fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
74 match u {
75 UnspecifiedYear::None => 0,
76 UnspecifiedYear::One => 9,
77 UnspecifiedYear::Two => 99,
78 UnspecifiedYear::Three => 999,
79 UnspecifiedYear::Four => 9999,
80 }
81}
82
83fn format_display_year(
85 year: &Year,
86 date_terms: &citum_schema::locale::DateTerms,
87 era_labels: &citum_schema::options::dates::EraLabels,
88 _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
89 range_delimiter: &str,
90) -> String {
91 if year.unspecified != UnspecifiedYear::None && year.value > 0 {
93 let mut s = year.value.to_string();
94 let unspec_count = match year.unspecified {
95 UnspecifiedYear::One => 1,
96 UnspecifiedYear::Two => 2,
97 UnspecifiedYear::Three => 3,
98 UnspecifiedYear::Four => 4,
99 _ => 0,
100 };
101 for _ in 0..unspec_count {
102 if let Some(last) = s.pop()
103 && last != '0'
104 {
105 s.push('X');
106 }
107 }
108 if s.len() < year.value.to_string().len() {
109 let diff = year.value.to_string().len() - s.len();
110 for _ in 0..diff {
111 s.push('X');
112 }
113 }
114 return s;
115 }
116
117 if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
119 let delta = unspecified_year_delta(&year.unspecified);
120 let astronomical_min = year.value - delta;
121 let astronomical_max = year.value;
122 let historical_end = 1 - astronomical_max;
123 let historical_start = 1 - astronomical_min;
124
125 let era_term = match era_labels {
126 citum_schema::options::dates::EraLabels::Default => {
127 date_terms.before_era.as_deref().unwrap_or("")
128 }
129 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
130 citum_schema::options::dates::EraLabels::BceCe => {
131 date_terms.bce.as_deref().unwrap_or("")
132 }
133 };
134
135 if era_term.is_empty() {
136 format!("{historical_start}{range_delimiter}{historical_end}")
137 } else {
138 format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
139 }
140 } else if year.value <= 0 {
141 let historical_year = 1 - year.value;
143 let era_term = match era_labels {
144 citum_schema::options::dates::EraLabels::Default => {
145 date_terms.before_era.as_deref().unwrap_or("")
146 }
147 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
148 citum_schema::options::dates::EraLabels::BceCe => {
149 date_terms.bce.as_deref().unwrap_or("")
150 }
151 };
152
153 if era_term.is_empty() {
154 historical_year.to_string()
155 } else {
156 format!("{historical_year} {era_term}")
157 }
158 } else {
159 let era_term = match era_labels {
161 citum_schema::options::dates::EraLabels::Default => "",
162 citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
163 citum_schema::options::dates::EraLabels::BceCe => {
164 date_terms.ce.as_deref().unwrap_or("")
165 }
166 };
167
168 if era_term.is_empty() {
169 year.value.to_string()
170 } else {
171 format!("{} {}", year.value, era_term)
172 }
173 }
174}
175
176fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
178 if year.unspecified != UnspecifiedYear::None {
179 return year.to_string();
180 }
181
182 if year.value <= 0 {
183 let historical_year = 1 - year.value;
184 if let Some(term) = before_era.filter(|term| !term.is_empty()) {
185 format!("{historical_year} {term}")
186 } else {
187 historical_year.to_string()
188 }
189 } else {
190 year.value.to_string()
191 }
192}
193
194#[allow(dead_code, reason = "kept for backwards compatibility")]
195fn extract_display_year_legacy(date: &EdtfString, before_era: Option<&str>) -> String {
196 match date.parse() {
197 RefDate::Edtf(edtf) => match edtf {
198 Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
199 Edtf::Interval(interval) => {
200 format_display_year_legacy(&interval.start.year, before_era)
201 }
202 Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
203 format_display_year_legacy(&date.year, before_era)
204 }
205 },
206 RefDate::Literal(_) => String::new(),
207 }
208}
209
210fn format_time(
215 time: citum_edtf::Time,
216 format: &TimeFormat,
217 show_seconds: bool,
218 show_timezone: bool,
219 am_term: Option<&str>,
220 pm_term: Option<&str>,
221 utc_term: Option<&str>,
222) -> String {
223 let (display_hour, period) = match format {
224 TimeFormat::Hour12 => {
225 let (h, p) = if time.hour == 0 {
226 (12u32, am_term.unwrap_or("AM"))
227 } else if time.hour < 12 {
228 (time.hour, am_term.unwrap_or("AM"))
229 } else if time.hour == 12 {
230 (12u32, pm_term.unwrap_or("PM"))
231 } else {
232 (time.hour - 12, pm_term.unwrap_or("PM"))
233 };
234 (h, Some(p))
235 }
236 TimeFormat::Hour24 => (time.hour, None),
237 };
238
239 let time_str = if show_seconds {
240 format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
241 } else {
242 format!("{:02}:{:02}", display_hour, time.minute)
243 };
244
245 let with_period = match period {
246 Some(p) => format!("{time_str} {p}"),
247 None => time_str,
248 };
249
250 if show_timezone {
251 let tz_str = match time.timezone {
252 Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
253 Some(Timezone::Offset(mins)) => {
254 let sign = if mins >= 0 { '+' } else { '-' };
255 let abs = mins.unsigned_abs();
256 format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
257 }
258 None => String::new(),
259 };
260 if tz_str.is_empty() {
261 with_period
262 } else {
263 format!("{with_period} {tz_str}")
264 }
265 } else {
266 with_period
267 }
268}
269
270fn format_date_range(
274 date: &EdtfString,
275 form: &DateForm,
276 locale: &citum_schema::locale::Locale,
277 date_config: Option<&citum_schema::options::dates::DateConfig>,
278) -> Option<String> {
279 let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
280
281 match date.parse() {
282 RefDate::Edtf(Edtf::Interval(interval)) => {
283 format_closed_range(date, &interval, form, locale, date_config, delimiter)
284 }
285 RefDate::Edtf(Edtf::IntervalFrom(_)) => {
286 let start = format_single_date(date, form, locale, date_config)?;
289 if let Some(end_marker) = date_config
290 .and_then(|c| c.open_range_marker.as_deref())
291 .or(locale.dates.open_ended_term.as_deref())
292 {
293 Some(format!("{start}{delimiter}{end_marker}"))
294 } else {
295 Some(start)
296 }
297 }
298 _ => format_single_date(date, form, locale, date_config),
301 }
302}
303
304fn format_closed_range(
307 date: &EdtfString,
308 interval: &citum_edtf::Interval,
309 form: &DateForm,
310 locale: &citum_schema::locale::Locale,
311 date_config: Option<&citum_schema::options::dates::DateConfig>,
312 delimiter: &str,
313) -> Option<String> {
314 let same_year = interval.start.year.value == interval.end.year.value;
315 let both_have_month =
316 interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
317
318 if same_year
319 && both_have_month
320 && let Some(collapsed) = format_same_year_range(
321 &interval.start,
322 &interval.end,
323 form,
324 locale,
325 date_config,
326 delimiter,
327 )
328 {
329 return Some(collapsed);
330 }
331
332 let start = format_single_date(date, form, locale, date_config);
333 let end = format_single_date(
334 &EdtfString(interval.end.to_string()),
335 form,
336 locale,
337 date_config,
338 );
339
340 match (start, end) {
341 (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
342 (Some(s), None) => Some(s),
343 (None, Some(e)) => Some(e),
344 (None, None) => None,
345 }
346}
347
348fn format_same_year_range(
355 start: &citum_edtf::Date,
356 end: &citum_edtf::Date,
357 form: &DateForm,
358 locale: &citum_schema::locale::Locale,
359 date_config: Option<&citum_schema::options::dates::DateConfig>,
360 delimiter: &str,
361) -> Option<String> {
362 let (start_form, end_form) = match form {
363 DateForm::Full => (DateForm::MonthDay, DateForm::Full),
364 DateForm::YearMonth => (DateForm::Month, DateForm::YearMonth),
365 DateForm::YearMonthDay => (DateForm::YearMonthDay, DateForm::MonthDay),
366 _ => return None,
367 };
368
369 let start_str = format_single_date(
370 &EdtfString(start.to_string()),
371 &start_form,
372 locale,
373 date_config,
374 )?;
375 let end_str = format_single_date(&EdtfString(end.to_string()), &end_form, locale, date_config)?;
376 Some(format!("{start_str}{delimiter}{end_str}"))
377}
378
379fn apply_date_markers(
381 value: String,
382 date: &EdtfString,
383 date_config: Option<&citum_schema::options::dates::DateConfig>,
384) -> String {
385 let mut result = value;
386 if date.is_approximate()
387 && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
388 {
389 result = format!("{marker}{result}");
390 }
391 if date.is_uncertain()
392 && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
393 {
394 result = format!("{result}{marker}");
395 }
396 result
397}
398
399fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
401 date: &EdtfString,
402 form: &DateForm,
403 hints: &ProcHints,
404 options: &RenderOptions<'_>,
405 fmt: &F,
406) -> Option<String> {
407 if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
408 let use_suffix = options
411 .config
412 .effective_processing()
413 .config()
414 .disambiguate
415 .as_ref()
416 .is_some_and(|d| d.year_suffix);
417
418 if use_suffix {
419 int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
420 } else {
421 None
422 }
423 } else {
424 None
425 }
426}
427
428fn date_form_displays_year(form: &DateForm) -> bool {
429 !matches!(form, DateForm::MonthDay)
430}
431
432fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
433 if year.is_empty() || suffix.is_empty() {
434 return formatted.to_string();
435 }
436
437 let year_index = match form {
438 DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
439 DateForm::YearMonth
440 | DateForm::Full
441 | DateForm::DayMonthAbbrYear
442 | DateForm::MonthAbbrDayYear => formatted.rfind(year),
443 DateForm::MonthDay => None,
444 _ => None,
445 };
446
447 let Some(index) = year_index else {
448 return format!("{formatted}{suffix}");
449 };
450
451 let year_end = index + year.len();
452 #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
453 let result = format!(
454 "{}{}{}{}",
455 &formatted[..index],
456 year,
457 suffix,
458 &formatted[year_end..]
459 );
460 result
461}
462
463#[allow(
465 clippy::too_many_lines,
466 reason = "date formatting handles 6 form variants"
467)]
468fn format_single_date(
469 date: &EdtfString,
470 form: &DateForm,
471 locale: &citum_schema::locale::Locale,
472 date_config: Option<&citum_schema::options::dates::DateConfig>,
473) -> Option<String> {
474 let default_era = citum_schema::options::dates::EraLabels::Default;
475 let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
476 let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
477 let neg_unspecified = date_config
478 .map(|c| &c.negative_unspecified_years)
479 .unwrap_or(&default_neg_unspec);
480 let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
481
482 let extract_year = |d: &EdtfString| -> String {
483 match d.parse() {
484 RefDate::Edtf(edtf) => match edtf {
485 Edtf::Date(dt) => format_display_year(
486 &dt.year,
487 &locale.dates,
488 era_labels,
489 neg_unspecified,
490 range_delimiter,
491 ),
492 Edtf::Interval(interval) => format_display_year(
493 &interval.start.year,
494 &locale.dates,
495 era_labels,
496 neg_unspecified,
497 range_delimiter,
498 ),
499 Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
500 &dt.year,
501 &locale.dates,
502 era_labels,
503 neg_unspecified,
504 range_delimiter,
505 ),
506 },
507 RefDate::Literal(_) => String::new(),
508 }
509 };
510
511 match form {
512 DateForm::Year => {
513 let year = extract_year(date);
514 if year.is_empty() { None } else { Some(year) }
515 }
516 DateForm::YearMonth => {
517 let year = extract_year(date);
518 if year.is_empty() {
519 return None;
520 }
521 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
522 let month_opt = (!month.is_empty()).then_some(month.as_str());
523 if let Some(rendered) =
524 locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
525 {
526 return Some(rendered);
527 }
528 if month.is_empty() {
529 Some(year)
530 } else {
531 Some(format!("{month} {year}"))
532 }
533 }
534 DateForm::Month => {
535 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
536 if month.is_empty() { None } else { Some(month) }
537 }
538 DateForm::MonthDay => {
539 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
540 if month.is_empty() {
541 return None;
542 }
543 let day = date.day();
544 if let Some(rendered) =
545 locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
546 {
547 return Some(rendered);
548 }
549 match day {
550 Some(d) => Some(format!("{month} {d}")),
551 None => Some(month),
552 }
553 }
554 DateForm::Full => {
555 let year = extract_year(date);
556 if year.is_empty() {
557 return None;
558 }
559 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
560 let day = date.day();
561 let base = locale
562 .resolve_date_pattern(
563 "pattern.date-full",
564 Some(&year),
565 (!month.is_empty()).then_some(month.as_str()),
566 day,
567 )
568 .unwrap_or_else(|| match (month.is_empty(), day) {
569 (true, _) => year.clone(),
570 (false, None) => format!("{month} {year}"),
571 (false, Some(d)) => format!("{month} {d}, {year}"),
572 });
573 if let (Some(time_fmt), Some(time)) = (
575 date_config.and_then(|c| c.time_format.as_ref()),
576 date.time(),
577 ) {
578 let show_secs = date_config.is_some_and(|c| c.show_seconds);
579 let show_tz = date_config.is_some_and(|c| c.show_timezone);
580 let time_str = format_time(
581 time,
582 time_fmt,
583 show_secs,
584 show_tz,
585 locale.dates.am.as_deref(),
586 locale.dates.pm.as_deref(),
587 locale.dates.timezone_utc.as_deref(),
588 );
589 Some(format!("{base}, {time_str}"))
590 } else {
591 Some(base)
592 }
593 }
594 DateForm::YearMonthDay => {
595 let year = extract_year(date);
596 if year.is_empty() {
597 return None;
598 }
599 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
600 let day = date.day();
601 let month_opt = (!month.is_empty()).then_some(month.as_str());
602 if let Some(rendered) = locale.resolve_date_pattern(
603 "pattern.date-year-month-day",
604 Some(&year),
605 month_opt,
606 day,
607 ) {
608 return Some(rendered);
609 }
610 match (month.is_empty(), day) {
611 (true, _) => Some(year),
612 (false, None) => Some(format!("{year}, {month}")),
613 (false, Some(d)) => Some(format!("{year}, {month} {d}")),
614 }
615 }
616 DateForm::DayMonthAbbrYear => {
617 let year = extract_year(date);
618 if year.is_empty() {
619 return None;
620 }
621 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
622 let day = date.day();
623 let month_opt = (!month.is_empty()).then_some(month.as_str());
624 if let Some(rendered) = locale.resolve_date_pattern(
625 "pattern.date-day-month-abbr-year",
626 Some(&year),
627 month_opt,
628 day,
629 ) {
630 return Some(rendered);
631 }
632 match (month.is_empty(), day) {
633 (true, _) => Some(year),
634 (false, None) => Some(format!("{month} {year}")),
635 (false, Some(d)) => Some(format!("{d} {month} {year}")),
636 }
637 }
638 DateForm::MonthAbbrDayYear => {
639 let year = extract_year(date);
640 if year.is_empty() {
641 return None;
642 }
643 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
644 let day = date.day();
645 let month_opt = (!month.is_empty()).then_some(month.as_str());
646 if let Some(rendered) = locale.resolve_date_pattern(
647 "pattern.date-month-abbr-day-year",
648 Some(&year),
649 month_opt,
650 day,
651 ) {
652 return Some(rendered);
653 }
654 match (month.is_empty(), day) {
655 (true, _) => Some(year),
656 (false, None) => Some(format!("{month} {year}")),
657 (false, Some(d)) => Some(format!("{month} {d}, {year}")),
658 }
659 }
660 _ => Some(extract_year(date)),
661 }
662}
663
664impl ComponentValues for TemplateDate {
665 fn values<F: crate::render::format::OutputFormat<Output = String>>(
666 &self,
667 reference: &Reference,
668 hints: &ProcHints,
669 options: &RenderOptions<'_>,
670 ) -> Option<ProcValues<F::Output>> {
671 let fmt = F::default();
672 let date_opt: Option<EdtfString> = match self.date {
673 TemplateDateVar::Issued => reference.effective_issued_date(),
674 TemplateDateVar::Accessed => reference.accessed(),
675 TemplateDateVar::OriginalPublished => reference.original_date(),
676 TemplateDateVar::EventDate => event_date(reference),
677 _ => None,
678 };
679
680 let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
681 if let Some(fallbacks) = &self.fallback {
683 for component in fallbacks {
684 if let Some(values) = component.values::<F>(reference, hints, options) {
685 return Some(values);
686 }
687 }
688 }
689 if matches!(self.date, TemplateDateVar::Issued)
691 && let Some(nd) = options.locale.resolved_general_term(
692 &GeneralTerm::NoDate,
693 &TermForm::Short,
694 None,
695 )
696 {
697 return Some(ProcValues {
698 value: nd,
699 prefix: None,
700 suffix: None,
701 url: None,
702 substituted_key: None,
703 pre_formatted: false,
704 });
705 }
706 return None;
707 };
708
709 let locale = options.locale;
710 let date_config = options.config.dates.as_ref();
711 let effective_form = self.form.clone();
712
713 let formatted = format_date_range(&date, &effective_form, locale, date_config);
714
715 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
717
718 let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
723 .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
724 .flatten();
725
726 formatted.map(|value| {
727 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
728 (
729 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
730 None,
731 )
732 } else {
733 (value, None)
734 };
735
736 ProcValues {
737 value,
738 prefix: None,
739 suffix,
740 url: crate::values::resolve_effective_url(
741 self.links.as_ref(),
742 options.config.links.as_ref(),
743 reference,
744 citum_schema::options::LinkAnchor::Component,
745 ),
746 substituted_key: None,
747 pre_formatted: false,
748 }
749 })
750 }
751}
752
753#[must_use]
755pub fn int_to_letter(n: u32) -> Option<String> {
756 if n == 0 {
757 return None;
758 }
759
760 let mut result = String::new();
761 let mut num = n - 1;
762
763 loop {
764 result.push((b'a' + (num % 26) as u8) as char);
765 if num < 26 {
766 break;
767 }
768 num = num / 26 - 1;
769 }
770
771 Some(result.chars().rev().collect())
772}
773
774#[cfg(test)]
775#[allow(
776 clippy::unwrap_used,
777 clippy::expect_used,
778 clippy::panic,
779 clippy::indexing_slicing,
780 clippy::todo,
781 clippy::unimplemented,
782 clippy::unreachable,
783 clippy::get_unwrap,
784 reason = "Panicking is acceptable and often desired in tests."
785)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn test_int_to_letter() {
791 assert_eq!(int_to_letter(1), Some("a".to_string()));
793 assert_eq!(int_to_letter(2), Some("b".to_string()));
794 assert_eq!(int_to_letter(26), Some("z".to_string()));
795
796 assert_eq!(int_to_letter(27), Some("aa".to_string()));
798 assert_eq!(int_to_letter(52), Some("az".to_string()));
799 assert_eq!(int_to_letter(53), Some("ba".to_string()));
800
801 assert_eq!(int_to_letter(0), None);
803 }
804}
805
806#[cfg(test)]
807#[allow(
808 clippy::unwrap_used,
809 clippy::expect_used,
810 clippy::panic,
811 clippy::indexing_slicing,
812 clippy::todo,
813 clippy::unimplemented,
814 clippy::unreachable,
815 clippy::get_unwrap,
816 reason = "Panicking is acceptable and often desired in tests."
817)]
818mod time_tests {
819 use super::*;
820 use citum_edtf::{Time, Timezone};
821
822 #[test]
823 fn test_format_time_12h_utc() {
824 let time = Time {
825 hour: 23,
826 minute: 20,
827 second: 30,
828 timezone: Some(Timezone::Utc),
829 };
830 let result = format_time(
831 time,
832 &TimeFormat::Hour12,
833 false,
834 true,
835 Some("AM"),
836 Some("PM"),
837 Some("UTC"),
838 );
839 assert_eq!(result, "11:20 PM UTC");
840 }
841
842 #[test]
843 fn test_format_time_24h_utc() {
844 let time = Time {
845 hour: 23,
846 minute: 20,
847 second: 30,
848 timezone: Some(Timezone::Utc),
849 };
850 let result = format_time(
851 time,
852 &TimeFormat::Hour24,
853 false,
854 true,
855 None,
856 None,
857 Some("UTC"),
858 );
859 assert_eq!(result, "23:20 UTC");
860 }
861
862 #[test]
863 fn test_format_time_with_offset() {
864 let time = Time {
865 hour: 10,
866 minute: 10,
867 second: 10,
868 timezone: Some(Timezone::Offset(330)),
869 };
870 let result = format_time(
871 time,
872 &TimeFormat::Hour24,
873 false,
874 true,
875 None,
876 None,
877 Some("UTC"),
878 );
879 assert_eq!(result, "10:10 +05:30");
880 }
881
882 #[test]
883 fn test_format_time_no_timezone() {
884 let time = Time {
885 hour: 14,
886 minute: 30,
887 second: 0,
888 timezone: None,
889 };
890 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
891 assert_eq!(result, "14:30");
892 }
893}
894
895#[cfg(test)]
896#[allow(
897 clippy::unwrap_used,
898 clippy::expect_used,
899 clippy::panic,
900 clippy::indexing_slicing,
901 clippy::todo,
902 clippy::unimplemented,
903 clippy::unreachable,
904 clippy::get_unwrap,
905 reason = "Panicking is acceptable and often desired in tests."
906)]
907mod era_tests {
908 use super::*;
909 use citum_edtf::{UnspecifiedYear, Year};
910 use citum_schema::locale::{DateTerms, Locale};
911 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
912
913 fn en_terms() -> DateTerms {
914 Locale::en_us().dates
915 }
916
917 #[test]
918 fn positive_year_default_no_suffix() {
919 let year = Year {
920 value: 54,
921 unspecified: UnspecifiedYear::None,
922 };
923 let result = format_display_year(
924 &year,
925 &en_terms(),
926 &EraLabels::Default,
927 &NegativeUnspecifiedYears::Range,
928 "–",
929 );
930 assert_eq!(result, "54");
931 }
932
933 #[test]
934 fn positive_year_bc_ad() {
935 let year = Year {
936 value: 54,
937 unspecified: UnspecifiedYear::None,
938 };
939 let result = format_display_year(
940 &year,
941 &en_terms(),
942 &EraLabels::BcAd,
943 &NegativeUnspecifiedYears::Range,
944 "–",
945 );
946 assert_eq!(result, "54 AD");
947 }
948
949 #[test]
950 fn positive_year_bce_ce() {
951 let year = Year {
952 value: 54,
953 unspecified: UnspecifiedYear::None,
954 };
955 let result = format_display_year(
956 &year,
957 &en_terms(),
958 &EraLabels::BceCe,
959 &NegativeUnspecifiedYears::Range,
960 "–",
961 );
962 assert_eq!(result, "54 CE");
963 }
964
965 #[test]
966 fn negative_year_default() {
967 let year = Year {
968 value: -43,
969 unspecified: UnspecifiedYear::None,
970 };
971 let result = format_display_year(
972 &year,
973 &en_terms(),
974 &EraLabels::Default,
975 &NegativeUnspecifiedYears::Range,
976 "–",
977 );
978 assert_eq!(result, "44 BC");
979 }
980
981 #[test]
982 fn negative_year_bc_ad() {
983 let year = Year {
984 value: -43,
985 unspecified: UnspecifiedYear::None,
986 };
987 let result = format_display_year(
988 &year,
989 &en_terms(),
990 &EraLabels::BcAd,
991 &NegativeUnspecifiedYears::Range,
992 "–",
993 );
994 assert_eq!(result, "44 BC");
995 }
996
997 #[test]
998 fn negative_year_bce_ce() {
999 let year = Year {
1000 value: -43,
1001 unspecified: UnspecifiedYear::None,
1002 };
1003 let result = format_display_year(
1004 &year,
1005 &en_terms(),
1006 &EraLabels::BceCe,
1007 &NegativeUnspecifiedYears::Range,
1008 "–",
1009 );
1010 assert_eq!(result, "44 BCE");
1011 }
1012
1013 #[test]
1014 fn positive_unspecified_ones() {
1015 let year = Year {
1016 value: 1990,
1017 unspecified: UnspecifiedYear::One,
1018 };
1019 let result = format_display_year(
1020 &year,
1021 &en_terms(),
1022 &EraLabels::Default,
1023 &NegativeUnspecifiedYears::Range,
1024 "–",
1025 );
1026 assert_eq!(result, "199X");
1027 }
1028
1029 #[test]
1030 fn positive_unspecified_two() {
1031 let year = Year {
1032 value: 1900,
1033 unspecified: UnspecifiedYear::Two,
1034 };
1035 let result = format_display_year(
1036 &year,
1037 &en_terms(),
1038 &EraLabels::Default,
1039 &NegativeUnspecifiedYears::Range,
1040 "–",
1041 );
1042 assert_eq!(result, "19XX");
1043 }
1044
1045 #[test]
1046 fn negative_unspecified_range() {
1047 let year = Year {
1048 value: -90,
1049 unspecified: UnspecifiedYear::One,
1050 };
1051 let result = format_display_year(
1052 &year,
1053 &en_terms(),
1054 &EraLabels::Default,
1055 &NegativeUnspecifiedYears::Range,
1056 "–",
1057 );
1058 assert_eq!(result, "100–91 BC");
1059 }
1060
1061 #[test]
1062 fn negative_unspecified_century() {
1063 let year = Year {
1064 value: 0,
1065 unspecified: UnspecifiedYear::Two,
1066 };
1067 let result = format_display_year(
1068 &year,
1069 &en_terms(),
1070 &EraLabels::Default,
1071 &NegativeUnspecifiedYears::Range,
1072 "–",
1073 );
1074 assert_eq!(result, "100–1 BC");
1075 }
1076
1077 #[test]
1078 fn backwards_compat_negative_year() {
1079 let year = Year {
1080 value: -99,
1081 unspecified: UnspecifiedYear::None,
1082 };
1083 let result = format_display_year(
1084 &year,
1085 &en_terms(),
1086 &EraLabels::Default,
1087 &NegativeUnspecifiedYears::Range,
1088 "–",
1089 );
1090 assert_eq!(result, "100 BC");
1091 }
1092}
1093
1094#[cfg(test)]
1095#[allow(
1096 clippy::unwrap_used,
1097 clippy::expect_used,
1098 reason = "Panicking is acceptable in tests."
1099)]
1100mod locale_pattern_tests {
1101 use super::*;
1102 use citum_schema::locale::Locale;
1103
1104 fn en_us() -> Locale {
1105 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1106 .expect("en-US locale should parse")
1107 }
1108
1109 fn es_es() -> Locale {
1110 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1111 .expect("es-ES locale should parse")
1112 }
1113
1114 fn eu_es() -> Locale {
1115 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1116 .expect("eu-ES locale should parse")
1117 }
1118
1119 fn full(locale: &Locale, edtf: &str) -> String {
1120 format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1121 .expect("date should render")
1122 }
1123
1124 fn month_day(locale: &Locale, edtf: &str) -> String {
1125 format_single_date(
1126 &EdtfString(edtf.to_string()),
1127 &DateForm::MonthDay,
1128 locale,
1129 None,
1130 )
1131 .expect("date should render")
1132 }
1133
1134 #[test]
1135 fn en_us_full_unchanged_by_pattern_machinery() {
1136 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1139 }
1140
1141 #[test]
1142 fn en_us_month_day_unchanged_by_pattern_machinery() {
1143 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1144 }
1145
1146 #[test]
1147 fn en_us_month_form_renders_month_name_only() {
1148 let out = format_single_date(
1150 &EdtfString("2023-06".to_string()),
1151 &DateForm::Month,
1152 &en_us(),
1153 None,
1154 );
1155 assert_eq!(out.as_deref(), Some("June"));
1157 }
1158
1159 #[test]
1160 fn en_us_month_form_renders_season_name() {
1161 let out = format_single_date(
1163 &EdtfString("2023-21".to_string()),
1164 &DateForm::Month,
1165 &en_us(),
1166 None,
1167 );
1168 assert_eq!(out.as_deref(), Some("Spring"));
1170 }
1171
1172 #[test]
1173 fn en_us_year_month_form_renders_season_and_year() {
1174 let out = format_single_date(
1175 &EdtfString("2023-21".to_string()),
1176 &DateForm::YearMonth,
1177 &en_us(),
1178 None,
1179 );
1180 assert_eq!(out.as_deref(), Some("Spring 2023"));
1181 }
1182
1183 #[test]
1184 fn en_us_full_form_renders_season_and_year() {
1185 assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1186 }
1187
1188 #[test]
1189 fn es_es_year_month_form_renders_localized_season() {
1190 let out = format_single_date(
1191 &EdtfString("2023-23".to_string()),
1192 &DateForm::YearMonth,
1193 &es_es(),
1194 None,
1195 );
1196 assert_eq!(out.as_deref(), Some("otoño de 2023"));
1197 }
1198
1199 #[test]
1200 fn es_es_full_uses_locale_pattern() {
1201 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1203 }
1204
1205 #[test]
1206 fn es_es_month_day_uses_locale_pattern() {
1207 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1208 }
1209
1210 #[test]
1211 fn eu_es_full_uses_locale_pattern() {
1212 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1215 }
1216
1217 #[test]
1218 fn eu_es_month_day_uses_locale_pattern() {
1219 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1220 }
1221
1222 fn year_month(locale: &Locale, edtf: &str) -> String {
1223 format_single_date(
1224 &EdtfString(edtf.to_string()),
1225 &DateForm::YearMonth,
1226 locale,
1227 None,
1228 )
1229 .expect("date should render")
1230 }
1231
1232 fn year_month_day(locale: &Locale, edtf: &str) -> String {
1233 format_single_date(
1234 &EdtfString(edtf.to_string()),
1235 &DateForm::YearMonthDay,
1236 locale,
1237 None,
1238 )
1239 .expect("date should render")
1240 }
1241
1242 fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1243 format_single_date(
1244 &EdtfString(edtf.to_string()),
1245 &DateForm::DayMonthAbbrYear,
1246 locale,
1247 None,
1248 )
1249 .expect("date should render")
1250 }
1251
1252 fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1253 format_single_date(
1254 &EdtfString(edtf.to_string()),
1255 &DateForm::MonthAbbrDayYear,
1256 locale,
1257 None,
1258 )
1259 .expect("date should render")
1260 }
1261
1262 #[test]
1263 fn en_us_year_month_unchanged_by_pattern_machinery() {
1264 assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1266 }
1267
1268 #[test]
1269 fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1270 assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1271 }
1272
1273 #[test]
1274 fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1275 assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1276 }
1277
1278 #[test]
1279 fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1280 assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1281 }
1282
1283 #[test]
1284 fn es_es_year_month_uses_locale_pattern() {
1285 assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1287 }
1288
1289 #[test]
1290 fn eu_es_year_month_uses_locale_pattern() {
1291 assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1293 }
1294
1295 #[test]
1296 fn year_month_missing_month_falls_back_to_year() {
1297 assert_eq!(year_month(&es_es(), "2023"), "2023");
1299 }
1300
1301 #[test]
1302 fn es_es_year_month_day_uses_locale_pattern() {
1303 assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1305 }
1306
1307 #[test]
1308 fn es_es_year_month_day_missing_day_falls_back() {
1309 assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1312 }
1313
1314 #[test]
1315 fn es_es_day_month_abbr_year_uses_locale_pattern() {
1316 assert_eq!(
1318 day_month_abbr_year(&es_es(), "2023-01-12"),
1319 "12 ene. de 2023"
1320 );
1321 }
1322
1323 #[test]
1324 fn es_es_day_month_abbr_year_missing_day_falls_back() {
1325 assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1327 }
1328
1329 #[test]
1330 fn es_es_month_abbr_day_year_uses_locale_pattern() {
1331 assert_eq!(
1333 month_abbr_day_year(&es_es(), "2023-01-12"),
1334 "ene. 12 de 2023"
1335 );
1336 }
1337
1338 #[test]
1339 fn es_es_month_abbr_day_year_missing_day_falls_back() {
1340 assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1342 }
1343
1344 #[test]
1345 fn pattern_missing_day_falls_back_to_english_assembly() {
1346 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1351 }
1352}
1353
1354#[cfg(test)]
1355#[allow(
1356 clippy::unwrap_used,
1357 clippy::expect_used,
1358 reason = "Panicking is acceptable in tests."
1359)]
1360mod range_tests {
1361 use super::*;
1362 use citum_schema::locale::Locale;
1363
1364 fn en_us() -> Locale {
1365 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1366 .expect("en-US locale should parse")
1367 }
1368
1369 fn es_es() -> Locale {
1370 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1371 .expect("es-ES locale should parse")
1372 }
1373
1374 fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1375 format_date_range(&EdtfString(edtf.to_string()), &form, locale, None)
1376 }
1377
1378 #[test]
1379 fn closed_range_year_form_regression() {
1380 assert_eq!(
1383 range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1384 Some("2020–2022")
1385 );
1386 }
1387
1388 #[test]
1389 fn closed_range_full_form_different_years() {
1390 assert_eq!(
1393 range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
1394 Some("May 14, 2023–June 2, 2024")
1395 );
1396 }
1397
1398 #[test]
1399 fn closed_range_full_form_same_year_collapses() {
1400 assert_eq!(
1403 range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
1404 Some("May 14–June 2, 2023")
1405 );
1406 }
1407
1408 #[test]
1409 fn closed_range_year_month_day_same_year_collapses() {
1410 assert_eq!(
1413 range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
1414 Some("2023, May 14–June 2")
1415 );
1416 }
1417
1418 #[test]
1419 fn closed_range_full_form_es_es_locale_pattern() {
1420 assert_eq!(
1424 range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
1425 Some("12 de enero de 2023–3 de febrero de 2024")
1426 );
1427 }
1428
1429 #[test]
1430 fn interval_to_year_form() {
1431 assert_eq!(
1434 range(&en_us(), "../2020", DateForm::Year).as_deref(),
1435 Some("2020")
1436 );
1437 }
1438
1439 #[test]
1440 fn closed_range_year_month_same_year_collapses() {
1441 assert_eq!(
1444 range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
1445 Some("May–June 2023")
1446 );
1447 }
1448
1449 #[test]
1450 fn closed_range_season_same_year_collapses() {
1451 assert_eq!(
1454 range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
1455 Some("Spring–Summer 2023")
1456 );
1457 }
1458}