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 compute_disamb_suffix_label(hints, options, fmt)
409 } else {
410 None
411 }
412}
413
414fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
415 hints: &ProcHints,
416 options: &RenderOptions<'_>,
417 fmt: &F,
418) -> Option<String> {
419 let use_suffix = options
422 .config
423 .effective_processing()
424 .config()
425 .disambiguate
426 .as_ref()
427 .is_some_and(|d| d.year_suffix);
428
429 if hints.disamb_condition && use_suffix {
430 int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
431 } else {
432 None
433 }
434}
435
436fn date_form_displays_year(form: &DateForm) -> bool {
437 !matches!(form, DateForm::MonthDay)
438}
439
440fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
441 let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
442 date_config.no_date_year_suffix_delimiter.as_str()
443 });
444 value.push_str(delimiter);
445 value.push_str(suffix);
446}
447
448fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
449 if year.is_empty() || suffix.is_empty() {
450 return formatted.to_string();
451 }
452
453 let year_index = match form {
454 DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
455 DateForm::YearMonth
456 | DateForm::Full
457 | DateForm::DayMonthAbbrYear
458 | DateForm::MonthAbbrDayYear => formatted.rfind(year),
459 DateForm::MonthDay => None,
460 _ => None,
461 };
462
463 let Some(index) = year_index else {
464 return format!("{formatted}{suffix}");
465 };
466
467 let year_end = index + year.len();
468 #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
469 let result = format!(
470 "{}{}{}{}",
471 &formatted[..index],
472 year,
473 suffix,
474 &formatted[year_end..]
475 );
476 result
477}
478
479#[allow(
481 clippy::too_many_lines,
482 reason = "date formatting handles 6 form variants"
483)]
484fn format_single_date(
485 date: &EdtfString,
486 form: &DateForm,
487 locale: &citum_schema::locale::Locale,
488 date_config: Option<&citum_schema::options::dates::DateConfig>,
489) -> Option<String> {
490 let default_era = citum_schema::options::dates::EraLabels::Default;
491 let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
492 let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
493 let neg_unspecified = date_config
494 .map(|c| &c.negative_unspecified_years)
495 .unwrap_or(&default_neg_unspec);
496 let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
497
498 let extract_year = |d: &EdtfString| -> String {
499 match d.parse() {
500 RefDate::Edtf(edtf) => match edtf {
501 Edtf::Date(dt) => format_display_year(
502 &dt.year,
503 &locale.dates,
504 era_labels,
505 neg_unspecified,
506 range_delimiter,
507 ),
508 Edtf::Interval(interval) => format_display_year(
509 &interval.start.year,
510 &locale.dates,
511 era_labels,
512 neg_unspecified,
513 range_delimiter,
514 ),
515 Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
516 &dt.year,
517 &locale.dates,
518 era_labels,
519 neg_unspecified,
520 range_delimiter,
521 ),
522 },
523 RefDate::Literal(_) => String::new(),
524 }
525 };
526
527 match form {
528 DateForm::Year => {
529 let year = extract_year(date);
530 if year.is_empty() { None } else { Some(year) }
531 }
532 DateForm::YearMonth => {
533 let year = extract_year(date);
534 if year.is_empty() {
535 return None;
536 }
537 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
538 let month_opt = (!month.is_empty()).then_some(month.as_str());
539 if let Some(rendered) =
540 locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
541 {
542 return Some(rendered);
543 }
544 if month.is_empty() {
545 Some(year)
546 } else {
547 Some(format!("{month} {year}"))
548 }
549 }
550 DateForm::Month => {
551 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
552 if month.is_empty() { None } else { Some(month) }
553 }
554 DateForm::MonthDay => {
555 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
556 if month.is_empty() {
557 return None;
558 }
559 let day = date.day();
560 if let Some(rendered) =
561 locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
562 {
563 return Some(rendered);
564 }
565 match day {
566 Some(d) => Some(format!("{month} {d}")),
567 None => Some(month),
568 }
569 }
570 DateForm::Full => {
571 let year = extract_year(date);
572 if year.is_empty() {
573 return None;
574 }
575 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
576 let day = date.day();
577 let base = locale
578 .resolve_date_pattern(
579 "pattern.date-full",
580 Some(&year),
581 (!month.is_empty()).then_some(month.as_str()),
582 day,
583 )
584 .unwrap_or_else(|| match (month.is_empty(), day) {
585 (true, _) => year.clone(),
586 (false, None) => format!("{month} {year}"),
587 (false, Some(d)) => format!("{month} {d}, {year}"),
588 });
589 if let (Some(time_fmt), Some(time)) = (
591 date_config.and_then(|c| c.time_format.as_ref()),
592 date.time(),
593 ) {
594 let show_secs = date_config.is_some_and(|c| c.show_seconds);
595 let show_tz = date_config.is_some_and(|c| c.show_timezone);
596 let time_str = format_time(
597 time,
598 time_fmt,
599 show_secs,
600 show_tz,
601 locale.dates.am.as_deref(),
602 locale.dates.pm.as_deref(),
603 locale.dates.timezone_utc.as_deref(),
604 );
605 Some(format!("{base}, {time_str}"))
606 } else {
607 Some(base)
608 }
609 }
610 DateForm::YearMonthDay => {
611 let year = extract_year(date);
612 if year.is_empty() {
613 return None;
614 }
615 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
616 let day = date.day();
617 let month_opt = (!month.is_empty()).then_some(month.as_str());
618 if let Some(rendered) = locale.resolve_date_pattern(
619 "pattern.date-year-month-day",
620 Some(&year),
621 month_opt,
622 day,
623 ) {
624 return Some(rendered);
625 }
626 match (month.is_empty(), day) {
627 (true, _) => Some(year),
628 (false, None) => Some(format!("{year}, {month}")),
629 (false, Some(d)) => Some(format!("{year}, {month} {d}")),
630 }
631 }
632 DateForm::DayMonthAbbrYear => {
633 let year = extract_year(date);
634 if year.is_empty() {
635 return None;
636 }
637 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
638 let day = date.day();
639 let month_opt = (!month.is_empty()).then_some(month.as_str());
640 if let Some(rendered) = locale.resolve_date_pattern(
641 "pattern.date-day-month-abbr-year",
642 Some(&year),
643 month_opt,
644 day,
645 ) {
646 return Some(rendered);
647 }
648 match (month.is_empty(), day) {
649 (true, _) => Some(year),
650 (false, None) => Some(format!("{month} {year}")),
651 (false, Some(d)) => Some(format!("{d} {month} {year}")),
652 }
653 }
654 DateForm::MonthAbbrDayYear => {
655 let year = extract_year(date);
656 if year.is_empty() {
657 return None;
658 }
659 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
660 let day = date.day();
661 let month_opt = (!month.is_empty()).then_some(month.as_str());
662 if let Some(rendered) = locale.resolve_date_pattern(
663 "pattern.date-month-abbr-day-year",
664 Some(&year),
665 month_opt,
666 day,
667 ) {
668 return Some(rendered);
669 }
670 match (month.is_empty(), day) {
671 (true, _) => Some(year),
672 (false, None) => Some(format!("{month} {year}")),
673 (false, Some(d)) => Some(format!("{month} {d}, {year}")),
674 }
675 }
676 _ => Some(extract_year(date)),
677 }
678}
679
680impl ComponentValues for TemplateDate {
681 fn values<F: crate::render::format::OutputFormat<Output = String>>(
682 &self,
683 reference: &Reference,
684 hints: &ProcHints,
685 options: &RenderOptions<'_>,
686 ) -> Option<ProcValues<F::Output>> {
687 let fmt = F::default();
688 let date_opt: Option<EdtfString> = match self.date {
689 TemplateDateVar::Issued => reference.effective_issued_date(),
690 TemplateDateVar::Accessed => reference.accessed(),
691 TemplateDateVar::OriginalPublished => reference.original_date(),
692 TemplateDateVar::EventDate => event_date(reference),
693 _ => None,
694 };
695
696 let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
697 if let Some(fallbacks) = &self.fallback {
699 for component in fallbacks {
700 if let Some(values) = component.values::<F>(reference, hints, options) {
701 return Some(values);
702 }
703 }
704 }
705 if matches!(self.date, TemplateDateVar::Issued)
707 && let Some(mut nd) = options.locale.resolved_general_term(
708 &GeneralTerm::NoDate,
709 &TermForm::Short,
710 None,
711 )
712 {
713 if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
714 append_no_date_disamb_suffix(&mut nd, &suffix, options);
715 }
716 return Some(ProcValues {
717 value: nd,
718 prefix: None,
719 suffix: None,
720 url: None,
721 substituted_key: None,
722 pre_formatted: false,
723 });
724 }
725 return None;
726 };
727
728 let locale = options.locale;
729 let date_config = options.config.dates.as_ref();
730 let effective_form = self.form.clone();
731
732 let formatted = format_date_range(&date, &effective_form, locale, date_config);
733
734 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
736
737 let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
742 .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
743 .flatten();
744
745 formatted.map(|value| {
746 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
747 (
748 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
749 None,
750 )
751 } else {
752 (value, None)
753 };
754
755 ProcValues {
756 value,
757 prefix: None,
758 suffix,
759 url: crate::values::resolve_effective_url(
760 self.links.as_ref(),
761 options.config.links.as_ref(),
762 reference,
763 citum_schema::options::LinkAnchor::Component,
764 ),
765 substituted_key: None,
766 pre_formatted: false,
767 }
768 })
769 }
770}
771
772#[must_use]
774pub fn int_to_letter(n: u32) -> Option<String> {
775 if n == 0 {
776 return None;
777 }
778
779 let mut result = String::new();
780 let mut num = n - 1;
781
782 loop {
783 result.push((b'a' + (num % 26) as u8) as char);
784 if num < 26 {
785 break;
786 }
787 num = num / 26 - 1;
788 }
789
790 Some(result.chars().rev().collect())
791}
792
793#[cfg(test)]
794#[allow(
795 clippy::unwrap_used,
796 clippy::expect_used,
797 clippy::panic,
798 clippy::indexing_slicing,
799 clippy::todo,
800 clippy::unimplemented,
801 clippy::unreachable,
802 clippy::get_unwrap,
803 reason = "Panicking is acceptable and often desired in tests."
804)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn test_int_to_letter() {
810 assert_eq!(int_to_letter(1), Some("a".to_string()));
812 assert_eq!(int_to_letter(2), Some("b".to_string()));
813 assert_eq!(int_to_letter(26), Some("z".to_string()));
814
815 assert_eq!(int_to_letter(27), Some("aa".to_string()));
817 assert_eq!(int_to_letter(52), Some("az".to_string()));
818 assert_eq!(int_to_letter(53), Some("ba".to_string()));
819
820 assert_eq!(int_to_letter(0), None);
822 }
823}
824
825#[cfg(test)]
826#[allow(
827 clippy::unwrap_used,
828 clippy::expect_used,
829 clippy::panic,
830 clippy::indexing_slicing,
831 clippy::todo,
832 clippy::unimplemented,
833 clippy::unreachable,
834 clippy::get_unwrap,
835 reason = "Panicking is acceptable and often desired in tests."
836)]
837mod time_tests {
838 use super::*;
839 use citum_edtf::{Time, Timezone};
840
841 #[test]
842 fn test_format_time_12h_utc() {
843 let time = Time {
844 hour: 23,
845 minute: 20,
846 second: 30,
847 timezone: Some(Timezone::Utc),
848 };
849 let result = format_time(
850 time,
851 &TimeFormat::Hour12,
852 false,
853 true,
854 Some("AM"),
855 Some("PM"),
856 Some("UTC"),
857 );
858 assert_eq!(result, "11:20 PM UTC");
859 }
860
861 #[test]
862 fn test_format_time_24h_utc() {
863 let time = Time {
864 hour: 23,
865 minute: 20,
866 second: 30,
867 timezone: Some(Timezone::Utc),
868 };
869 let result = format_time(
870 time,
871 &TimeFormat::Hour24,
872 false,
873 true,
874 None,
875 None,
876 Some("UTC"),
877 );
878 assert_eq!(result, "23:20 UTC");
879 }
880
881 #[test]
882 fn test_format_time_with_offset() {
883 let time = Time {
884 hour: 10,
885 minute: 10,
886 second: 10,
887 timezone: Some(Timezone::Offset(330)),
888 };
889 let result = format_time(
890 time,
891 &TimeFormat::Hour24,
892 false,
893 true,
894 None,
895 None,
896 Some("UTC"),
897 );
898 assert_eq!(result, "10:10 +05:30");
899 }
900
901 #[test]
902 fn test_format_time_no_timezone() {
903 let time = Time {
904 hour: 14,
905 minute: 30,
906 second: 0,
907 timezone: None,
908 };
909 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
910 assert_eq!(result, "14:30");
911 }
912}
913
914#[cfg(test)]
915#[allow(
916 clippy::unwrap_used,
917 clippy::expect_used,
918 clippy::panic,
919 clippy::indexing_slicing,
920 clippy::todo,
921 clippy::unimplemented,
922 clippy::unreachable,
923 clippy::get_unwrap,
924 reason = "Panicking is acceptable and often desired in tests."
925)]
926mod era_tests {
927 use super::*;
928 use citum_edtf::{UnspecifiedYear, Year};
929 use citum_schema::locale::{DateTerms, Locale};
930 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
931
932 fn en_terms() -> DateTerms {
933 Locale::en_us().dates
934 }
935
936 #[test]
937 fn positive_year_default_no_suffix() {
938 let year = Year {
939 value: 54,
940 unspecified: UnspecifiedYear::None,
941 };
942 let result = format_display_year(
943 &year,
944 &en_terms(),
945 &EraLabels::Default,
946 &NegativeUnspecifiedYears::Range,
947 "–",
948 );
949 assert_eq!(result, "54");
950 }
951
952 #[test]
953 fn positive_year_bc_ad() {
954 let year = Year {
955 value: 54,
956 unspecified: UnspecifiedYear::None,
957 };
958 let result = format_display_year(
959 &year,
960 &en_terms(),
961 &EraLabels::BcAd,
962 &NegativeUnspecifiedYears::Range,
963 "–",
964 );
965 assert_eq!(result, "54 AD");
966 }
967
968 #[test]
969 fn positive_year_bce_ce() {
970 let year = Year {
971 value: 54,
972 unspecified: UnspecifiedYear::None,
973 };
974 let result = format_display_year(
975 &year,
976 &en_terms(),
977 &EraLabels::BceCe,
978 &NegativeUnspecifiedYears::Range,
979 "–",
980 );
981 assert_eq!(result, "54 CE");
982 }
983
984 #[test]
985 fn negative_year_default() {
986 let year = Year {
987 value: -43,
988 unspecified: UnspecifiedYear::None,
989 };
990 let result = format_display_year(
991 &year,
992 &en_terms(),
993 &EraLabels::Default,
994 &NegativeUnspecifiedYears::Range,
995 "–",
996 );
997 assert_eq!(result, "44 BC");
998 }
999
1000 #[test]
1001 fn negative_year_bc_ad() {
1002 let year = Year {
1003 value: -43,
1004 unspecified: UnspecifiedYear::None,
1005 };
1006 let result = format_display_year(
1007 &year,
1008 &en_terms(),
1009 &EraLabels::BcAd,
1010 &NegativeUnspecifiedYears::Range,
1011 "–",
1012 );
1013 assert_eq!(result, "44 BC");
1014 }
1015
1016 #[test]
1017 fn negative_year_bce_ce() {
1018 let year = Year {
1019 value: -43,
1020 unspecified: UnspecifiedYear::None,
1021 };
1022 let result = format_display_year(
1023 &year,
1024 &en_terms(),
1025 &EraLabels::BceCe,
1026 &NegativeUnspecifiedYears::Range,
1027 "–",
1028 );
1029 assert_eq!(result, "44 BCE");
1030 }
1031
1032 #[test]
1033 fn positive_unspecified_ones() {
1034 let year = Year {
1035 value: 1990,
1036 unspecified: UnspecifiedYear::One,
1037 };
1038 let result = format_display_year(
1039 &year,
1040 &en_terms(),
1041 &EraLabels::Default,
1042 &NegativeUnspecifiedYears::Range,
1043 "–",
1044 );
1045 assert_eq!(result, "199X");
1046 }
1047
1048 #[test]
1049 fn positive_unspecified_two() {
1050 let year = Year {
1051 value: 1900,
1052 unspecified: UnspecifiedYear::Two,
1053 };
1054 let result = format_display_year(
1055 &year,
1056 &en_terms(),
1057 &EraLabels::Default,
1058 &NegativeUnspecifiedYears::Range,
1059 "–",
1060 );
1061 assert_eq!(result, "19XX");
1062 }
1063
1064 #[test]
1065 fn negative_unspecified_range() {
1066 let year = Year {
1067 value: -90,
1068 unspecified: UnspecifiedYear::One,
1069 };
1070 let result = format_display_year(
1071 &year,
1072 &en_terms(),
1073 &EraLabels::Default,
1074 &NegativeUnspecifiedYears::Range,
1075 "–",
1076 );
1077 assert_eq!(result, "100–91 BC");
1078 }
1079
1080 #[test]
1081 fn negative_unspecified_century() {
1082 let year = Year {
1083 value: 0,
1084 unspecified: UnspecifiedYear::Two,
1085 };
1086 let result = format_display_year(
1087 &year,
1088 &en_terms(),
1089 &EraLabels::Default,
1090 &NegativeUnspecifiedYears::Range,
1091 "–",
1092 );
1093 assert_eq!(result, "100–1 BC");
1094 }
1095
1096 #[test]
1097 fn backwards_compat_negative_year() {
1098 let year = Year {
1099 value: -99,
1100 unspecified: UnspecifiedYear::None,
1101 };
1102 let result = format_display_year(
1103 &year,
1104 &en_terms(),
1105 &EraLabels::Default,
1106 &NegativeUnspecifiedYears::Range,
1107 "–",
1108 );
1109 assert_eq!(result, "100 BC");
1110 }
1111}
1112
1113#[cfg(test)]
1114#[allow(
1115 clippy::unwrap_used,
1116 clippy::expect_used,
1117 reason = "Panicking is acceptable in tests."
1118)]
1119mod locale_pattern_tests {
1120 use super::*;
1121 use citum_schema::locale::Locale;
1122
1123 fn en_us() -> Locale {
1124 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1125 .expect("en-US locale should parse")
1126 }
1127
1128 fn es_es() -> Locale {
1129 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1130 .expect("es-ES locale should parse")
1131 }
1132
1133 fn eu_es() -> Locale {
1134 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1135 .expect("eu-ES locale should parse")
1136 }
1137
1138 fn full(locale: &Locale, edtf: &str) -> String {
1139 format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1140 .expect("date should render")
1141 }
1142
1143 fn month_day(locale: &Locale, edtf: &str) -> String {
1144 format_single_date(
1145 &EdtfString(edtf.to_string()),
1146 &DateForm::MonthDay,
1147 locale,
1148 None,
1149 )
1150 .expect("date should render")
1151 }
1152
1153 #[test]
1154 fn en_us_full_unchanged_by_pattern_machinery() {
1155 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1158 }
1159
1160 #[test]
1161 fn en_us_month_day_unchanged_by_pattern_machinery() {
1162 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1163 }
1164
1165 #[test]
1166 fn en_us_month_form_renders_month_name_only() {
1167 let out = format_single_date(
1169 &EdtfString("2023-06".to_string()),
1170 &DateForm::Month,
1171 &en_us(),
1172 None,
1173 );
1174 assert_eq!(out.as_deref(), Some("June"));
1176 }
1177
1178 #[test]
1179 fn en_us_month_form_renders_season_name() {
1180 let out = format_single_date(
1182 &EdtfString("2023-21".to_string()),
1183 &DateForm::Month,
1184 &en_us(),
1185 None,
1186 );
1187 assert_eq!(out.as_deref(), Some("Spring"));
1189 }
1190
1191 #[test]
1192 fn en_us_year_month_form_renders_season_and_year() {
1193 let out = format_single_date(
1194 &EdtfString("2023-21".to_string()),
1195 &DateForm::YearMonth,
1196 &en_us(),
1197 None,
1198 );
1199 assert_eq!(out.as_deref(), Some("Spring 2023"));
1200 }
1201
1202 #[test]
1203 fn en_us_full_form_renders_season_and_year() {
1204 assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1205 }
1206
1207 #[test]
1208 fn es_es_year_month_form_renders_localized_season() {
1209 let out = format_single_date(
1210 &EdtfString("2023-23".to_string()),
1211 &DateForm::YearMonth,
1212 &es_es(),
1213 None,
1214 );
1215 assert_eq!(out.as_deref(), Some("otoño de 2023"));
1216 }
1217
1218 #[test]
1219 fn es_es_full_uses_locale_pattern() {
1220 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1222 }
1223
1224 #[test]
1225 fn es_es_month_day_uses_locale_pattern() {
1226 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1227 }
1228
1229 #[test]
1230 fn eu_es_full_uses_locale_pattern() {
1231 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1234 }
1235
1236 #[test]
1237 fn eu_es_month_day_uses_locale_pattern() {
1238 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1239 }
1240
1241 fn year_month(locale: &Locale, edtf: &str) -> String {
1242 format_single_date(
1243 &EdtfString(edtf.to_string()),
1244 &DateForm::YearMonth,
1245 locale,
1246 None,
1247 )
1248 .expect("date should render")
1249 }
1250
1251 fn year_month_day(locale: &Locale, edtf: &str) -> String {
1252 format_single_date(
1253 &EdtfString(edtf.to_string()),
1254 &DateForm::YearMonthDay,
1255 locale,
1256 None,
1257 )
1258 .expect("date should render")
1259 }
1260
1261 fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1262 format_single_date(
1263 &EdtfString(edtf.to_string()),
1264 &DateForm::DayMonthAbbrYear,
1265 locale,
1266 None,
1267 )
1268 .expect("date should render")
1269 }
1270
1271 fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1272 format_single_date(
1273 &EdtfString(edtf.to_string()),
1274 &DateForm::MonthAbbrDayYear,
1275 locale,
1276 None,
1277 )
1278 .expect("date should render")
1279 }
1280
1281 #[test]
1282 fn en_us_year_month_unchanged_by_pattern_machinery() {
1283 assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1285 }
1286
1287 #[test]
1288 fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1289 assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1290 }
1291
1292 #[test]
1293 fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1294 assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1295 }
1296
1297 #[test]
1298 fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1299 assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1300 }
1301
1302 #[test]
1303 fn es_es_year_month_uses_locale_pattern() {
1304 assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1306 }
1307
1308 #[test]
1309 fn eu_es_year_month_uses_locale_pattern() {
1310 assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1312 }
1313
1314 #[test]
1315 fn year_month_missing_month_falls_back_to_year() {
1316 assert_eq!(year_month(&es_es(), "2023"), "2023");
1318 }
1319
1320 #[test]
1321 fn es_es_year_month_day_uses_locale_pattern() {
1322 assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1324 }
1325
1326 #[test]
1327 fn es_es_year_month_day_missing_day_falls_back() {
1328 assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1331 }
1332
1333 #[test]
1334 fn es_es_day_month_abbr_year_uses_locale_pattern() {
1335 assert_eq!(
1337 day_month_abbr_year(&es_es(), "2023-01-12"),
1338 "12 ene. de 2023"
1339 );
1340 }
1341
1342 #[test]
1343 fn es_es_day_month_abbr_year_missing_day_falls_back() {
1344 assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1346 }
1347
1348 #[test]
1349 fn es_es_month_abbr_day_year_uses_locale_pattern() {
1350 assert_eq!(
1352 month_abbr_day_year(&es_es(), "2023-01-12"),
1353 "ene. 12 de 2023"
1354 );
1355 }
1356
1357 #[test]
1358 fn es_es_month_abbr_day_year_missing_day_falls_back() {
1359 assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1361 }
1362
1363 #[test]
1364 fn pattern_missing_day_falls_back_to_english_assembly() {
1365 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1370 }
1371}
1372
1373#[cfg(test)]
1374#[allow(
1375 clippy::unwrap_used,
1376 clippy::expect_used,
1377 reason = "Panicking is acceptable in tests."
1378)]
1379mod range_tests {
1380 use super::*;
1381 use citum_schema::locale::Locale;
1382
1383 fn en_us() -> Locale {
1384 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1385 .expect("en-US locale should parse")
1386 }
1387
1388 fn es_es() -> Locale {
1389 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1390 .expect("es-ES locale should parse")
1391 }
1392
1393 fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1394 format_date_range(&EdtfString(edtf.to_string()), &form, locale, None)
1395 }
1396
1397 #[test]
1398 fn closed_range_year_form_regression() {
1399 assert_eq!(
1402 range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1403 Some("2020–2022")
1404 );
1405 }
1406
1407 #[test]
1408 fn closed_range_full_form_different_years() {
1409 assert_eq!(
1412 range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
1413 Some("May 14, 2023–June 2, 2024")
1414 );
1415 }
1416
1417 #[test]
1418 fn closed_range_full_form_same_year_collapses() {
1419 assert_eq!(
1422 range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
1423 Some("May 14–June 2, 2023")
1424 );
1425 }
1426
1427 #[test]
1428 fn closed_range_year_month_day_same_year_collapses() {
1429 assert_eq!(
1432 range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
1433 Some("2023, May 14–June 2")
1434 );
1435 }
1436
1437 #[test]
1438 fn closed_range_full_form_es_es_locale_pattern() {
1439 assert_eq!(
1443 range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
1444 Some("12 de enero de 2023–3 de febrero de 2024")
1445 );
1446 }
1447
1448 #[test]
1449 fn interval_to_year_form() {
1450 assert_eq!(
1453 range(&en_us(), "../2020", DateForm::Year).as_deref(),
1454 Some("2020")
1455 );
1456 }
1457
1458 #[test]
1459 fn closed_range_year_month_same_year_collapses() {
1460 assert_eq!(
1463 range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
1464 Some("May–June 2023")
1465 );
1466 }
1467
1468 #[test]
1469 fn closed_range_season_same_year_collapses() {
1470 assert_eq!(
1473 range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
1474 Some("Spring–Summer 2023")
1475 );
1476 }
1477}