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 = compute_disamb_suffix(&date, &effective_form, hints, options, &fmt);
793
794 formatted.map(|value| {
795 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
796 (
797 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
798 None,
799 )
800 } else {
801 (value, None)
802 };
803
804 ProcValues {
805 value,
806 prefix: None,
807 suffix,
808 url: crate::values::resolve_effective_url(
809 self.links.as_ref(),
810 options.config.links.as_ref(),
811 reference,
812 citum_schema::options::LinkAnchor::Component,
813 ),
814 substituted_key: None,
815 pre_formatted: false,
816 }
817 })
818 }
819}
820
821#[must_use]
823pub fn int_to_letter(n: u32) -> Option<String> {
824 if n == 0 {
825 return None;
826 }
827
828 let mut result = String::new();
829 let mut num = n - 1;
830
831 loop {
832 result.push((b'a' + (num % 26) as u8) as char);
833 if num < 26 {
834 break;
835 }
836 num = num / 26 - 1;
837 }
838
839 Some(result.chars().rev().collect())
840}
841
842#[cfg(test)]
843#[allow(
844 clippy::unwrap_used,
845 clippy::expect_used,
846 clippy::panic,
847 clippy::indexing_slicing,
848 clippy::todo,
849 clippy::unimplemented,
850 clippy::unreachable,
851 clippy::get_unwrap,
852 reason = "Panicking is acceptable and often desired in tests."
853)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn test_int_to_letter() {
859 assert_eq!(int_to_letter(1), Some("a".to_string()));
861 assert_eq!(int_to_letter(2), Some("b".to_string()));
862 assert_eq!(int_to_letter(26), Some("z".to_string()));
863
864 assert_eq!(int_to_letter(27), Some("aa".to_string()));
866 assert_eq!(int_to_letter(52), Some("az".to_string()));
867 assert_eq!(int_to_letter(53), Some("ba".to_string()));
868
869 assert_eq!(int_to_letter(0), None);
871 }
872}
873
874#[cfg(test)]
875#[allow(
876 clippy::unwrap_used,
877 clippy::expect_used,
878 clippy::panic,
879 clippy::indexing_slicing,
880 clippy::todo,
881 clippy::unimplemented,
882 clippy::unreachable,
883 clippy::get_unwrap,
884 reason = "Panicking is acceptable and often desired in tests."
885)]
886mod time_tests {
887 use super::*;
888 use citum_edtf::{Time, Timezone};
889
890 #[test]
891 fn test_format_time_12h_utc() {
892 let time = Time {
893 hour: 23,
894 minute: 20,
895 second: 30,
896 timezone: Some(Timezone::Utc),
897 };
898 let result = format_time(
899 time,
900 &TimeFormat::Hour12,
901 false,
902 true,
903 Some("AM"),
904 Some("PM"),
905 Some("UTC"),
906 );
907 assert_eq!(result, "11:20 PM UTC");
908 }
909
910 #[test]
911 fn test_format_time_24h_utc() {
912 let time = Time {
913 hour: 23,
914 minute: 20,
915 second: 30,
916 timezone: Some(Timezone::Utc),
917 };
918 let result = format_time(
919 time,
920 &TimeFormat::Hour24,
921 false,
922 true,
923 None,
924 None,
925 Some("UTC"),
926 );
927 assert_eq!(result, "23:20 UTC");
928 }
929
930 #[test]
931 fn test_format_time_with_offset() {
932 let time = Time {
933 hour: 10,
934 minute: 10,
935 second: 10,
936 timezone: Some(Timezone::Offset(330)),
937 };
938 let result = format_time(
939 time,
940 &TimeFormat::Hour24,
941 false,
942 true,
943 None,
944 None,
945 Some("UTC"),
946 );
947 assert_eq!(result, "10:10 +05:30");
948 }
949
950 #[test]
951 fn test_format_time_no_timezone() {
952 let time = Time {
953 hour: 14,
954 minute: 30,
955 second: 0,
956 timezone: None,
957 };
958 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
959 assert_eq!(result, "14:30");
960 }
961}
962
963#[cfg(test)]
964#[allow(
965 clippy::unwrap_used,
966 clippy::expect_used,
967 clippy::panic,
968 clippy::indexing_slicing,
969 clippy::todo,
970 clippy::unimplemented,
971 clippy::unreachable,
972 clippy::get_unwrap,
973 reason = "Panicking is acceptable and often desired in tests."
974)]
975mod era_tests {
976 use super::*;
977 use citum_edtf::{UnspecifiedYear, Year};
978 use citum_schema::locale::DateTerms;
979 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
980
981 fn en_terms() -> DateTerms {
982 DateTerms::en_us()
983 }
984
985 #[test]
986 fn positive_year_default_no_suffix() {
987 let year = Year {
988 value: 54,
989 unspecified: UnspecifiedYear::None,
990 };
991 let result = format_display_year(
992 &year,
993 &en_terms(),
994 &EraLabels::Default,
995 &NegativeUnspecifiedYears::Range,
996 "–",
997 );
998 assert_eq!(result, "54");
999 }
1000
1001 #[test]
1002 fn positive_year_bc_ad() {
1003 let year = Year {
1004 value: 54,
1005 unspecified: UnspecifiedYear::None,
1006 };
1007 let result = format_display_year(
1008 &year,
1009 &en_terms(),
1010 &EraLabels::BcAd,
1011 &NegativeUnspecifiedYears::Range,
1012 "–",
1013 );
1014 assert_eq!(result, "54 AD");
1015 }
1016
1017 #[test]
1018 fn positive_year_bce_ce() {
1019 let year = Year {
1020 value: 54,
1021 unspecified: UnspecifiedYear::None,
1022 };
1023 let result = format_display_year(
1024 &year,
1025 &en_terms(),
1026 &EraLabels::BceCe,
1027 &NegativeUnspecifiedYears::Range,
1028 "–",
1029 );
1030 assert_eq!(result, "54 CE");
1031 }
1032
1033 #[test]
1034 fn negative_year_default() {
1035 let year = Year {
1036 value: -43,
1037 unspecified: UnspecifiedYear::None,
1038 };
1039 let result = format_display_year(
1040 &year,
1041 &en_terms(),
1042 &EraLabels::Default,
1043 &NegativeUnspecifiedYears::Range,
1044 "–",
1045 );
1046 assert_eq!(result, "44 BC");
1047 }
1048
1049 #[test]
1050 fn negative_year_bc_ad() {
1051 let year = Year {
1052 value: -43,
1053 unspecified: UnspecifiedYear::None,
1054 };
1055 let result = format_display_year(
1056 &year,
1057 &en_terms(),
1058 &EraLabels::BcAd,
1059 &NegativeUnspecifiedYears::Range,
1060 "–",
1061 );
1062 assert_eq!(result, "44 BC");
1063 }
1064
1065 #[test]
1066 fn negative_year_bce_ce() {
1067 let year = Year {
1068 value: -43,
1069 unspecified: UnspecifiedYear::None,
1070 };
1071 let result = format_display_year(
1072 &year,
1073 &en_terms(),
1074 &EraLabels::BceCe,
1075 &NegativeUnspecifiedYears::Range,
1076 "–",
1077 );
1078 assert_eq!(result, "44 BCE");
1079 }
1080
1081 #[test]
1082 fn positive_unspecified_ones() {
1083 let year = Year {
1084 value: 1990,
1085 unspecified: UnspecifiedYear::One,
1086 };
1087 let result = format_display_year(
1088 &year,
1089 &en_terms(),
1090 &EraLabels::Default,
1091 &NegativeUnspecifiedYears::Range,
1092 "–",
1093 );
1094 assert_eq!(result, "199X");
1095 }
1096
1097 #[test]
1098 fn positive_unspecified_two() {
1099 let year = Year {
1100 value: 1900,
1101 unspecified: UnspecifiedYear::Two,
1102 };
1103 let result = format_display_year(
1104 &year,
1105 &en_terms(),
1106 &EraLabels::Default,
1107 &NegativeUnspecifiedYears::Range,
1108 "–",
1109 );
1110 assert_eq!(result, "19XX");
1111 }
1112
1113 #[test]
1114 fn negative_unspecified_range() {
1115 let year = Year {
1116 value: -90,
1117 unspecified: UnspecifiedYear::One,
1118 };
1119 let result = format_display_year(
1120 &year,
1121 &en_terms(),
1122 &EraLabels::Default,
1123 &NegativeUnspecifiedYears::Range,
1124 "–",
1125 );
1126 assert_eq!(result, "100–91 BC");
1127 }
1128
1129 #[test]
1130 fn negative_unspecified_century() {
1131 let year = Year {
1132 value: 0,
1133 unspecified: UnspecifiedYear::Two,
1134 };
1135 let result = format_display_year(
1136 &year,
1137 &en_terms(),
1138 &EraLabels::Default,
1139 &NegativeUnspecifiedYears::Range,
1140 "–",
1141 );
1142 assert_eq!(result, "100–1 BC");
1143 }
1144
1145 #[test]
1146 fn backwards_compat_negative_year() {
1147 let year = Year {
1148 value: -99,
1149 unspecified: UnspecifiedYear::None,
1150 };
1151 let result = format_display_year(
1152 &year,
1153 &en_terms(),
1154 &EraLabels::Default,
1155 &NegativeUnspecifiedYears::Range,
1156 "–",
1157 );
1158 assert_eq!(result, "100 BC");
1159 }
1160}
1161
1162#[cfg(test)]
1163#[allow(
1164 clippy::unwrap_used,
1165 clippy::expect_used,
1166 reason = "Panicking is acceptable in tests."
1167)]
1168mod locale_pattern_tests {
1169 use super::*;
1170 use citum_schema::locale::Locale;
1171
1172 fn en_us() -> Locale {
1173 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1174 .expect("en-US locale should parse")
1175 }
1176
1177 fn es_es() -> Locale {
1178 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1179 .expect("es-ES locale should parse")
1180 }
1181
1182 fn eu_es() -> Locale {
1183 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1184 .expect("eu-ES locale should parse")
1185 }
1186
1187 fn full(locale: &Locale, edtf: &str) -> String {
1188 format_single_date(&EdtfString(edtf.to_string()), &DateForm::Full, locale, None)
1189 .expect("date should render")
1190 }
1191
1192 fn month_day(locale: &Locale, edtf: &str) -> String {
1193 format_single_date(
1194 &EdtfString(edtf.to_string()),
1195 &DateForm::MonthDay,
1196 locale,
1197 None,
1198 )
1199 .expect("date should render")
1200 }
1201
1202 #[test]
1203 fn en_us_full_unchanged_by_pattern_machinery() {
1204 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1207 }
1208
1209 #[test]
1210 fn en_us_month_day_unchanged_by_pattern_machinery() {
1211 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1212 }
1213
1214 #[test]
1215 fn es_es_full_uses_locale_pattern() {
1216 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1218 }
1219
1220 #[test]
1221 fn es_es_month_day_uses_locale_pattern() {
1222 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1223 }
1224
1225 #[test]
1226 fn eu_es_full_uses_locale_pattern() {
1227 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1230 }
1231
1232 #[test]
1233 fn eu_es_month_day_uses_locale_pattern() {
1234 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1235 }
1236
1237 fn year_month(locale: &Locale, edtf: &str) -> String {
1238 format_single_date(
1239 &EdtfString(edtf.to_string()),
1240 &DateForm::YearMonth,
1241 locale,
1242 None,
1243 )
1244 .expect("date should render")
1245 }
1246
1247 fn year_month_day(locale: &Locale, edtf: &str) -> String {
1248 format_single_date(
1249 &EdtfString(edtf.to_string()),
1250 &DateForm::YearMonthDay,
1251 locale,
1252 None,
1253 )
1254 .expect("date should render")
1255 }
1256
1257 fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1258 format_single_date(
1259 &EdtfString(edtf.to_string()),
1260 &DateForm::DayMonthAbbrYear,
1261 locale,
1262 None,
1263 )
1264 .expect("date should render")
1265 }
1266
1267 fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1268 format_single_date(
1269 &EdtfString(edtf.to_string()),
1270 &DateForm::MonthAbbrDayYear,
1271 locale,
1272 None,
1273 )
1274 .expect("date should render")
1275 }
1276
1277 #[test]
1278 fn en_us_year_month_unchanged_by_pattern_machinery() {
1279 assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1281 }
1282
1283 #[test]
1284 fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1285 assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1286 }
1287
1288 #[test]
1289 fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1290 assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1291 }
1292
1293 #[test]
1294 fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1295 assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1296 }
1297
1298 #[test]
1299 fn es_es_year_month_uses_locale_pattern() {
1300 assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1302 }
1303
1304 #[test]
1305 fn eu_es_year_month_uses_locale_pattern() {
1306 assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1308 }
1309
1310 #[test]
1311 fn year_month_missing_month_falls_back_to_year() {
1312 assert_eq!(year_month(&es_es(), "2023"), "2023");
1314 }
1315
1316 #[test]
1317 fn es_es_year_month_day_uses_locale_pattern() {
1318 assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1320 }
1321
1322 #[test]
1323 fn es_es_year_month_day_missing_day_falls_back() {
1324 assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1327 }
1328
1329 #[test]
1330 fn es_es_day_month_abbr_year_uses_locale_pattern() {
1331 assert_eq!(
1333 day_month_abbr_year(&es_es(), "2023-01-12"),
1334 "12 ene. de 2023"
1335 );
1336 }
1337
1338 #[test]
1339 fn es_es_day_month_abbr_year_missing_day_falls_back() {
1340 assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1342 }
1343
1344 #[test]
1345 fn es_es_month_abbr_day_year_uses_locale_pattern() {
1346 assert_eq!(
1348 month_abbr_day_year(&es_es(), "2023-01-12"),
1349 "ene. 12 de 2023"
1350 );
1351 }
1352
1353 #[test]
1354 fn es_es_month_abbr_day_year_missing_day_falls_back() {
1355 assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1357 }
1358
1359 #[test]
1360 fn pattern_missing_day_falls_back_to_english_assembly() {
1361 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1366 }
1367}