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