1use crate::reference::{EdtfString, Reference};
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_edtf::{Day, Edtf, MonthOrSeason, Timezone, UnspecifiedYear, Year};
14use citum_schema::locale::{GeneralTerm, TermForm};
15use citum_schema::options::dates::TimeFormat;
16use citum_schema::reference::types::RefDate;
17use citum_schema::template::{DateForm, DateVariable as TemplateDateVar, TemplateDate};
18
19fn month_to_string(month: u32, months: &[String]) -> String {
20 if month > 0 {
21 let index = month - 1;
22 if let Some(month_name) = months.get(index as usize) {
23 month_name.clone()
24 } else {
25 String::new()
26 }
27 } else {
28 String::new()
29 }
30}
31
32fn extract_month(date: &EdtfString, months: &[String]) -> String {
33 let parsed_date = date.parse();
34 let month: Option<u32> = match parsed_date {
35 RefDate::Edtf(edtf) => edtf.month(),
36 RefDate::Literal(_) => None,
37 };
38 match month {
39 Some(month) => month_to_string(month, months),
40 None => String::new(),
41 }
42}
43
44fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
46 match u {
47 UnspecifiedYear::None => 0,
48 UnspecifiedYear::One => 9,
49 UnspecifiedYear::Two => 99,
50 UnspecifiedYear::Three => 999,
51 UnspecifiedYear::Four => 9999,
52 }
53}
54
55fn format_display_year(
57 year: &Year,
58 date_terms: &citum_schema::locale::DateTerms,
59 era_labels: &citum_schema::options::dates::EraLabels,
60 _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
61 range_delimiter: &str,
62) -> String {
63 if year.unspecified != UnspecifiedYear::None && year.value > 0 {
65 let mut s = year.value.to_string();
66 let unspec_count = match year.unspecified {
67 UnspecifiedYear::One => 1,
68 UnspecifiedYear::Two => 2,
69 UnspecifiedYear::Three => 3,
70 UnspecifiedYear::Four => 4,
71 _ => 0,
72 };
73 for _ in 0..unspec_count {
74 if let Some(last) = s.pop()
75 && last != '0'
76 {
77 s.push('X');
78 }
79 }
80 if s.len() < year.value.to_string().len() {
81 let diff = year.value.to_string().len() - s.len();
82 for _ in 0..diff {
83 s.push('X');
84 }
85 }
86 return s;
87 }
88
89 if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
91 let delta = unspecified_year_delta(&year.unspecified);
92 let astronomical_min = year.value - delta;
93 let astronomical_max = year.value;
94 let historical_end = 1 - astronomical_max;
95 let historical_start = 1 - astronomical_min;
96
97 let era_term = match era_labels {
98 citum_schema::options::dates::EraLabels::Default => {
99 date_terms.before_era.as_deref().unwrap_or("")
100 }
101 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
102 citum_schema::options::dates::EraLabels::BceCe => {
103 date_terms.bce.as_deref().unwrap_or("")
104 }
105 };
106
107 if era_term.is_empty() {
108 format!("{historical_start}{range_delimiter}{historical_end}")
109 } else {
110 format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
111 }
112 } else if year.value <= 0 {
113 let historical_year = 1 - year.value;
115 let era_term = match era_labels {
116 citum_schema::options::dates::EraLabels::Default => {
117 date_terms.before_era.as_deref().unwrap_or("")
118 }
119 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
120 citum_schema::options::dates::EraLabels::BceCe => {
121 date_terms.bce.as_deref().unwrap_or("")
122 }
123 };
124
125 if era_term.is_empty() {
126 historical_year.to_string()
127 } else {
128 format!("{historical_year} {era_term}")
129 }
130 } else {
131 let era_term = match era_labels {
133 citum_schema::options::dates::EraLabels::Default => "",
134 citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
135 citum_schema::options::dates::EraLabels::BceCe => {
136 date_terms.ce.as_deref().unwrap_or("")
137 }
138 };
139
140 if era_term.is_empty() {
141 year.value.to_string()
142 } else {
143 format!("{} {}", year.value, era_term)
144 }
145 }
146}
147
148fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
150 if year.unspecified != UnspecifiedYear::None {
151 return year.to_string();
152 }
153
154 if year.value <= 0 {
155 let historical_year = 1 - year.value;
156 if let Some(term) = before_era.filter(|term| !term.is_empty()) {
157 format!("{historical_year} {term}")
158 } else {
159 historical_year.to_string()
160 }
161 } else {
162 year.value.to_string()
163 }
164}
165
166#[allow(dead_code, reason = "kept for backwards compatibility")]
167fn extract_display_year_legacy(date: &EdtfString, before_era: Option<&str>) -> String {
168 match date.parse() {
169 RefDate::Edtf(edtf) => match edtf {
170 Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
171 Edtf::Interval(interval) => {
172 format_display_year_legacy(&interval.start.year, before_era)
173 }
174 Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
175 format_display_year_legacy(&date.year, before_era)
176 }
177 },
178 RefDate::Literal(_) => String::new(),
179 }
180}
181
182fn extract_range_end(
183 date: &EdtfString,
184 months: &[String],
185 date_terms: &citum_schema::locale::DateTerms,
186 era_labels: &citum_schema::options::dates::EraLabels,
187 neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
188 range_delimiter: &str,
189) -> Option<String> {
190 match date.parse() {
191 RefDate::Edtf(edtf) => match edtf {
192 Edtf::Interval(interval) => {
193 let end = &interval.end;
194 let year = format_display_year(
195 &end.year,
196 date_terms,
197 era_labels,
198 neg_unspecified,
199 range_delimiter,
200 );
201 let month = match end.month_or_season {
202 Some(MonthOrSeason::Month(m)) => Some(m),
203 _ => None,
204 };
205 let day = match end.day {
206 Some(Day::Day(d)) => Some(d),
207 _ => None,
208 };
209
210 match (month, day) {
211 (Some(m), Some(d)) if m > 0 && d > 0 => {
212 let month_str = month_to_string(m, months);
213 Some(format!("{} {}, {}", month_str, d, year))
214 }
215 (Some(m), _) if m > 0 => {
216 let month_str = month_to_string(m, months);
217 Some(format!("{} {}", month_str, year))
218 }
219 _ => Some(year),
220 }
221 }
222 Edtf::IntervalFrom(_date) => None, Edtf::IntervalTo(date) => {
224 let year = format_display_year(
225 &date.year,
226 date_terms,
227 era_labels,
228 neg_unspecified,
229 range_delimiter,
230 );
231 Some(year)
232 }
233 _ => None,
234 },
235 RefDate::Literal(_) => None,
236 }
237}
238
239fn format_time(
244 time: citum_edtf::Time,
245 format: &TimeFormat,
246 show_seconds: bool,
247 show_timezone: bool,
248 am_term: Option<&str>,
249 pm_term: Option<&str>,
250 utc_term: Option<&str>,
251) -> String {
252 let (display_hour, period) = match format {
253 TimeFormat::Hour12 => {
254 let (h, p) = if time.hour == 0 {
255 (12u32, am_term.unwrap_or("AM"))
256 } else if time.hour < 12 {
257 (time.hour, am_term.unwrap_or("AM"))
258 } else if time.hour == 12 {
259 (12u32, pm_term.unwrap_or("PM"))
260 } else {
261 (time.hour - 12, pm_term.unwrap_or("PM"))
262 };
263 (h, Some(p))
264 }
265 TimeFormat::Hour24 => (time.hour, None),
266 };
267
268 let time_str = if show_seconds {
269 format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
270 } else {
271 format!("{:02}:{:02}", display_hour, time.minute)
272 };
273
274 let with_period = match period {
275 Some(p) => format!("{time_str} {p}"),
276 None => time_str,
277 };
278
279 if show_timezone {
280 let tz_str = match time.timezone {
281 Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
282 Some(Timezone::Offset(mins)) => {
283 let sign = if mins >= 0 { '+' } else { '-' };
284 let abs = mins.unsigned_abs();
285 format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
286 }
287 None => String::new(),
288 };
289 if tz_str.is_empty() {
290 with_period
291 } else {
292 format!("{with_period} {tz_str}")
293 }
294 } else {
295 with_period
296 }
297}
298
299fn format_range_start(
301 date: &EdtfString,
302 form: &DateForm,
303 locale: &citum_schema::locale::Locale,
304 date_config: Option<&citum_schema::options::dates::DateConfig>,
305) -> String {
306 let default_era = citum_schema::options::dates::EraLabels::Default;
307 let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
308 let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
309 let neg_unspecified = date_config
310 .map(|c| &c.negative_unspecified_years)
311 .unwrap_or(&default_neg_unspec);
312 let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
313
314 let extract_year = |d: &EdtfString| -> String {
315 match d.parse() {
316 RefDate::Edtf(edtf) => match edtf {
317 Edtf::Date(date) => format_display_year(
318 &date.year,
319 &locale.dates,
320 era_labels,
321 neg_unspecified,
322 range_delimiter,
323 ),
324 Edtf::Interval(interval) => format_display_year(
325 &interval.start.year,
326 &locale.dates,
327 era_labels,
328 neg_unspecified,
329 range_delimiter,
330 ),
331 Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => format_display_year(
332 &date.year,
333 &locale.dates,
334 era_labels,
335 neg_unspecified,
336 range_delimiter,
337 ),
338 },
339 RefDate::Literal(_) => String::new(),
340 }
341 };
342
343 match form {
344 DateForm::Year => extract_year(date),
345 DateForm::YearMonth => {
346 let month = extract_month(date, &locale.dates.months.long);
347 let year = extract_year(date);
348 if month.is_empty() {
349 year
350 } else {
351 format!("{month} {year}")
352 }
353 }
354 DateForm::MonthDay => {
355 let month = extract_month(date, &locale.dates.months.long);
356 let day = date.day();
357 match day {
358 Some(d) => format!("{month} {d}"),
359 None => month,
360 }
361 }
362 DateForm::Full => {
363 let year = extract_year(date);
364 let month = extract_month(date, &locale.dates.months.long);
365 let day = date.day();
366 match (month.is_empty(), day) {
367 (true, _) => year,
368 (false, None) => format!("{month} {year}"),
369 (false, Some(d)) => format!("{month} {d}, {year}"),
370 }
371 }
372 DateForm::YearMonthDay => {
373 let year = extract_year(date);
374 let month = extract_month(date, &locale.dates.months.long);
375 let day = date.day();
376 match (month.is_empty(), day) {
377 (true, _) => year,
378 (false, None) => format!("{year}, {month}"),
379 (false, Some(d)) => format!("{year}, {month} {d}"),
380 }
381 }
382 DateForm::DayMonthAbbrYear => {
383 let year = extract_year(date);
384 let month = extract_month(date, &locale.dates.months.short);
385 let day = date.day();
386 match (month.is_empty(), day) {
387 (true, _) => year,
388 (false, None) => format!("{month} {year}"),
389 (false, Some(d)) => format!("{d} {month} {year}"),
390 }
391 }
392 DateForm::MonthAbbrDayYear => {
393 let year = extract_year(date);
394 let month = extract_month(date, &locale.dates.months.short);
395 let day = date.day();
396 match (month.is_empty(), day) {
397 (true, _) => year,
398 (false, None) => format!("{month} {year}"),
399 (false, Some(d)) => format!("{month} {d}, {year}"),
400 }
401 }
402 _ => extract_year(date),
403 }
404}
405
406fn format_date_range(
408 start: String,
409 date: &EdtfString,
410 locale: &citum_schema::locale::Locale,
411 date_config: Option<&citum_schema::options::dates::DateConfig>,
412) -> Option<String> {
413 let era_labels = date_config
414 .map(|c| &c.era_labels)
415 .unwrap_or(&citum_schema::options::dates::EraLabels::Default);
416 let neg_unspecified = date_config
417 .map(|c| &c.negative_unspecified_years)
418 .unwrap_or(&citum_schema::options::dates::NegativeUnspecifiedYears::Range);
419 let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
420
421 if date.is_open_range() {
422 if let Some(end_marker) = date_config
424 .and_then(|c| c.open_range_marker.as_deref())
425 .or(locale.dates.open_ended_term.as_deref())
426 {
427 Some(format!("{start}{delimiter}{end_marker}"))
428 } else {
429 Some(start)
431 }
432 } else if let Some(end) = extract_range_end(
433 date,
434 &locale.dates.months.long,
435 &locale.dates,
436 era_labels,
437 neg_unspecified,
438 delimiter,
439 ) {
440 Some(format!("{start}{delimiter}{end}"))
442 } else {
443 Some(start)
444 }
445}
446
447fn apply_date_markers(
449 value: String,
450 date: &EdtfString,
451 date_config: Option<&citum_schema::options::dates::DateConfig>,
452) -> String {
453 let mut result = value;
454 if date.is_approximate()
455 && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
456 {
457 result = format!("{marker}{result}");
458 }
459 if date.is_uncertain()
460 && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
461 {
462 result = format!("{result}{marker}");
463 }
464 result
465}
466
467fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
469 date: &EdtfString,
470 form: &DateForm,
471 hints: &ProcHints,
472 options: &RenderOptions<'_>,
473 fmt: &F,
474) -> Option<String> {
475 if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
476 let use_suffix = options
480 .config
481 .processing
482 .as_ref()
483 .unwrap_or(&citum_schema::options::Processing::AuthorDate)
484 .config()
485 .disambiguate
486 .as_ref()
487 .is_some_and(|d| d.year_suffix);
488
489 if use_suffix {
490 int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
491 } else {
492 None
493 }
494 } else {
495 None
496 }
497}
498
499fn date_form_displays_year(form: &DateForm) -> bool {
500 !matches!(form, DateForm::MonthDay)
501}
502
503fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
504 if year.is_empty() || suffix.is_empty() {
505 return formatted.to_string();
506 }
507
508 let year_index = match form {
509 DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
510 DateForm::YearMonth
511 | DateForm::Full
512 | DateForm::DayMonthAbbrYear
513 | DateForm::MonthAbbrDayYear => formatted.rfind(year),
514 DateForm::MonthDay => None,
515 _ => None,
516 };
517
518 let Some(index) = year_index else {
519 return format!("{formatted}{suffix}");
520 };
521
522 let year_end = index + year.len();
523 #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
524 let result = format!(
525 "{}{}{}{}",
526 &formatted[..index],
527 year,
528 suffix,
529 &formatted[year_end..]
530 );
531 result
532}
533
534#[allow(
536 clippy::too_many_lines,
537 reason = "date formatting handles 6 form variants"
538)]
539fn format_single_date(
540 date: &EdtfString,
541 form: &DateForm,
542 locale: &citum_schema::locale::Locale,
543 date_config: Option<&citum_schema::options::dates::DateConfig>,
544) -> Option<String> {
545 let default_era = citum_schema::options::dates::EraLabels::Default;
546 let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
547 let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
548 let neg_unspecified = date_config
549 .map(|c| &c.negative_unspecified_years)
550 .unwrap_or(&default_neg_unspec);
551 let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
552
553 let extract_year = |d: &EdtfString| -> String {
554 match d.parse() {
555 RefDate::Edtf(edtf) => match edtf {
556 Edtf::Date(dt) => format_display_year(
557 &dt.year,
558 &locale.dates,
559 era_labels,
560 neg_unspecified,
561 range_delimiter,
562 ),
563 Edtf::Interval(interval) => format_display_year(
564 &interval.start.year,
565 &locale.dates,
566 era_labels,
567 neg_unspecified,
568 range_delimiter,
569 ),
570 Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
571 &dt.year,
572 &locale.dates,
573 era_labels,
574 neg_unspecified,
575 range_delimiter,
576 ),
577 },
578 RefDate::Literal(_) => String::new(),
579 }
580 };
581
582 match form {
583 DateForm::Year => {
584 let year = extract_year(date);
585 if year.is_empty() { None } else { Some(year) }
586 }
587 DateForm::YearMonth => {
588 let year = extract_year(date);
589 if year.is_empty() {
590 return None;
591 }
592 let month = extract_month(date, &locale.dates.months.long);
593 if month.is_empty() {
594 Some(year)
595 } else {
596 Some(format!("{month} {year}"))
597 }
598 }
599 DateForm::MonthDay => {
600 let month = extract_month(date, &locale.dates.months.long);
601 if month.is_empty() {
602 return None;
603 }
604 let day = date.day();
605 if let Some(rendered) =
606 locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
607 {
608 return Some(rendered);
609 }
610 match day {
611 Some(d) => Some(format!("{month} {d}")),
612 None => Some(month),
613 }
614 }
615 DateForm::Full => {
616 let year = extract_year(date);
617 if year.is_empty() {
618 return None;
619 }
620 let month = extract_month(date, &locale.dates.months.long);
621 let day = date.day();
622 let base = locale
623 .resolve_date_pattern(
624 "pattern.date-full",
625 Some(&year),
626 (!month.is_empty()).then_some(month.as_str()),
627 day,
628 )
629 .unwrap_or_else(|| match (month.is_empty(), day) {
630 (true, _) => year.clone(),
631 (false, None) => format!("{month} {year}"),
632 (false, Some(d)) => format!("{month} {d}, {year}"),
633 });
634 if let (Some(time_fmt), Some(time)) = (
636 date_config.and_then(|c| c.time_format.as_ref()),
637 date.time(),
638 ) {
639 let show_secs = date_config.is_some_and(|c| c.show_seconds);
640 let show_tz = date_config.is_some_and(|c| c.show_timezone);
641 let time_str = format_time(
642 time,
643 time_fmt,
644 show_secs,
645 show_tz,
646 locale.dates.am.as_deref(),
647 locale.dates.pm.as_deref(),
648 locale.dates.timezone_utc.as_deref(),
649 );
650 Some(format!("{base}, {time_str}"))
651 } else {
652 Some(base)
653 }
654 }
655 DateForm::YearMonthDay => {
656 let year = extract_year(date);
657 if year.is_empty() {
658 return None;
659 }
660 let month = extract_month(date, &locale.dates.months.long);
661 let day = date.day();
662 match (month.is_empty(), day) {
663 (true, _) => Some(year),
664 (false, None) => Some(format!("{year}, {month}")),
665 (false, Some(d)) => Some(format!("{year}, {month} {d}")),
666 }
667 }
668 DateForm::DayMonthAbbrYear => {
669 let year = extract_year(date);
670 if year.is_empty() {
671 return None;
672 }
673 let month = extract_month(date, &locale.dates.months.short);
674 let day = date.day();
675 match (month.is_empty(), day) {
676 (true, _) => Some(year),
677 (false, None) => Some(format!("{month} {year}")),
678 (false, Some(d)) => Some(format!("{d} {month} {year}")),
679 }
680 }
681 DateForm::MonthAbbrDayYear => {
682 let year = extract_year(date);
683 if year.is_empty() {
684 return None;
685 }
686 let month = extract_month(date, &locale.dates.months.short);
687 let day = date.day();
688 match (month.is_empty(), day) {
689 (true, _) => Some(year),
690 (false, None) => Some(format!("{month} {year}")),
691 (false, Some(d)) => Some(format!("{month} {d}, {year}")),
692 }
693 }
694 _ => Some(extract_year(date)),
695 }
696}
697
698impl ComponentValues for TemplateDate {
699 fn values<F: crate::render::format::OutputFormat<Output = String>>(
700 &self,
701 reference: &Reference,
702 hints: &ProcHints,
703 options: &RenderOptions<'_>,
704 ) -> Option<ProcValues<F::Output>> {
705 let fmt = F::default();
706 let date_opt: Option<EdtfString> = match self.date {
707 TemplateDateVar::Issued => reference.csl_issued_date(),
708 TemplateDateVar::Accessed => reference.accessed(),
709 TemplateDateVar::OriginalPublished => reference.original_date(),
710 _ => None,
711 };
712
713 let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
714 if let Some(fallbacks) = &self.fallback {
716 for component in fallbacks {
717 if let Some(values) = component.values::<F>(reference, hints, options) {
718 return Some(values);
719 }
720 }
721 }
722 if matches!(self.date, TemplateDateVar::Issued)
724 && let Some(nd) = options.locale.resolved_general_term(
725 &GeneralTerm::NoDate,
726 &TermForm::Short,
727 None,
728 )
729 {
730 return Some(ProcValues {
731 value: nd,
732 prefix: None,
733 suffix: None,
734 url: None,
735 substituted_key: None,
736 pre_formatted: false,
737 });
738 }
739 return None;
740 };
741
742 let locale = options.locale;
743 let date_config = options.config.dates.as_ref();
744 let effective_form = self.form.clone();
745
746 let formatted = if date.is_range() {
747 let start = format_range_start(&date, &effective_form, locale, date_config);
749 format_date_range(start, &date, locale, date_config)
750 } else {
751 format_single_date(&date, &effective_form, locale, date_config)
753 };
754
755 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
757
758 let disamb_suffix = compute_disamb_suffix(&date, &effective_form, hints, options, &fmt);
760
761 formatted.map(|value| {
762 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
763 (
764 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
765 None,
766 )
767 } else {
768 (value, None)
769 };
770
771 ProcValues {
772 value,
773 prefix: None,
774 suffix,
775 url: crate::values::resolve_effective_url(
776 self.links.as_ref(),
777 options.config.links.as_ref(),
778 reference,
779 citum_schema::options::LinkAnchor::Component,
780 ),
781 substituted_key: None,
782 pre_formatted: false,
783 }
784 })
785 }
786}
787
788#[must_use]
790pub fn int_to_letter(n: u32) -> Option<String> {
791 if n == 0 {
792 return None;
793 }
794
795 let mut result = String::new();
796 let mut num = n - 1;
797
798 loop {
799 result.push((b'a' + (num % 26) as u8) as char);
800 if num < 26 {
801 break;
802 }
803 num = num / 26 - 1;
804 }
805
806 Some(result.chars().rev().collect())
807}
808
809#[cfg(test)]
810#[allow(
811 clippy::unwrap_used,
812 clippy::expect_used,
813 clippy::panic,
814 clippy::indexing_slicing,
815 clippy::todo,
816 clippy::unimplemented,
817 clippy::unreachable,
818 clippy::get_unwrap,
819 reason = "Panicking is acceptable and often desired in tests."
820)]
821mod tests {
822 use super::*;
823
824 #[test]
825 fn test_int_to_letter() {
826 assert_eq!(int_to_letter(1), Some("a".to_string()));
828 assert_eq!(int_to_letter(2), Some("b".to_string()));
829 assert_eq!(int_to_letter(26), Some("z".to_string()));
830
831 assert_eq!(int_to_letter(27), Some("aa".to_string()));
833 assert_eq!(int_to_letter(52), Some("az".to_string()));
834 assert_eq!(int_to_letter(53), Some("ba".to_string()));
835
836 assert_eq!(int_to_letter(0), None);
838 }
839}
840
841#[cfg(test)]
842#[allow(
843 clippy::unwrap_used,
844 clippy::expect_used,
845 clippy::panic,
846 clippy::indexing_slicing,
847 clippy::todo,
848 clippy::unimplemented,
849 clippy::unreachable,
850 clippy::get_unwrap,
851 reason = "Panicking is acceptable and often desired in tests."
852)]
853mod time_tests {
854 use super::*;
855 use citum_edtf::{Time, Timezone};
856
857 #[test]
858 fn test_format_time_12h_utc() {
859 let time = Time {
860 hour: 23,
861 minute: 20,
862 second: 30,
863 timezone: Some(Timezone::Utc),
864 };
865 let result = format_time(
866 time,
867 &TimeFormat::Hour12,
868 false,
869 true,
870 Some("AM"),
871 Some("PM"),
872 Some("UTC"),
873 );
874 assert_eq!(result, "11:20 PM UTC");
875 }
876
877 #[test]
878 fn test_format_time_24h_utc() {
879 let time = Time {
880 hour: 23,
881 minute: 20,
882 second: 30,
883 timezone: Some(Timezone::Utc),
884 };
885 let result = format_time(
886 time,
887 &TimeFormat::Hour24,
888 false,
889 true,
890 None,
891 None,
892 Some("UTC"),
893 );
894 assert_eq!(result, "23:20 UTC");
895 }
896
897 #[test]
898 fn test_format_time_with_offset() {
899 let time = Time {
900 hour: 10,
901 minute: 10,
902 second: 10,
903 timezone: Some(Timezone::Offset(330)),
904 };
905 let result = format_time(
906 time,
907 &TimeFormat::Hour24,
908 false,
909 true,
910 None,
911 None,
912 Some("UTC"),
913 );
914 assert_eq!(result, "10:10 +05:30");
915 }
916
917 #[test]
918 fn test_format_time_no_timezone() {
919 let time = Time {
920 hour: 14,
921 minute: 30,
922 second: 0,
923 timezone: None,
924 };
925 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
926 assert_eq!(result, "14:30");
927 }
928}
929
930#[cfg(test)]
931#[allow(
932 clippy::unwrap_used,
933 clippy::expect_used,
934 clippy::panic,
935 clippy::indexing_slicing,
936 clippy::todo,
937 clippy::unimplemented,
938 clippy::unreachable,
939 clippy::get_unwrap,
940 reason = "Panicking is acceptable and often desired in tests."
941)]
942mod era_tests {
943 use super::*;
944 use citum_edtf::{UnspecifiedYear, Year};
945 use citum_schema::locale::DateTerms;
946 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
947
948 fn en_terms() -> DateTerms {
949 DateTerms::en_us()
950 }
951
952 #[test]
953 fn positive_year_default_no_suffix() {
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::Default,
962 &NegativeUnspecifiedYears::Range,
963 "–",
964 );
965 assert_eq!(result, "54");
966 }
967
968 #[test]
969 fn positive_year_bc_ad() {
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::BcAd,
978 &NegativeUnspecifiedYears::Range,
979 "–",
980 );
981 assert_eq!(result, "54 AD");
982 }
983
984 #[test]
985 fn positive_year_bce_ce() {
986 let year = Year {
987 value: 54,
988 unspecified: UnspecifiedYear::None,
989 };
990 let result = format_display_year(
991 &year,
992 &en_terms(),
993 &EraLabels::BceCe,
994 &NegativeUnspecifiedYears::Range,
995 "–",
996 );
997 assert_eq!(result, "54 CE");
998 }
999
1000 #[test]
1001 fn negative_year_default() {
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::Default,
1010 &NegativeUnspecifiedYears::Range,
1011 "–",
1012 );
1013 assert_eq!(result, "44 BC");
1014 }
1015
1016 #[test]
1017 fn negative_year_bc_ad() {
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::BcAd,
1026 &NegativeUnspecifiedYears::Range,
1027 "–",
1028 );
1029 assert_eq!(result, "44 BC");
1030 }
1031
1032 #[test]
1033 fn negative_year_bce_ce() {
1034 let year = Year {
1035 value: -43,
1036 unspecified: UnspecifiedYear::None,
1037 };
1038 let result = format_display_year(
1039 &year,
1040 &en_terms(),
1041 &EraLabels::BceCe,
1042 &NegativeUnspecifiedYears::Range,
1043 "–",
1044 );
1045 assert_eq!(result, "44 BCE");
1046 }
1047
1048 #[test]
1049 fn positive_unspecified_ones() {
1050 let year = Year {
1051 value: 1990,
1052 unspecified: UnspecifiedYear::One,
1053 };
1054 let result = format_display_year(
1055 &year,
1056 &en_terms(),
1057 &EraLabels::Default,
1058 &NegativeUnspecifiedYears::Range,
1059 "–",
1060 );
1061 assert_eq!(result, "199X");
1062 }
1063
1064 #[test]
1065 fn positive_unspecified_two() {
1066 let year = Year {
1067 value: 1900,
1068 unspecified: UnspecifiedYear::Two,
1069 };
1070 let result = format_display_year(
1071 &year,
1072 &en_terms(),
1073 &EraLabels::Default,
1074 &NegativeUnspecifiedYears::Range,
1075 "–",
1076 );
1077 assert_eq!(result, "19XX");
1078 }
1079
1080 #[test]
1081 fn negative_unspecified_range() {
1082 let year = Year {
1083 value: -90,
1084 unspecified: UnspecifiedYear::One,
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–91 BC");
1094 }
1095
1096 #[test]
1097 fn negative_unspecified_century() {
1098 let year = Year {
1099 value: 0,
1100 unspecified: UnspecifiedYear::Two,
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–1 BC");
1110 }
1111
1112 #[test]
1113 fn backwards_compat_negative_year() {
1114 let year = Year {
1115 value: -99,
1116 unspecified: UnspecifiedYear::None,
1117 };
1118 let result = format_display_year(
1119 &year,
1120 &en_terms(),
1121 &EraLabels::Default,
1122 &NegativeUnspecifiedYears::Range,
1123 "–",
1124 );
1125 assert_eq!(result, "100 BC");
1126 }
1127}
1128
1129#[cfg(test)]
1130#[allow(
1131 clippy::unwrap_used,
1132 clippy::expect_used,
1133 reason = "Panicking is acceptable in tests."
1134)]
1135mod locale_pattern_tests {
1136 use super::*;
1137 use citum_schema::locale::Locale;
1138
1139 fn en_us() -> Locale {
1140 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1141 .expect("en-US locale should parse")
1142 }
1143
1144 fn es_es() -> Locale {
1145 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1146 .expect("es-ES locale should parse")
1147 }
1148
1149 fn eu_es() -> Locale {
1150 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1151 .expect("eu-ES locale should parse")
1152 }
1153
1154 fn full(locale: &Locale, edtf: &str) -> String {
1155 format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1156 .expect("date should render")
1157 }
1158
1159 fn month_day(locale: &Locale, edtf: &str) -> String {
1160 format_single_date(
1161 &EdtfString(edtf.to_string()),
1162 &DateForm::MonthDay,
1163 locale,
1164 None,
1165 )
1166 .expect("date should render")
1167 }
1168
1169 #[test]
1170 fn en_us_full_unchanged_by_pattern_machinery() {
1171 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1174 }
1175
1176 #[test]
1177 fn en_us_month_day_unchanged_by_pattern_machinery() {
1178 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1179 }
1180
1181 #[test]
1182 fn es_es_full_uses_locale_pattern() {
1183 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1185 }
1186
1187 #[test]
1188 fn es_es_month_day_uses_locale_pattern() {
1189 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1190 }
1191
1192 #[test]
1193 fn eu_es_full_uses_locale_pattern() {
1194 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1197 }
1198
1199 #[test]
1200 fn eu_es_month_day_uses_locale_pattern() {
1201 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1202 }
1203
1204 #[test]
1205 fn pattern_missing_day_falls_back_to_english_assembly() {
1206 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1211 }
1212}