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