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 let month_opt = (!month.is_empty()).then_some(month.as_str());
594 if let Some(rendered) =
595 locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
596 {
597 return Some(rendered);
598 }
599 if month.is_empty() {
600 Some(year)
601 } else {
602 Some(format!("{month} {year}"))
603 }
604 }
605 DateForm::MonthDay => {
606 let month = extract_month(date, &locale.dates.months.long);
607 if month.is_empty() {
608 return None;
609 }
610 let day = date.day();
611 if let Some(rendered) =
612 locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
613 {
614 return Some(rendered);
615 }
616 match day {
617 Some(d) => Some(format!("{month} {d}")),
618 None => Some(month),
619 }
620 }
621 DateForm::Full => {
622 let year = extract_year(date);
623 if year.is_empty() {
624 return None;
625 }
626 let month = extract_month(date, &locale.dates.months.long);
627 let day = date.day();
628 let base = locale
629 .resolve_date_pattern(
630 "pattern.date-full",
631 Some(&year),
632 (!month.is_empty()).then_some(month.as_str()),
633 day,
634 )
635 .unwrap_or_else(|| match (month.is_empty(), day) {
636 (true, _) => year.clone(),
637 (false, None) => format!("{month} {year}"),
638 (false, Some(d)) => format!("{month} {d}, {year}"),
639 });
640 if let (Some(time_fmt), Some(time)) = (
642 date_config.and_then(|c| c.time_format.as_ref()),
643 date.time(),
644 ) {
645 let show_secs = date_config.is_some_and(|c| c.show_seconds);
646 let show_tz = date_config.is_some_and(|c| c.show_timezone);
647 let time_str = format_time(
648 time,
649 time_fmt,
650 show_secs,
651 show_tz,
652 locale.dates.am.as_deref(),
653 locale.dates.pm.as_deref(),
654 locale.dates.timezone_utc.as_deref(),
655 );
656 Some(format!("{base}, {time_str}"))
657 } else {
658 Some(base)
659 }
660 }
661 DateForm::YearMonthDay => {
662 let year = extract_year(date);
663 if year.is_empty() {
664 return None;
665 }
666 let month = extract_month(date, &locale.dates.months.long);
667 let day = date.day();
668 let month_opt = (!month.is_empty()).then_some(month.as_str());
669 if let Some(rendered) = locale.resolve_date_pattern(
670 "pattern.date-year-month-day",
671 Some(&year),
672 month_opt,
673 day,
674 ) {
675 return Some(rendered);
676 }
677 match (month.is_empty(), day) {
678 (true, _) => Some(year),
679 (false, None) => Some(format!("{year}, {month}")),
680 (false, Some(d)) => Some(format!("{year}, {month} {d}")),
681 }
682 }
683 DateForm::DayMonthAbbrYear => {
684 let year = extract_year(date);
685 if year.is_empty() {
686 return None;
687 }
688 let month = extract_month(date, &locale.dates.months.short);
689 let day = date.day();
690 let month_opt = (!month.is_empty()).then_some(month.as_str());
691 if let Some(rendered) = locale.resolve_date_pattern(
692 "pattern.date-day-month-abbr-year",
693 Some(&year),
694 month_opt,
695 day,
696 ) {
697 return Some(rendered);
698 }
699 match (month.is_empty(), day) {
700 (true, _) => Some(year),
701 (false, None) => Some(format!("{month} {year}")),
702 (false, Some(d)) => Some(format!("{d} {month} {year}")),
703 }
704 }
705 DateForm::MonthAbbrDayYear => {
706 let year = extract_year(date);
707 if year.is_empty() {
708 return None;
709 }
710 let month = extract_month(date, &locale.dates.months.short);
711 let day = date.day();
712 let month_opt = (!month.is_empty()).then_some(month.as_str());
713 if let Some(rendered) = locale.resolve_date_pattern(
714 "pattern.date-month-abbr-day-year",
715 Some(&year),
716 month_opt,
717 day,
718 ) {
719 return Some(rendered);
720 }
721 match (month.is_empty(), day) {
722 (true, _) => Some(year),
723 (false, None) => Some(format!("{month} {year}")),
724 (false, Some(d)) => Some(format!("{month} {d}, {year}")),
725 }
726 }
727 _ => Some(extract_year(date)),
728 }
729}
730
731impl ComponentValues for TemplateDate {
732 fn values<F: crate::render::format::OutputFormat<Output = String>>(
733 &self,
734 reference: &Reference,
735 hints: &ProcHints,
736 options: &RenderOptions<'_>,
737 ) -> Option<ProcValues<F::Output>> {
738 let fmt = F::default();
739 let date_opt: Option<EdtfString> = match self.date {
740 TemplateDateVar::Issued => reference.csl_issued_date(),
741 TemplateDateVar::Accessed => reference.accessed(),
742 TemplateDateVar::OriginalPublished => reference.original_date(),
743 _ => None,
744 };
745
746 let Some(date) = date_opt.filter(|d| !d.0.is_empty()) else {
747 if let Some(fallbacks) = &self.fallback {
749 for component in fallbacks {
750 if let Some(values) = component.values::<F>(reference, hints, options) {
751 return Some(values);
752 }
753 }
754 }
755 if matches!(self.date, TemplateDateVar::Issued)
757 && let Some(nd) = options.locale.resolved_general_term(
758 &GeneralTerm::NoDate,
759 &TermForm::Short,
760 None,
761 )
762 {
763 return Some(ProcValues {
764 value: nd,
765 prefix: None,
766 suffix: None,
767 url: None,
768 substituted_key: None,
769 pre_formatted: false,
770 });
771 }
772 return None;
773 };
774
775 let locale = options.locale;
776 let date_config = options.config.dates.as_ref();
777 let effective_form = self.form.clone();
778
779 let formatted = if date.is_range() {
780 let start = format_range_start(&date, &effective_form, locale, date_config);
782 format_date_range(start, &date, locale, date_config)
783 } else {
784 format_single_date(&date, &effective_form, locale, date_config)
786 };
787
788 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
790
791 let disamb_suffix = matches!(self.date, TemplateDateVar::Issued)
796 .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
797 .flatten();
798
799 formatted.map(|value| {
800 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
801 (
802 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
803 None,
804 )
805 } else {
806 (value, None)
807 };
808
809 ProcValues {
810 value,
811 prefix: None,
812 suffix,
813 url: crate::values::resolve_effective_url(
814 self.links.as_ref(),
815 options.config.links.as_ref(),
816 reference,
817 citum_schema::options::LinkAnchor::Component,
818 ),
819 substituted_key: None,
820 pre_formatted: false,
821 }
822 })
823 }
824}
825
826#[must_use]
828pub fn int_to_letter(n: u32) -> Option<String> {
829 if n == 0 {
830 return None;
831 }
832
833 let mut result = String::new();
834 let mut num = n - 1;
835
836 loop {
837 result.push((b'a' + (num % 26) as u8) as char);
838 if num < 26 {
839 break;
840 }
841 num = num / 26 - 1;
842 }
843
844 Some(result.chars().rev().collect())
845}
846
847#[cfg(test)]
848#[allow(
849 clippy::unwrap_used,
850 clippy::expect_used,
851 clippy::panic,
852 clippy::indexing_slicing,
853 clippy::todo,
854 clippy::unimplemented,
855 clippy::unreachable,
856 clippy::get_unwrap,
857 reason = "Panicking is acceptable and often desired in tests."
858)]
859mod tests {
860 use super::*;
861
862 #[test]
863 fn test_int_to_letter() {
864 assert_eq!(int_to_letter(1), Some("a".to_string()));
866 assert_eq!(int_to_letter(2), Some("b".to_string()));
867 assert_eq!(int_to_letter(26), Some("z".to_string()));
868
869 assert_eq!(int_to_letter(27), Some("aa".to_string()));
871 assert_eq!(int_to_letter(52), Some("az".to_string()));
872 assert_eq!(int_to_letter(53), Some("ba".to_string()));
873
874 assert_eq!(int_to_letter(0), None);
876 }
877}
878
879#[cfg(test)]
880#[allow(
881 clippy::unwrap_used,
882 clippy::expect_used,
883 clippy::panic,
884 clippy::indexing_slicing,
885 clippy::todo,
886 clippy::unimplemented,
887 clippy::unreachable,
888 clippy::get_unwrap,
889 reason = "Panicking is acceptable and often desired in tests."
890)]
891mod time_tests {
892 use super::*;
893 use citum_edtf::{Time, Timezone};
894
895 #[test]
896 fn test_format_time_12h_utc() {
897 let time = Time {
898 hour: 23,
899 minute: 20,
900 second: 30,
901 timezone: Some(Timezone::Utc),
902 };
903 let result = format_time(
904 time,
905 &TimeFormat::Hour12,
906 false,
907 true,
908 Some("AM"),
909 Some("PM"),
910 Some("UTC"),
911 );
912 assert_eq!(result, "11:20 PM UTC");
913 }
914
915 #[test]
916 fn test_format_time_24h_utc() {
917 let time = Time {
918 hour: 23,
919 minute: 20,
920 second: 30,
921 timezone: Some(Timezone::Utc),
922 };
923 let result = format_time(
924 time,
925 &TimeFormat::Hour24,
926 false,
927 true,
928 None,
929 None,
930 Some("UTC"),
931 );
932 assert_eq!(result, "23:20 UTC");
933 }
934
935 #[test]
936 fn test_format_time_with_offset() {
937 let time = Time {
938 hour: 10,
939 minute: 10,
940 second: 10,
941 timezone: Some(Timezone::Offset(330)),
942 };
943 let result = format_time(
944 time,
945 &TimeFormat::Hour24,
946 false,
947 true,
948 None,
949 None,
950 Some("UTC"),
951 );
952 assert_eq!(result, "10:10 +05:30");
953 }
954
955 #[test]
956 fn test_format_time_no_timezone() {
957 let time = Time {
958 hour: 14,
959 minute: 30,
960 second: 0,
961 timezone: None,
962 };
963 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
964 assert_eq!(result, "14:30");
965 }
966}
967
968#[cfg(test)]
969#[allow(
970 clippy::unwrap_used,
971 clippy::expect_used,
972 clippy::panic,
973 clippy::indexing_slicing,
974 clippy::todo,
975 clippy::unimplemented,
976 clippy::unreachable,
977 clippy::get_unwrap,
978 reason = "Panicking is acceptable and often desired in tests."
979)]
980mod era_tests {
981 use super::*;
982 use citum_edtf::{UnspecifiedYear, Year};
983 use citum_schema::locale::DateTerms;
984 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
985
986 fn en_terms() -> DateTerms {
987 DateTerms::en_us()
988 }
989
990 #[test]
991 fn positive_year_default_no_suffix() {
992 let year = Year {
993 value: 54,
994 unspecified: UnspecifiedYear::None,
995 };
996 let result = format_display_year(
997 &year,
998 &en_terms(),
999 &EraLabels::Default,
1000 &NegativeUnspecifiedYears::Range,
1001 "–",
1002 );
1003 assert_eq!(result, "54");
1004 }
1005
1006 #[test]
1007 fn positive_year_bc_ad() {
1008 let year = Year {
1009 value: 54,
1010 unspecified: UnspecifiedYear::None,
1011 };
1012 let result = format_display_year(
1013 &year,
1014 &en_terms(),
1015 &EraLabels::BcAd,
1016 &NegativeUnspecifiedYears::Range,
1017 "–",
1018 );
1019 assert_eq!(result, "54 AD");
1020 }
1021
1022 #[test]
1023 fn positive_year_bce_ce() {
1024 let year = Year {
1025 value: 54,
1026 unspecified: UnspecifiedYear::None,
1027 };
1028 let result = format_display_year(
1029 &year,
1030 &en_terms(),
1031 &EraLabels::BceCe,
1032 &NegativeUnspecifiedYears::Range,
1033 "–",
1034 );
1035 assert_eq!(result, "54 CE");
1036 }
1037
1038 #[test]
1039 fn negative_year_default() {
1040 let year = Year {
1041 value: -43,
1042 unspecified: UnspecifiedYear::None,
1043 };
1044 let result = format_display_year(
1045 &year,
1046 &en_terms(),
1047 &EraLabels::Default,
1048 &NegativeUnspecifiedYears::Range,
1049 "–",
1050 );
1051 assert_eq!(result, "44 BC");
1052 }
1053
1054 #[test]
1055 fn negative_year_bc_ad() {
1056 let year = Year {
1057 value: -43,
1058 unspecified: UnspecifiedYear::None,
1059 };
1060 let result = format_display_year(
1061 &year,
1062 &en_terms(),
1063 &EraLabels::BcAd,
1064 &NegativeUnspecifiedYears::Range,
1065 "–",
1066 );
1067 assert_eq!(result, "44 BC");
1068 }
1069
1070 #[test]
1071 fn negative_year_bce_ce() {
1072 let year = Year {
1073 value: -43,
1074 unspecified: UnspecifiedYear::None,
1075 };
1076 let result = format_display_year(
1077 &year,
1078 &en_terms(),
1079 &EraLabels::BceCe,
1080 &NegativeUnspecifiedYears::Range,
1081 "–",
1082 );
1083 assert_eq!(result, "44 BCE");
1084 }
1085
1086 #[test]
1087 fn positive_unspecified_ones() {
1088 let year = Year {
1089 value: 1990,
1090 unspecified: UnspecifiedYear::One,
1091 };
1092 let result = format_display_year(
1093 &year,
1094 &en_terms(),
1095 &EraLabels::Default,
1096 &NegativeUnspecifiedYears::Range,
1097 "–",
1098 );
1099 assert_eq!(result, "199X");
1100 }
1101
1102 #[test]
1103 fn positive_unspecified_two() {
1104 let year = Year {
1105 value: 1900,
1106 unspecified: UnspecifiedYear::Two,
1107 };
1108 let result = format_display_year(
1109 &year,
1110 &en_terms(),
1111 &EraLabels::Default,
1112 &NegativeUnspecifiedYears::Range,
1113 "–",
1114 );
1115 assert_eq!(result, "19XX");
1116 }
1117
1118 #[test]
1119 fn negative_unspecified_range() {
1120 let year = Year {
1121 value: -90,
1122 unspecified: UnspecifiedYear::One,
1123 };
1124 let result = format_display_year(
1125 &year,
1126 &en_terms(),
1127 &EraLabels::Default,
1128 &NegativeUnspecifiedYears::Range,
1129 "–",
1130 );
1131 assert_eq!(result, "100–91 BC");
1132 }
1133
1134 #[test]
1135 fn negative_unspecified_century() {
1136 let year = Year {
1137 value: 0,
1138 unspecified: UnspecifiedYear::Two,
1139 };
1140 let result = format_display_year(
1141 &year,
1142 &en_terms(),
1143 &EraLabels::Default,
1144 &NegativeUnspecifiedYears::Range,
1145 "–",
1146 );
1147 assert_eq!(result, "100–1 BC");
1148 }
1149
1150 #[test]
1151 fn backwards_compat_negative_year() {
1152 let year = Year {
1153 value: -99,
1154 unspecified: UnspecifiedYear::None,
1155 };
1156 let result = format_display_year(
1157 &year,
1158 &en_terms(),
1159 &EraLabels::Default,
1160 &NegativeUnspecifiedYears::Range,
1161 "–",
1162 );
1163 assert_eq!(result, "100 BC");
1164 }
1165}
1166
1167#[cfg(test)]
1168#[allow(
1169 clippy::unwrap_used,
1170 clippy::expect_used,
1171 reason = "Panicking is acceptable in tests."
1172)]
1173mod locale_pattern_tests {
1174 use super::*;
1175 use citum_schema::locale::Locale;
1176
1177 fn en_us() -> Locale {
1178 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1179 .expect("en-US locale should parse")
1180 }
1181
1182 fn es_es() -> Locale {
1183 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1184 .expect("es-ES locale should parse")
1185 }
1186
1187 fn eu_es() -> Locale {
1188 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1189 .expect("eu-ES locale should parse")
1190 }
1191
1192 fn full(locale: &Locale, edtf: &str) -> String {
1193 format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1194 .expect("date should render")
1195 }
1196
1197 fn month_day(locale: &Locale, edtf: &str) -> String {
1198 format_single_date(
1199 &EdtfString(edtf.to_string()),
1200 &DateForm::MonthDay,
1201 locale,
1202 None,
1203 )
1204 .expect("date should render")
1205 }
1206
1207 #[test]
1208 fn en_us_full_unchanged_by_pattern_machinery() {
1209 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1212 }
1213
1214 #[test]
1215 fn en_us_month_day_unchanged_by_pattern_machinery() {
1216 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1217 }
1218
1219 #[test]
1220 fn es_es_full_uses_locale_pattern() {
1221 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1223 }
1224
1225 #[test]
1226 fn es_es_month_day_uses_locale_pattern() {
1227 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1228 }
1229
1230 #[test]
1231 fn eu_es_full_uses_locale_pattern() {
1232 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1235 }
1236
1237 #[test]
1238 fn eu_es_month_day_uses_locale_pattern() {
1239 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1240 }
1241
1242 fn year_month(locale: &Locale, edtf: &str) -> String {
1243 format_single_date(
1244 &EdtfString(edtf.to_string()),
1245 &DateForm::YearMonth,
1246 locale,
1247 None,
1248 )
1249 .expect("date should render")
1250 }
1251
1252 fn year_month_day(locale: &Locale, edtf: &str) -> String {
1253 format_single_date(
1254 &EdtfString(edtf.to_string()),
1255 &DateForm::YearMonthDay,
1256 locale,
1257 None,
1258 )
1259 .expect("date should render")
1260 }
1261
1262 fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1263 format_single_date(
1264 &EdtfString(edtf.to_string()),
1265 &DateForm::DayMonthAbbrYear,
1266 locale,
1267 None,
1268 )
1269 .expect("date should render")
1270 }
1271
1272 fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1273 format_single_date(
1274 &EdtfString(edtf.to_string()),
1275 &DateForm::MonthAbbrDayYear,
1276 locale,
1277 None,
1278 )
1279 .expect("date should render")
1280 }
1281
1282 #[test]
1283 fn en_us_year_month_unchanged_by_pattern_machinery() {
1284 assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1286 }
1287
1288 #[test]
1289 fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1290 assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1291 }
1292
1293 #[test]
1294 fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1295 assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1296 }
1297
1298 #[test]
1299 fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1300 assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1301 }
1302
1303 #[test]
1304 fn es_es_year_month_uses_locale_pattern() {
1305 assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1307 }
1308
1309 #[test]
1310 fn eu_es_year_month_uses_locale_pattern() {
1311 assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1313 }
1314
1315 #[test]
1316 fn year_month_missing_month_falls_back_to_year() {
1317 assert_eq!(year_month(&es_es(), "2023"), "2023");
1319 }
1320
1321 #[test]
1322 fn es_es_year_month_day_uses_locale_pattern() {
1323 assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1325 }
1326
1327 #[test]
1328 fn es_es_year_month_day_missing_day_falls_back() {
1329 assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1332 }
1333
1334 #[test]
1335 fn es_es_day_month_abbr_year_uses_locale_pattern() {
1336 assert_eq!(
1338 day_month_abbr_year(&es_es(), "2023-01-12"),
1339 "12 ene. de 2023"
1340 );
1341 }
1342
1343 #[test]
1344 fn es_es_day_month_abbr_year_missing_day_falls_back() {
1345 assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1347 }
1348
1349 #[test]
1350 fn es_es_month_abbr_day_year_uses_locale_pattern() {
1351 assert_eq!(
1353 month_abbr_day_year(&es_es(), "2023-01-12"),
1354 "ene. 12 de 2023"
1355 );
1356 }
1357
1358 #[test]
1359 fn es_es_month_abbr_day_year_missing_day_falls_back() {
1360 assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1362 }
1363
1364 #[test]
1365 fn pattern_missing_day_falls_back_to_english_assembly() {
1366 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1371 }
1372}