1use crate::reference::{DateValue, Reference};
12use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
13use citum_edtf::{Edtf, Timezone, UnspecifiedYear, Year};
14use citum_schema::locale::{GeneralTerm, TermForm};
15use citum_schema::options::dates::{DateRangeFormat, TimeFormat};
16use citum_schema::reference::types::RefDate;
17use citum_schema::reference::{ClassExtension, WorkRelation};
18use citum_schema::template::{
19 DateForm, DateVariable as TemplateDateVar, TemplateComponent, TemplateDate,
20};
21
22fn month_to_string(month: u32, months: &[String]) -> String {
23 if month > 0 {
24 let index = month - 1;
25 if let Some(month_name) = months.get(index as usize) {
26 month_name.clone()
27 } else {
28 String::new()
29 }
30 } else {
31 String::new()
32 }
33}
34
35fn extract_month_numeric(date: &DateValue) -> Option<String> {
39 let RefDate::Edtf(edtf) = date.parse() else {
40 return None;
41 };
42 let month = edtf.month()?;
43 (1..=12).contains(&month).then(|| format!("{month:02}"))
44}
45
46fn extract_month(date: &DateValue, months: &[String], seasons: &[String]) -> String {
47 let parsed_date = date.parse();
48 let edtf = match parsed_date {
49 RefDate::Edtf(edtf) => edtf,
50 RefDate::Literal(_) => return String::new(),
51 };
52 match edtf.month() {
53 Some(month) => month_to_string(month, months),
54 None => match edtf.season() {
55 Some(season) => month_to_string(season, seasons),
56 None => String::new(),
57 },
58 }
59}
60
61fn event_date(reference: &Reference) -> Option<DateValue> {
62 match reference.extension() {
63 ClassExtension::Event(event) => event.date.clone(),
64 ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
65 ClassExtension::SerialComponent(component) => {
66 embedded_event_date(component.event.as_ref()?)
67 }
68 ClassExtension::AudioVisual(audio_visual) => {
69 embedded_event_date(audio_visual.event.as_ref()?)
70 }
71 _ => None,
72 }
73}
74
75fn embedded_event_date(relation: &WorkRelation) -> Option<DateValue> {
76 let WorkRelation::Embedded(reference) = relation else {
77 return None;
78 };
79 let ClassExtension::Event(event) = reference.extension() else {
80 return None;
81 };
82 event.date.clone()
83}
84
85fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
87 match u {
88 UnspecifiedYear::None => 0,
89 UnspecifiedYear::One => 9,
90 UnspecifiedYear::Two => 99,
91 UnspecifiedYear::Three => 999,
92 UnspecifiedYear::Four => 9999,
93 }
94}
95
96fn format_display_year(
98 year: &Year,
99 date_terms: &citum_schema::locale::DateTerms,
100 era_labels: &citum_schema::options::dates::EraLabels,
101 _neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
102 range_delimiter: &str,
103) -> String {
104 if year.unspecified != UnspecifiedYear::None && year.value > 0 {
106 let mut s = year.value.to_string();
107 let unspec_count = match year.unspecified {
108 UnspecifiedYear::One => 1,
109 UnspecifiedYear::Two => 2,
110 UnspecifiedYear::Three => 3,
111 UnspecifiedYear::Four => 4,
112 _ => 0,
113 };
114 for _ in 0..unspec_count {
115 if let Some(last) = s.pop()
116 && last != '0'
117 {
118 s.push('X');
119 }
120 }
121 if s.len() < year.value.to_string().len() {
122 let diff = year.value.to_string().len() - s.len();
123 for _ in 0..diff {
124 s.push('X');
125 }
126 }
127 return s;
128 }
129
130 if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
132 let delta = unspecified_year_delta(&year.unspecified);
133 let astronomical_min = year.value - delta;
134 let astronomical_max = year.value;
135 let historical_end = 1 - astronomical_max;
136 let historical_start = 1 - astronomical_min;
137
138 let era_term = match era_labels {
139 citum_schema::options::dates::EraLabels::Default => {
140 date_terms.before_era.as_deref().unwrap_or("")
141 }
142 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
143 citum_schema::options::dates::EraLabels::BceCe => {
144 date_terms.bce.as_deref().unwrap_or("")
145 }
146 };
147
148 if era_term.is_empty() {
149 format!("{historical_start}{range_delimiter}{historical_end}")
150 } else {
151 format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
152 }
153 } else if year.value <= 0 {
154 let historical_year = 1 - year.value;
156 let era_term = match era_labels {
157 citum_schema::options::dates::EraLabels::Default => {
158 date_terms.before_era.as_deref().unwrap_or("")
159 }
160 citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
161 citum_schema::options::dates::EraLabels::BceCe => {
162 date_terms.bce.as_deref().unwrap_or("")
163 }
164 };
165
166 if era_term.is_empty() {
167 historical_year.to_string()
168 } else {
169 format!("{historical_year} {era_term}")
170 }
171 } else {
172 let era_term = match era_labels {
174 citum_schema::options::dates::EraLabels::Default => "",
175 citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
176 citum_schema::options::dates::EraLabels::BceCe => {
177 date_terms.ce.as_deref().unwrap_or("")
178 }
179 };
180
181 if era_term.is_empty() {
182 year.value.to_string()
183 } else {
184 format!("{} {}", year.value, era_term)
185 }
186 }
187}
188
189fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
191 if year.unspecified != UnspecifiedYear::None {
192 return year.to_string();
193 }
194
195 if year.value <= 0 {
196 let historical_year = 1 - year.value;
197 if let Some(term) = before_era.filter(|term| !term.is_empty()) {
198 format!("{historical_year} {term}")
199 } else {
200 historical_year.to_string()
201 }
202 } else {
203 year.value.to_string()
204 }
205}
206
207#[allow(dead_code, reason = "kept for backwards compatibility")]
208fn extract_display_year_legacy(date: &DateValue, before_era: Option<&str>) -> String {
209 match date.parse() {
210 RefDate::Edtf(edtf) => match edtf {
211 Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
212 Edtf::Interval(interval) => {
213 format_display_year_legacy(&interval.start.year, before_era)
214 }
215 Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
216 format_display_year_legacy(&date.year, before_era)
217 }
218 },
219 RefDate::Literal(_) => String::new(),
220 }
221}
222
223fn format_time(
228 time: citum_edtf::Time,
229 format: &TimeFormat,
230 show_seconds: bool,
231 show_timezone: bool,
232 am_term: Option<&str>,
233 pm_term: Option<&str>,
234 utc_term: Option<&str>,
235) -> String {
236 let (display_hour, period) = match format {
237 TimeFormat::Hour12 => {
238 let (h, p) = if time.hour == 0 {
239 (12u32, am_term.unwrap_or("AM"))
240 } else if time.hour < 12 {
241 (time.hour, am_term.unwrap_or("AM"))
242 } else if time.hour == 12 {
243 (12u32, pm_term.unwrap_or("PM"))
244 } else {
245 (time.hour - 12, pm_term.unwrap_or("PM"))
246 };
247 (h, Some(p))
248 }
249 TimeFormat::Hour24 => (time.hour, None),
250 };
251
252 let time_str = if show_seconds {
253 format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
254 } else {
255 format!("{:02}:{:02}", display_hour, time.minute)
256 };
257
258 let with_period = match period {
259 Some(p) => format!("{time_str} {p}"),
260 None => time_str,
261 };
262
263 if show_timezone {
264 let tz_str = match time.timezone {
265 Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
266 Some(Timezone::Offset(mins)) => {
267 let sign = if mins >= 0 { '+' } else { '-' };
268 let abs = mins.unsigned_abs();
269 format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
270 }
271 None => String::new(),
272 };
273 if tz_str.is_empty() {
274 with_period
275 } else {
276 format!("{with_period} {tz_str}")
277 }
278 } else {
279 with_period
280 }
281}
282
283fn format_date_range(
287 date: &DateValue,
288 form: &DateForm,
289 locale: &citum_schema::locale::Locale,
290 date_config: Option<&citum_schema::options::dates::DateConfig>,
291) -> Option<String> {
292 let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
293
294 match date.parse() {
295 RefDate::Edtf(Edtf::Interval(interval)) => {
296 format_closed_range(date, &interval, form, locale, date_config, delimiter)
297 }
298 RefDate::Edtf(Edtf::IntervalFrom(_)) => {
299 let start = format_single_date(date, form, locale, date_config)?;
302 if let Some(end_marker) = date_config
303 .and_then(|c| c.open_range_marker.as_deref())
304 .or(locale.dates.open_ended_term.as_deref())
305 {
306 Some(format!("{start}{delimiter}{end_marker}"))
307 } else {
308 Some(start)
309 }
310 }
311 _ => format_single_date(date, form, locale, date_config),
314 }
315}
316
317fn format_closed_range(
320 date: &DateValue,
321 interval: &citum_edtf::Interval,
322 form: &DateForm,
323 locale: &citum_schema::locale::Locale,
324 date_config: Option<&citum_schema::options::dates::DateConfig>,
325 delimiter: &str,
326) -> Option<String> {
327 if let Some(rendered) =
328 format_chicago_year_range(interval, form, locale, date_config, delimiter)
329 {
330 return Some(rendered);
331 }
332
333 let same_year = interval.start.year.value == interval.end.year.value;
334 let both_have_month =
335 interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
336
337 if same_year
338 && (both_have_month || matches!(form, DateForm::Year))
339 && let Some(collapsed) = format_same_year_range(
340 &interval.start,
341 &interval.end,
342 form,
343 locale,
344 date_config,
345 delimiter,
346 )
347 {
348 return Some(collapsed);
349 }
350
351 let start = format_single_date(date, form, locale, date_config);
352 let end = format_single_date(
353 &DateValue::new(interval.end.to_string()),
354 form,
355 locale,
356 date_config,
357 );
358
359 match (start, end) {
360 (Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
361 (Some(s), None) => Some(s),
362 (None, Some(e)) => Some(e),
363 (None, None) => None,
364 }
365}
366
367fn format_chicago_year_range(
372 interval: &citum_edtf::Interval,
373 form: &DateForm,
374 locale: &citum_schema::locale::Locale,
375 date_config: Option<&citum_schema::options::dates::DateConfig>,
376 delimiter: &str,
377) -> Option<String> {
378 if !matches!(form, DateForm::Year)
379 || !matches!(
380 date_config.map(|config| &config.range_format),
381 Some(DateRangeFormat::Chicago)
382 )
383 || interval.start.year.unspecified != UnspecifiedYear::None
384 || interval.end.year.unspecified != UnspecifiedYear::None
385 || interval.start.month_or_season.is_some()
386 || interval.end.month_or_season.is_some()
387 {
388 return None;
389 }
390
391 let start_is_bce = interval.start.year.value <= 0;
392 let end_is_bce = interval.end.year.value <= 0;
393 if start_is_bce != end_is_bce || interval.end.year.value <= interval.start.year.value {
394 return None;
395 }
396
397 let start = display_year_number(interval.start.year.value)?;
398 let end = display_year_number(interval.end.year.value)?;
399 let abbreviated_end = crate::values::number::format_chicago_range_end(start, end);
400 let era = chicago_year_range_era_suffix(start_is_bce, locale, date_config);
401 Some(format!("{start}{delimiter}{abbreviated_end}{era}"))
402}
403
404fn display_year_number(year: i64) -> Option<u32> {
405 let historical_year = if year <= 0 {
406 1_i64.checked_sub(year)?
407 } else {
408 year
409 };
410 u32::try_from(historical_year).ok()
411}
412
413fn chicago_year_range_era_suffix(
414 is_bce: bool,
415 locale: &citum_schema::locale::Locale,
416 date_config: Option<&citum_schema::options::dates::DateConfig>,
417) -> String {
418 use citum_schema::options::dates::EraLabels;
419
420 let era_labels = date_config
421 .map(|config| &config.era_labels)
422 .unwrap_or(&EraLabels::Default);
423 let label = match (is_bce, era_labels) {
424 (true, EraLabels::Default) => locale.dates.before_era.as_deref(),
425 (true, EraLabels::BcAd) => locale.dates.bc.as_deref(),
426 (true, EraLabels::BceCe) => locale.dates.bce.as_deref(),
427 (false, EraLabels::Default) => None,
428 (false, EraLabels::BcAd) => locale.dates.ad.as_deref(),
429 (false, EraLabels::BceCe) => locale.dates.ce.as_deref(),
430 };
431 label
432 .filter(|value| !value.is_empty())
433 .map(|value| format!(" {value}"))
434 .unwrap_or_default()
435}
436
437fn format_same_year_range(
444 start: &citum_edtf::Date,
445 end: &citum_edtf::Date,
446 form: &DateForm,
447 locale: &citum_schema::locale::Locale,
448 date_config: Option<&citum_schema::options::dates::DateConfig>,
449 delimiter: &str,
450) -> Option<String> {
451 let start_date = DateValue::new(start.to_string());
452 let end_date = DateValue::new(end.to_string());
453 let start_fragment = format_same_year_fragment(&start_date, form, locale, date_config)?;
454 let end_fragment = format_same_year_fragment(&end_date, form, locale, date_config)?;
455 let shared_year = date_form_displays_year(form)
456 .then(|| format_single_date(&start_date, &DateForm::Year, locale, date_config))
457 .flatten();
458
459 if let Some(pattern_id) = date_range_pattern_id(form)
460 && let Some(rendered) = locale.resolve_date_range_pattern(
461 pattern_id,
462 &start_fragment,
463 &end_fragment,
464 shared_year.as_deref(),
465 )
466 {
467 return Some(rendered);
468 }
469
470 match form {
471 DateForm::Full => {
472 let end_full = format_single_date(&end_date, &DateForm::Full, locale, date_config)?;
473 Some(format!("{start_fragment}{delimiter}{end_full}"))
474 }
475 DateForm::YearMonth => {
476 let end_full =
477 format_single_date(&end_date, &DateForm::YearMonth, locale, date_config)?;
478 Some(format!("{start_fragment}{delimiter}{end_full}"))
479 }
480 DateForm::YearMonthDay => {
481 let start_full =
482 format_single_date(&start_date, &DateForm::YearMonthDay, locale, date_config)?;
483 Some(format!("{start_full}{delimiter}{end_fragment}"))
484 }
485 _ => None,
486 }
487}
488
489fn date_range_pattern_id(form: &DateForm) -> Option<&'static str> {
490 match form {
491 DateForm::Year => Some("pattern.date-range-year"),
492 DateForm::Month => Some("pattern.date-range-month"),
493 DateForm::MonthDay => Some("pattern.date-range-month-day"),
494 DateForm::YearMonth => Some("pattern.date-range-year-month"),
495 DateForm::Full => Some("pattern.date-range-full"),
496 DateForm::YearMonthDay => Some("pattern.date-range-year-month-day"),
497 DateForm::DayMonthAbbrYear => Some("pattern.date-range-day-month-abbr-year"),
498 DateForm::MonthAbbrDayYear => Some("pattern.date-range-month-abbr-day-year"),
499 _ => None,
500 }
501}
502
503fn format_same_year_fragment(
504 date: &DateValue,
505 form: &DateForm,
506 locale: &citum_schema::locale::Locale,
507 date_config: Option<&citum_schema::options::dates::DateConfig>,
508) -> Option<String> {
509 match form {
510 DateForm::Year => format_single_date(date, &DateForm::Year, locale, date_config),
511 DateForm::Month | DateForm::YearMonth => {
512 format_single_date(date, &DateForm::Month, locale, date_config)
513 }
514 DateForm::Full | DateForm::MonthDay | DateForm::YearMonthDay => {
515 format_single_date(date, &DateForm::MonthDay, locale, date_config)
516 }
517 DateForm::DayMonthAbbrYear | DateForm::MonthAbbrDayYear => {
518 format_abbreviated_month_day_fragment(date, form, locale, date_config)
519 }
520 _ => None,
521 }
522}
523
524fn format_abbreviated_month_day_fragment(
525 date: &DateValue,
526 form: &DateForm,
527 locale: &citum_schema::locale::Locale,
528 date_config: Option<&citum_schema::options::dates::DateConfig>,
529) -> Option<String> {
530 let numeric_months = date_config
531 .is_some_and(|config| config.month == citum_schema::options::MonthFormat::Numeric);
532 if numeric_months && let Some(month) = extract_month_numeric(date) {
533 return Some(match date.day() {
534 Some(day) => format!("{month}-{day:02}"),
535 None => month,
536 });
537 }
538
539 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
540 if month.is_empty() {
541 return None;
542 }
543 match (form, date.day()) {
544 (DateForm::DayMonthAbbrYear, Some(day)) => Some(format!("{day} {month}")),
545 (DateForm::MonthAbbrDayYear, Some(day)) => Some(format!("{month} {day}")),
546 (_, None) => Some(month),
547 _ => None,
548 }
549}
550
551fn append_note<F: crate::render::format::OutputFormat<Output = String>>(
560 fmt: &F,
561 formatted: String,
562 date: &DateValue,
563 date_config: Option<&citum_schema::options::dates::DateConfig>,
564 reference: &Reference,
565 options: &RenderOptions<'_>,
566) -> String {
567 let Some(note) = date.note.as_deref().filter(|n| !n.is_empty()) else {
568 return formatted;
569 };
570 let Some(wrap) = date_config.and_then(|c| c.note_wrap.as_ref()) else {
571 return formatted;
572 };
573
574 let content = fmt.text(note);
575 let content = fmt.inner_affix(
576 wrap.inner_prefix.as_deref().unwrap_or_default(),
577 content,
578 wrap.inner_suffix.as_deref().unwrap_or_default(),
579 );
580 let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
581 let item_language = crate::values::effective_item_language(reference);
582 let (script, realization) = crate::values::punctuation_realization_context(
583 item_language.as_deref(),
584 options.config.multilingual.as_ref(),
585 options.locale.punctuation_realization.as_ref(),
586 );
587 let wrapped = fmt.wrap_punctuation(
588 &wrap.punctuation,
589 content,
590 &marks,
591 script,
592 realization.as_deref(),
593 );
594 format!("{formatted}{wrapped}")
595}
596
597fn apply_date_markers(
599 value: String,
600 date: &DateValue,
601 date_config: Option<&citum_schema::options::dates::DateConfig>,
602) -> String {
603 let mut result = value;
604 if date.is_approximate()
605 && let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
606 {
607 let suffix = date_config
608 .and_then(|c| c.approximation_marker_suffix.as_deref())
609 .unwrap_or("");
610 result = format!("{marker}{result}{suffix}");
611 }
612 if date.is_uncertain()
613 && let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
614 {
615 let prefix = date_config
616 .and_then(|c| c.uncertainty_marker_prefix.as_deref())
617 .unwrap_or("");
618 result = format!("{prefix}{result}{marker}");
619 }
620 result
621}
622
623fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
625 date: &DateValue,
626 form: &DateForm,
627 hints: &ProcHints,
628 options: &RenderOptions<'_>,
629 fmt: &F,
630) -> Option<String> {
631 if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
632 compute_disamb_suffix_label(hints, options, fmt)
633 } else {
634 None
635 }
636}
637
638fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
639 hints: &ProcHints,
640 options: &RenderOptions<'_>,
641 fmt: &F,
642) -> Option<String> {
643 let use_suffix = options
646 .config
647 .effective_processing()
648 .config()
649 .disambiguate
650 .as_ref()
651 .is_some_and(|d| d.year_suffix);
652
653 if hints.disamb_condition && use_suffix {
654 int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
655 } else {
656 None
657 }
658}
659
660fn date_form_displays_year(form: &DateForm) -> bool {
661 !matches!(form, DateForm::MonthDay)
662}
663
664fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
665 let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
666 date_config.no_date_year_suffix_delimiter.as_str()
667 });
668 value.push_str(delimiter);
669 value.push_str(suffix);
670}
671
672fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
673 if year.is_empty() || suffix.is_empty() {
674 return formatted.to_string();
675 }
676
677 let year_index = match form {
678 DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
679 DateForm::YearMonth
680 | DateForm::Full
681 | DateForm::DayMonthAbbrYear
682 | DateForm::MonthAbbrDayYear => formatted.rfind(year),
683 DateForm::MonthDay => None,
684 _ => None,
685 };
686
687 let Some(index) = year_index else {
688 return format!("{formatted}{suffix}");
689 };
690
691 let year_end = index + year.len();
692 #[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
693 let result = format!(
694 "{}{}{}{}",
695 &formatted[..index],
696 year,
697 suffix,
698 &formatted[year_end..]
699 );
700 result
701}
702
703#[allow(
705 clippy::too_many_lines,
706 reason = "date formatting handles 6 form variants"
707)]
708fn format_single_date(
709 date: &DateValue,
710 form: &DateForm,
711 locale: &citum_schema::locale::Locale,
712 date_config: Option<&citum_schema::options::dates::DateConfig>,
713) -> Option<String> {
714 let default_era = citum_schema::options::dates::EraLabels::Default;
715 let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
716 let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
717 let neg_unspecified = date_config
718 .map(|c| &c.negative_unspecified_years)
719 .unwrap_or(&default_neg_unspec);
720 let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
721 let numeric_months =
725 date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
726
727 let extract_year = |d: &DateValue| -> String {
728 match d.parse() {
729 RefDate::Edtf(edtf) => match edtf {
730 Edtf::Date(dt) => format_display_year(
731 &dt.year,
732 &locale.dates,
733 era_labels,
734 neg_unspecified,
735 range_delimiter,
736 ),
737 Edtf::Interval(interval) => format_display_year(
738 &interval.start.year,
739 &locale.dates,
740 era_labels,
741 neg_unspecified,
742 range_delimiter,
743 ),
744 Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
745 &dt.year,
746 &locale.dates,
747 era_labels,
748 neg_unspecified,
749 range_delimiter,
750 ),
751 },
752 RefDate::Literal(_) => String::new(),
753 }
754 };
755
756 match form {
757 DateForm::Year => {
758 let year = extract_year(date);
759 if year.is_empty() { None } else { Some(year) }
760 }
761 DateForm::YearMonth => {
762 let year = extract_year(date);
763 if year.is_empty() {
764 return None;
765 }
766 if numeric_months && let Some(month) = extract_month_numeric(date) {
767 return Some(format!("{year}-{month}"));
768 }
769 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
770 let month_opt = (!month.is_empty()).then_some(month.as_str());
771 if let Some(rendered) =
772 locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
773 {
774 return Some(rendered);
775 }
776 if month.is_empty() {
777 Some(year)
778 } else {
779 Some(format!("{month} {year}"))
780 }
781 }
782 DateForm::Month => {
783 if numeric_months && let Some(month) = extract_month_numeric(date) {
784 return Some(month);
785 }
786 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
787 if month.is_empty() { None } else { Some(month) }
788 }
789 DateForm::MonthDay => {
790 if numeric_months && let Some(month) = extract_month_numeric(date) {
791 return Some(match date.day() {
792 Some(d) => format!("{month}-{d:02}"),
793 None => month,
794 });
795 }
796 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
797 if month.is_empty() {
798 return None;
799 }
800 let day = date.day();
801 if let Some(rendered) =
802 locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
803 {
804 return Some(rendered);
805 }
806 match day {
807 Some(d) => Some(format!("{month} {d}")),
808 None => Some(month),
809 }
810 }
811 DateForm::Full => {
812 let year = extract_year(date);
813 if year.is_empty() {
814 return None;
815 }
816 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
817 let day = date.day();
818 let numeric_base = if numeric_months {
819 extract_month_numeric(date).map(|month| match day {
820 Some(d) => format!("{year}-{month}-{d:02}"),
821 None => format!("{year}-{month}"),
822 })
823 } else {
824 None
825 };
826 let base = numeric_base
827 .or_else(|| {
828 locale.resolve_date_pattern(
829 "pattern.date-full",
830 Some(&year),
831 (!month.is_empty()).then_some(month.as_str()),
832 day,
833 )
834 })
835 .unwrap_or_else(|| match (month.is_empty(), day) {
836 (true, _) => year.clone(),
837 (false, None) => format!("{month} {year}"),
838 (false, Some(d)) => format!("{month} {d}, {year}"),
839 });
840 if let (Some(time_fmt), Some(time)) = (
842 date_config.and_then(|c| c.time_format.as_ref()),
843 date.time(),
844 ) {
845 let show_secs = date_config.is_some_and(|c| c.show_seconds);
846 let show_tz = date_config.is_some_and(|c| c.show_timezone);
847 let time_str = format_time(
848 time,
849 time_fmt,
850 show_secs,
851 show_tz,
852 locale.dates.am.as_deref(),
853 locale.dates.pm.as_deref(),
854 locale.dates.timezone_utc.as_deref(),
855 );
856 Some(format!("{base}, {time_str}"))
857 } else {
858 Some(base)
859 }
860 }
861 DateForm::YearMonthDay => {
862 let year = extract_year(date);
863 if year.is_empty() {
864 return None;
865 }
866 if numeric_months && let Some(month) = extract_month_numeric(date) {
867 return Some(match date.day() {
868 Some(d) => format!("{year}-{month}-{d:02}"),
869 None => format!("{year}-{month}"),
870 });
871 }
872 let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
873 let day = date.day();
874 let month_opt = (!month.is_empty()).then_some(month.as_str());
875 if let Some(rendered) = locale.resolve_date_pattern(
876 "pattern.date-year-month-day",
877 Some(&year),
878 month_opt,
879 day,
880 ) {
881 return Some(rendered);
882 }
883 match (month.is_empty(), day) {
884 (true, _) => Some(year),
885 (false, None) => Some(format!("{year}, {month}")),
886 (false, Some(d)) => Some(format!("{year}, {month} {d}")),
887 }
888 }
889 DateForm::DayMonthAbbrYear => {
890 let year = extract_year(date);
891 if year.is_empty() {
892 return None;
893 }
894 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
895 let day = date.day();
896 let month_opt = (!month.is_empty()).then_some(month.as_str());
897 if let Some(rendered) = locale.resolve_date_pattern(
898 "pattern.date-day-month-abbr-year",
899 Some(&year),
900 month_opt,
901 day,
902 ) {
903 return Some(rendered);
904 }
905 match (month.is_empty(), day) {
906 (true, _) => Some(year),
907 (false, None) => Some(format!("{month} {year}")),
908 (false, Some(d)) => Some(format!("{d} {month} {year}")),
909 }
910 }
911 DateForm::MonthAbbrDayYear => {
912 let year = extract_year(date);
913 if year.is_empty() {
914 return None;
915 }
916 let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
917 let day = date.day();
918 let month_opt = (!month.is_empty()).then_some(month.as_str());
919 if let Some(rendered) = locale.resolve_date_pattern(
920 "pattern.date-month-abbr-day-year",
921 Some(&year),
922 month_opt,
923 day,
924 ) {
925 return Some(rendered);
926 }
927 match (month.is_empty(), day) {
928 (true, _) => Some(year),
929 (false, None) => Some(format!("{month} {year}")),
930 (false, Some(d)) => Some(format!("{month} {d}, {year}")),
931 }
932 }
933 _ => Some(extract_year(date)),
934 }
935}
936
937pub(crate) fn apply_fallback_component_rendering<
948 F: crate::render::format::OutputFormat<Output = String>,
949>(
950 fmt: &F,
951 value: &str,
952 pre_formatted: bool,
953 rendering: &citum_schema::template::Rendering,
954 reference: &Reference,
955 options: &RenderOptions<'_>,
956) -> F::Output {
957 let mut output = if pre_formatted {
958 fmt.join(vec![value.to_string()], "")
959 } else {
960 fmt.text(value)
961 };
962 if let Some(wrap_config) = rendering.wrap.as_ref() {
963 let (script, realization) = crate::values::punctuation_realization_context(
964 crate::values::effective_item_language(reference).as_deref(),
965 options.config.multilingual.as_ref(),
966 options.locale.punctuation_realization.as_ref(),
967 );
968 output = fmt.wrap_punctuation(
969 &wrap_config.punctuation,
970 output,
971 &crate::render::format::QuoteMarks::default(),
972 script,
973 realization.as_deref(),
974 );
975 }
976 let (script, realization) = crate::values::punctuation_realization_context(
977 crate::values::effective_item_language(reference).as_deref(),
978 options.config.multilingual.as_ref(),
979 options.locale.punctuation_realization.as_ref(),
980 );
981 let prefix = rendering
982 .prefix
983 .as_ref()
984 .map(|punctuation| {
985 crate::render::format::realize_punctuation(
986 punctuation,
987 script,
988 realization.as_deref(),
989 crate::render::format::PunctuationPosition::Prefix,
990 )
991 })
992 .unwrap_or_default();
993 let suffix = rendering
994 .suffix
995 .as_ref()
996 .map(|punctuation| {
997 crate::render::format::realize_punctuation(
998 punctuation,
999 script,
1000 realization.as_deref(),
1001 crate::render::format::PunctuationPosition::Suffix,
1002 )
1003 })
1004 .unwrap_or_default();
1005 if !prefix.is_empty() || !suffix.is_empty() {
1006 output = crate::render::format::apply_punctuation_affixes(
1007 fmt,
1008 rendering
1009 .prefix
1010 .as_ref()
1011 .map(|punctuation| (punctuation, prefix.as_ref())),
1012 output,
1013 rendering
1014 .suffix
1015 .as_ref()
1016 .map(|punctuation| (punctuation, suffix.as_ref())),
1017 );
1018 }
1019 output
1020}
1021
1022impl ComponentValues for TemplateDate {
1023 fn values<F: crate::render::format::OutputFormat<Output = String>>(
1024 &self,
1025 reference: &Reference,
1026 hints: &ProcHints,
1027 options: &RenderOptions<'_>,
1028 ) -> Option<ProcValues<F::Output>> {
1029 let fmt = F::default();
1030 let date_opt: Option<DateValue> = match self.date {
1031 TemplateDateVar::Issued => reference.effective_issued_date(),
1032 TemplateDateVar::Accessed => reference.accessed(),
1033 TemplateDateVar::OriginalPublished => reference.original_date(),
1034 TemplateDateVar::EventDate => event_date(reference),
1035 TemplateDateVar::Copyright => reference.copyright(),
1036 TemplateDateVar::Printing => reference.printing(),
1037 _ => None,
1038 };
1039
1040 let Some(date) = date_opt.filter(|d| !d.is_empty()) else {
1041 if let Some(fallbacks) = &self.fallback {
1043 for component in fallbacks {
1044 if let Some(values) = component.values::<F>(reference, hints, options) {
1045 let mut output = apply_fallback_component_rendering(
1046 &fmt,
1047 &values.value,
1048 values.pre_formatted,
1049 component.rendering(),
1050 reference,
1051 options,
1052 );
1053 if matches!(self.date, TemplateDateVar::Issued)
1066 && self.suppress_disamb_suffix != Some(true)
1067 && matches!(component, TemplateComponent::Message(_))
1068 && let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt)
1069 {
1070 append_no_date_disamb_suffix(&mut output, &suffix, options);
1071 }
1072 return Some(ProcValues {
1073 value: output,
1074 prefix: None,
1075 suffix: None,
1076 url: values.url,
1077 substituted_key: values.substituted_key,
1078 pre_formatted: true,
1079 });
1080 }
1081 }
1082 return None;
1083 }
1084 if matches!(self.date, TemplateDateVar::Issued)
1086 && let Some(mut nd) = options.locale.resolved_general_term(
1087 &GeneralTerm::NoDate,
1088 &TermForm::Short,
1089 None,
1090 )
1091 {
1092 if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
1093 append_no_date_disamb_suffix(&mut nd, &suffix, options);
1094 }
1095 return Some(ProcValues {
1096 value: nd,
1097 prefix: None,
1098 suffix: None,
1099 url: None,
1100 substituted_key: None,
1101 pre_formatted: false,
1102 });
1103 }
1104 return None;
1105 };
1106
1107 let locale = options.locale;
1108 let date_config = options.config.dates.as_ref();
1109 let effective_form = self.form.clone();
1110
1111 let formatted = format_date_range(&date, &effective_form, locale, date_config);
1112
1113 let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
1115
1116 let disamb_suffix = (matches!(self.date, TemplateDateVar::Issued)
1121 && self.suppress_disamb_suffix != Some(true))
1122 .then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
1123 .flatten();
1124
1125 formatted.map(|value| {
1126 let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
1127 (
1128 inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
1129 None,
1130 )
1131 } else {
1132 (value, None)
1133 };
1134
1135 let value = if self.suppress_note == Some(true) {
1136 value
1137 } else {
1138 append_note(&fmt, value, &date, date_config, reference, options)
1139 };
1140
1141 ProcValues {
1142 value,
1143 prefix: None,
1144 suffix,
1145 url: crate::values::resolve_effective_url(
1146 self.links.as_ref(),
1147 options.config.links.as_ref(),
1148 reference,
1149 citum_schema::options::LinkAnchor::Component,
1150 ),
1151 substituted_key: None,
1152 pre_formatted: false,
1153 }
1154 })
1155 }
1156}
1157
1158#[must_use]
1160pub fn int_to_letter(n: u32) -> Option<String> {
1161 if n == 0 {
1162 return None;
1163 }
1164
1165 let mut result = String::new();
1166 let mut num = n - 1;
1167
1168 loop {
1169 result.push((b'a' + (num % 26) as u8) as char);
1170 if num < 26 {
1171 break;
1172 }
1173 num = num / 26 - 1;
1174 }
1175
1176 Some(result.chars().rev().collect())
1177}
1178
1179#[cfg(test)]
1180#[allow(
1181 clippy::unwrap_used,
1182 clippy::expect_used,
1183 clippy::panic,
1184 clippy::indexing_slicing,
1185 clippy::todo,
1186 clippy::unimplemented,
1187 clippy::unreachable,
1188 clippy::get_unwrap,
1189 reason = "Panicking is acceptable and often desired in tests."
1190)]
1191mod tests {
1192 use super::*;
1193
1194 #[test]
1195 fn test_int_to_letter() {
1196 assert_eq!(int_to_letter(1), Some("a".to_string()));
1198 assert_eq!(int_to_letter(2), Some("b".to_string()));
1199 assert_eq!(int_to_letter(26), Some("z".to_string()));
1200
1201 assert_eq!(int_to_letter(27), Some("aa".to_string()));
1203 assert_eq!(int_to_letter(52), Some("az".to_string()));
1204 assert_eq!(int_to_letter(53), Some("ba".to_string()));
1205
1206 assert_eq!(int_to_letter(0), None);
1208 }
1209
1210 #[test]
1211 fn test_apply_date_markers_uncertainty_suffix_only_by_default() {
1212 let date = DateValue::new("1750?");
1213 let config = citum_schema::options::dates::DateConfig::default();
1214 let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1215 assert_eq!(result, "1750?");
1216 }
1217
1218 #[test]
1219 fn test_apply_date_markers_uncertainty_paired_brackets() {
1220 let date = DateValue::new("1750?");
1221 let config = citum_schema::options::dates::DateConfig {
1222 uncertainty_marker: Some("?]".to_string()),
1223 uncertainty_marker_prefix: Some("[".to_string()),
1224 ..citum_schema::options::dates::DateConfig::default()
1225 };
1226 let result = apply_date_markers("1750".to_string(), &date, Some(&config));
1227 assert_eq!(result, "[1750?]");
1228 }
1229}
1230
1231#[cfg(test)]
1232#[allow(
1233 clippy::unwrap_used,
1234 clippy::expect_used,
1235 clippy::panic,
1236 clippy::indexing_slicing,
1237 clippy::todo,
1238 clippy::unimplemented,
1239 clippy::unreachable,
1240 clippy::get_unwrap,
1241 reason = "Panicking is acceptable and often desired in tests."
1242)]
1243mod time_tests {
1244 use super::*;
1245 use citum_edtf::{Time, Timezone};
1246
1247 #[test]
1248 fn test_format_time_12h_utc() {
1249 let time = Time {
1250 hour: 23,
1251 minute: 20,
1252 second: 30,
1253 timezone: Some(Timezone::Utc),
1254 };
1255 let result = format_time(
1256 time,
1257 &TimeFormat::Hour12,
1258 false,
1259 true,
1260 Some("AM"),
1261 Some("PM"),
1262 Some("UTC"),
1263 );
1264 assert_eq!(result, "11:20 PM UTC");
1265 }
1266
1267 #[test]
1268 fn test_format_time_24h_utc() {
1269 let time = Time {
1270 hour: 23,
1271 minute: 20,
1272 second: 30,
1273 timezone: Some(Timezone::Utc),
1274 };
1275 let result = format_time(
1276 time,
1277 &TimeFormat::Hour24,
1278 false,
1279 true,
1280 None,
1281 None,
1282 Some("UTC"),
1283 );
1284 assert_eq!(result, "23:20 UTC");
1285 }
1286
1287 #[test]
1288 fn test_format_time_with_offset() {
1289 let time = Time {
1290 hour: 10,
1291 minute: 10,
1292 second: 10,
1293 timezone: Some(Timezone::Offset(330)),
1294 };
1295 let result = format_time(
1296 time,
1297 &TimeFormat::Hour24,
1298 false,
1299 true,
1300 None,
1301 None,
1302 Some("UTC"),
1303 );
1304 assert_eq!(result, "10:10 +05:30");
1305 }
1306
1307 #[test]
1308 fn test_format_time_no_timezone() {
1309 let time = Time {
1310 hour: 14,
1311 minute: 30,
1312 second: 0,
1313 timezone: None,
1314 };
1315 let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
1316 assert_eq!(result, "14:30");
1317 }
1318}
1319
1320#[cfg(test)]
1321#[allow(
1322 clippy::unwrap_used,
1323 clippy::expect_used,
1324 clippy::panic,
1325 clippy::indexing_slicing,
1326 clippy::todo,
1327 clippy::unimplemented,
1328 clippy::unreachable,
1329 clippy::get_unwrap,
1330 reason = "Panicking is acceptable and often desired in tests."
1331)]
1332mod era_tests {
1333 use super::*;
1334 use citum_edtf::{UnspecifiedYear, Year};
1335 use citum_schema::locale::{DateTerms, Locale};
1336 use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
1337
1338 fn en_terms() -> DateTerms {
1339 Locale::en_us().dates
1340 }
1341
1342 #[test]
1343 fn positive_year_default_no_suffix() {
1344 let year = Year {
1345 value: 54,
1346 unspecified: UnspecifiedYear::None,
1347 };
1348 let result = format_display_year(
1349 &year,
1350 &en_terms(),
1351 &EraLabels::Default,
1352 &NegativeUnspecifiedYears::Range,
1353 "–",
1354 );
1355 assert_eq!(result, "54");
1356 }
1357
1358 #[test]
1359 fn positive_year_bc_ad() {
1360 let year = Year {
1361 value: 54,
1362 unspecified: UnspecifiedYear::None,
1363 };
1364 let result = format_display_year(
1365 &year,
1366 &en_terms(),
1367 &EraLabels::BcAd,
1368 &NegativeUnspecifiedYears::Range,
1369 "–",
1370 );
1371 assert_eq!(result, "54 AD");
1372 }
1373
1374 #[test]
1375 fn positive_year_bce_ce() {
1376 let year = Year {
1377 value: 54,
1378 unspecified: UnspecifiedYear::None,
1379 };
1380 let result = format_display_year(
1381 &year,
1382 &en_terms(),
1383 &EraLabels::BceCe,
1384 &NegativeUnspecifiedYears::Range,
1385 "–",
1386 );
1387 assert_eq!(result, "54 CE");
1388 }
1389
1390 #[test]
1391 fn negative_year_default() {
1392 let year = Year {
1393 value: -43,
1394 unspecified: UnspecifiedYear::None,
1395 };
1396 let result = format_display_year(
1397 &year,
1398 &en_terms(),
1399 &EraLabels::Default,
1400 &NegativeUnspecifiedYears::Range,
1401 "–",
1402 );
1403 assert_eq!(result, "44 BC");
1404 }
1405
1406 #[test]
1407 fn negative_year_bc_ad() {
1408 let year = Year {
1409 value: -43,
1410 unspecified: UnspecifiedYear::None,
1411 };
1412 let result = format_display_year(
1413 &year,
1414 &en_terms(),
1415 &EraLabels::BcAd,
1416 &NegativeUnspecifiedYears::Range,
1417 "–",
1418 );
1419 assert_eq!(result, "44 BC");
1420 }
1421
1422 #[test]
1423 fn negative_year_bce_ce() {
1424 let year = Year {
1425 value: -43,
1426 unspecified: UnspecifiedYear::None,
1427 };
1428 let result = format_display_year(
1429 &year,
1430 &en_terms(),
1431 &EraLabels::BceCe,
1432 &NegativeUnspecifiedYears::Range,
1433 "–",
1434 );
1435 assert_eq!(result, "44 BCE");
1436 }
1437
1438 #[test]
1439 fn positive_unspecified_ones() {
1440 let year = Year {
1441 value: 1990,
1442 unspecified: UnspecifiedYear::One,
1443 };
1444 let result = format_display_year(
1445 &year,
1446 &en_terms(),
1447 &EraLabels::Default,
1448 &NegativeUnspecifiedYears::Range,
1449 "–",
1450 );
1451 assert_eq!(result, "199X");
1452 }
1453
1454 #[test]
1455 fn positive_unspecified_two() {
1456 let year = Year {
1457 value: 1900,
1458 unspecified: UnspecifiedYear::Two,
1459 };
1460 let result = format_display_year(
1461 &year,
1462 &en_terms(),
1463 &EraLabels::Default,
1464 &NegativeUnspecifiedYears::Range,
1465 "–",
1466 );
1467 assert_eq!(result, "19XX");
1468 }
1469
1470 #[test]
1471 fn negative_unspecified_range() {
1472 let year = Year {
1473 value: -90,
1474 unspecified: UnspecifiedYear::One,
1475 };
1476 let result = format_display_year(
1477 &year,
1478 &en_terms(),
1479 &EraLabels::Default,
1480 &NegativeUnspecifiedYears::Range,
1481 "–",
1482 );
1483 assert_eq!(result, "100–91 BC");
1484 }
1485
1486 #[test]
1487 fn negative_unspecified_century() {
1488 let year = Year {
1489 value: 0,
1490 unspecified: UnspecifiedYear::Two,
1491 };
1492 let result = format_display_year(
1493 &year,
1494 &en_terms(),
1495 &EraLabels::Default,
1496 &NegativeUnspecifiedYears::Range,
1497 "–",
1498 );
1499 assert_eq!(result, "100–1 BC");
1500 }
1501
1502 #[test]
1503 fn backwards_compat_negative_year() {
1504 let year = Year {
1505 value: -99,
1506 unspecified: UnspecifiedYear::None,
1507 };
1508 let result = format_display_year(
1509 &year,
1510 &en_terms(),
1511 &EraLabels::Default,
1512 &NegativeUnspecifiedYears::Range,
1513 "–",
1514 );
1515 assert_eq!(result, "100 BC");
1516 }
1517}
1518
1519#[cfg(test)]
1520#[allow(
1521 clippy::unwrap_used,
1522 clippy::expect_used,
1523 reason = "Panicking is acceptable in tests."
1524)]
1525mod locale_pattern_tests {
1526 use super::*;
1527 use citum_schema::locale::Locale;
1528
1529 fn en_us() -> Locale {
1530 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1531 .expect("en-US locale should parse")
1532 }
1533
1534 fn es_es() -> Locale {
1535 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1536 .expect("es-ES locale should parse")
1537 }
1538
1539 fn eu_es() -> Locale {
1540 Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
1541 .expect("eu-ES locale should parse")
1542 }
1543
1544 fn full(locale: &Locale, edtf: &str) -> String {
1545 format_single_date(
1546 &DateValue::new(edtf.to_string()),
1547 &DateForm::Full,
1548 locale,
1549 None,
1550 )
1551 .expect("date should render")
1552 }
1553
1554 fn month_day(locale: &Locale, edtf: &str) -> String {
1555 format_single_date(
1556 &DateValue::new(edtf.to_string()),
1557 &DateForm::MonthDay,
1558 locale,
1559 None,
1560 )
1561 .expect("date should render")
1562 }
1563
1564 #[test]
1565 fn en_us_full_unchanged_by_pattern_machinery() {
1566 assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
1569 }
1570
1571 #[test]
1572 fn en_us_month_day_unchanged_by_pattern_machinery() {
1573 assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
1574 }
1575
1576 #[test]
1577 fn en_us_month_form_renders_month_name_only() {
1578 let out = format_single_date(
1580 &DateValue::new("2023-06".to_string()),
1581 &DateForm::Month,
1582 &en_us(),
1583 None,
1584 );
1585 assert_eq!(out.as_deref(), Some("June"));
1587 }
1588
1589 #[test]
1590 fn en_us_month_form_renders_season_name() {
1591 let out = format_single_date(
1593 &DateValue::new("2023-21".to_string()),
1594 &DateForm::Month,
1595 &en_us(),
1596 None,
1597 );
1598 assert_eq!(out.as_deref(), Some("Spring"));
1600 }
1601
1602 #[test]
1603 fn en_us_year_month_form_renders_season_and_year() {
1604 let out = format_single_date(
1605 &DateValue::new("2023-21".to_string()),
1606 &DateForm::YearMonth,
1607 &en_us(),
1608 None,
1609 );
1610 assert_eq!(out.as_deref(), Some("Spring 2023"));
1611 }
1612
1613 #[test]
1614 fn en_us_full_form_renders_season_and_year() {
1615 assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
1616 }
1617
1618 #[test]
1619 fn es_es_year_month_form_renders_localized_season() {
1620 let out = format_single_date(
1621 &DateValue::new("2023-23".to_string()),
1622 &DateForm::YearMonth,
1623 &es_es(),
1624 None,
1625 );
1626 assert_eq!(out.as_deref(), Some("otoño de 2023"));
1627 }
1628
1629 #[test]
1630 fn es_es_full_uses_locale_pattern() {
1631 assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
1633 }
1634
1635 #[test]
1636 fn es_es_month_day_uses_locale_pattern() {
1637 assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
1638 }
1639
1640 #[test]
1641 fn eu_es_full_uses_locale_pattern() {
1642 assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
1645 }
1646
1647 #[test]
1648 fn eu_es_month_day_uses_locale_pattern() {
1649 assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
1650 }
1651
1652 fn year_month(locale: &Locale, edtf: &str) -> String {
1653 format_single_date(
1654 &DateValue::new(edtf.to_string()),
1655 &DateForm::YearMonth,
1656 locale,
1657 None,
1658 )
1659 .expect("date should render")
1660 }
1661
1662 fn year_month_day(locale: &Locale, edtf: &str) -> String {
1663 format_single_date(
1664 &DateValue::new(edtf.to_string()),
1665 &DateForm::YearMonthDay,
1666 locale,
1667 None,
1668 )
1669 .expect("date should render")
1670 }
1671
1672 fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
1673 format_single_date(
1674 &DateValue::new(edtf.to_string()),
1675 &DateForm::DayMonthAbbrYear,
1676 locale,
1677 None,
1678 )
1679 .expect("date should render")
1680 }
1681
1682 fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
1683 format_single_date(
1684 &DateValue::new(edtf.to_string()),
1685 &DateForm::MonthAbbrDayYear,
1686 locale,
1687 None,
1688 )
1689 .expect("date should render")
1690 }
1691
1692 #[test]
1693 fn en_us_year_month_unchanged_by_pattern_machinery() {
1694 assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
1696 }
1697
1698 #[test]
1699 fn en_us_year_month_day_unchanged_by_pattern_machinery() {
1700 assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
1701 }
1702
1703 #[test]
1704 fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
1705 assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
1706 }
1707
1708 #[test]
1709 fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
1710 assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
1711 }
1712
1713 #[test]
1714 fn es_es_year_month_uses_locale_pattern() {
1715 assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
1717 }
1718
1719 #[test]
1720 fn eu_es_year_month_uses_locale_pattern() {
1721 assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
1723 }
1724
1725 #[test]
1726 fn year_month_missing_month_falls_back_to_year() {
1727 assert_eq!(year_month(&es_es(), "2023"), "2023");
1729 }
1730
1731 #[test]
1732 fn es_es_year_month_day_uses_locale_pattern() {
1733 assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
1735 }
1736
1737 #[test]
1738 fn es_es_year_month_day_missing_day_falls_back() {
1739 assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
1742 }
1743
1744 #[test]
1745 fn es_es_day_month_abbr_year_uses_locale_pattern() {
1746 assert_eq!(
1748 day_month_abbr_year(&es_es(), "2023-01-12"),
1749 "12 ene. de 2023"
1750 );
1751 }
1752
1753 #[test]
1754 fn es_es_day_month_abbr_year_missing_day_falls_back() {
1755 assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
1757 }
1758
1759 #[test]
1760 fn es_es_month_abbr_day_year_uses_locale_pattern() {
1761 assert_eq!(
1763 month_abbr_day_year(&es_es(), "2023-01-12"),
1764 "ene. 12 de 2023"
1765 );
1766 }
1767
1768 #[test]
1769 fn es_es_month_abbr_day_year_missing_day_falls_back() {
1770 assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
1772 }
1773
1774 #[test]
1775 fn pattern_missing_day_falls_back_to_english_assembly() {
1776 assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
1781 }
1782}
1783
1784#[cfg(test)]
1785#[allow(
1786 clippy::unwrap_used,
1787 clippy::expect_used,
1788 reason = "Panicking is acceptable in tests."
1789)]
1790mod numeric_month_tests {
1791 use super::*;
1792 use citum_schema::locale::Locale;
1793 use citum_schema::options::MonthFormat;
1794 use citum_schema::options::dates::DateConfig;
1795
1796 fn en_us() -> Locale {
1797 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1798 .expect("en-US locale should parse")
1799 }
1800
1801 fn numeric_config() -> DateConfig {
1802 DateConfig {
1803 month: MonthFormat::Numeric,
1804 ..Default::default()
1805 }
1806 }
1807
1808 fn render(form: DateForm, edtf: &str) -> Option<String> {
1809 format_single_date(
1810 &DateValue::new(edtf.to_string()),
1811 &form,
1812 &en_us(),
1813 Some(&numeric_config()),
1814 )
1815 }
1816
1817 #[test]
1818 fn given_month_numeric_when_year_month_day_then_iso_hyphenated() {
1819 assert_eq!(
1821 render(DateForm::YearMonthDay, "2024-01-15").as_deref(),
1822 Some("2024-01-15")
1823 );
1824 }
1825
1826 #[test]
1827 fn given_month_numeric_when_day_missing_then_year_month_only() {
1828 assert_eq!(
1829 render(DateForm::YearMonthDay, "2024-01").as_deref(),
1830 Some("2024-01")
1831 );
1832 }
1833
1834 #[test]
1835 fn given_month_numeric_when_year_only_then_plain_year() {
1836 assert_eq!(
1837 render(DateForm::YearMonthDay, "2024").as_deref(),
1838 Some("2024")
1839 );
1840 }
1841
1842 #[test]
1843 fn given_month_numeric_when_year_month_form_then_hyphenated() {
1844 assert_eq!(
1845 render(DateForm::YearMonth, "2024-03").as_deref(),
1846 Some("2024-03")
1847 );
1848 }
1849
1850 #[test]
1851 fn given_month_numeric_when_month_day_form_then_zero_padded() {
1852 assert_eq!(
1853 render(DateForm::MonthDay, "2024-03-05").as_deref(),
1854 Some("03-05")
1855 );
1856 }
1857
1858 #[test]
1859 fn given_month_numeric_when_full_form_then_iso_hyphenated() {
1860 assert_eq!(
1861 render(DateForm::Full, "2024-01-15").as_deref(),
1862 Some("2024-01-15")
1863 );
1864 }
1865
1866 #[test]
1867 fn given_month_numeric_when_season_date_then_textual_fallback() {
1868 assert_eq!(
1870 render(DateForm::YearMonth, "2024-22").as_deref(),
1871 Some("Summer 2024")
1872 );
1873 }
1874
1875 #[test]
1876 fn given_long_month_config_when_year_month_day_then_unchanged() {
1877 let out = format_single_date(
1879 &DateValue::new("2024-01-15".to_string()),
1880 &DateForm::YearMonthDay,
1881 &en_us(),
1882 None,
1883 );
1884 assert_eq!(out.as_deref(), Some("2024, January 15"));
1885 }
1886}
1887
1888#[cfg(test)]
1889#[allow(
1890 clippy::unwrap_used,
1891 clippy::expect_used,
1892 reason = "Panicking is acceptable in tests."
1893)]
1894mod range_tests {
1895 use super::*;
1896 use citum_schema::locale::Locale;
1897 use citum_schema::options::dates::{DateConfig, EraLabels};
1898
1899 fn en_us() -> Locale {
1900 Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
1901 .expect("en-US locale should parse")
1902 }
1903
1904 fn es_es() -> Locale {
1905 Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
1906 .expect("es-ES locale should parse")
1907 }
1908
1909 fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1910 format_date_range(&DateValue::new(edtf.to_string()), &form, locale, None)
1911 }
1912
1913 fn chicago_range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
1914 let config = DateConfig {
1915 range_format: DateRangeFormat::Chicago,
1916 ..Default::default()
1917 };
1918 format_date_range(
1919 &DateValue::new(edtf.to_string()),
1920 &form,
1921 locale,
1922 Some(&config),
1923 )
1924 }
1925
1926 fn range_with_config(
1927 locale: &Locale,
1928 edtf: &str,
1929 form: DateForm,
1930 config: &DateConfig,
1931 ) -> Option<String> {
1932 format_date_range(
1933 &DateValue::new(edtf.to_string()),
1934 &form,
1935 locale,
1936 Some(config),
1937 )
1938 }
1939
1940 #[test]
1941 fn closed_range_year_form_regression() {
1942 assert_eq!(
1945 range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
1946 Some("2020–2022")
1947 );
1948 }
1949
1950 #[test]
1951 fn chicago_year_range_condenses_the_end_year() {
1952 assert_eq!(
1953 chicago_range(&en_us(), "2021/2026", DateForm::Year).as_deref(),
1954 Some("2021–26")
1955 );
1956 }
1957
1958 #[test]
1959 fn chicago_range_format_keeps_cross_year_month_ranges_expanded() {
1960 assert_eq!(
1961 chicago_range(&en_us(), "2021-05/2026-06", DateForm::YearMonth).as_deref(),
1962 Some("May 2021–June 2026")
1963 );
1964 }
1965
1966 #[test]
1967 fn shared_year_month_range_uses_spanish_mf2_pattern() {
1968 assert_eq!(
1969 range(&es_es(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
1970 Some("mayo a junio, 2026")
1971 );
1972 }
1973
1974 #[test]
1975 fn shared_year_full_range_uses_spanish_mf2_pattern() {
1976 assert_eq!(
1977 range(&es_es(), "2026-05-14/2026-06-02", DateForm::Full).as_deref(),
1978 Some("14 de mayo a 2 de junio de 2026")
1979 );
1980 }
1981
1982 #[test]
1983 fn chicago_year_range_condenses_same_era_bce_years() {
1984 let config = DateConfig {
1985 range_format: DateRangeFormat::Chicago,
1986 era_labels: EraLabels::BceCe,
1987 ..Default::default()
1988 };
1989 assert_eq!(
1990 range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
1991 Some("327–21 BCE")
1992 );
1993 }
1994
1995 #[test]
1996 fn expanded_same_era_bce_years_keep_both_endpoints() {
1997 let config = DateConfig {
1998 era_labels: EraLabels::BceCe,
1999 ..Default::default()
2000 };
2001 assert_eq!(
2002 range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
2003 Some("327 BCE–21 BCE")
2004 );
2005 }
2006
2007 #[test]
2008 fn chicago_year_range_preserves_cross_era_endpoints() {
2009 let config = DateConfig {
2010 range_format: DateRangeFormat::Chicago,
2011 era_labels: EraLabels::BcAd,
2012 ..Default::default()
2013 };
2014 assert_eq!(
2015 range_with_config(&en_us(), "-0114/0010", DateForm::Year, &config).as_deref(),
2016 Some("115 BC–10 AD")
2017 );
2018 }
2019
2020 #[test]
2021 fn chicago_year_range_keeps_reversed_input_expanded() {
2022 assert_eq!(
2023 chicago_range(&en_us(), "2026/2021", DateForm::Year).as_deref(),
2024 Some("2026–2021")
2025 );
2026 }
2027
2028 #[test]
2029 fn chicago_range_format_does_not_condense_non_four_digit_or_unspecified_years() {
2030 assert_eq!(
2031 chicago_range(&en_us(), "0999/1000", DateForm::Year).as_deref(),
2032 Some("999–1000")
2033 );
2034 assert_eq!(
2035 chicago_range(&en_us(), "202u/203u", DateForm::Year).as_deref(),
2036 Some("202X–203X")
2037 );
2038 }
2039
2040 #[test]
2041 fn closed_range_full_form_different_years() {
2042 assert_eq!(
2045 range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
2046 Some("May 14, 2023–June 2, 2024")
2047 );
2048 }
2049
2050 #[test]
2051 fn closed_range_full_form_same_year_collapses() {
2052 assert_eq!(
2055 range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
2056 Some("May 14–June 2, 2023")
2057 );
2058 }
2059
2060 #[test]
2061 fn closed_range_year_month_day_same_year_collapses() {
2062 assert_eq!(
2065 range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
2066 Some("2023, May 14–June 2")
2067 );
2068 }
2069
2070 #[test]
2071 fn closed_range_full_form_es_es_locale_pattern() {
2072 assert_eq!(
2076 range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
2077 Some("12 de enero de 2023–3 de febrero de 2024")
2078 );
2079 }
2080
2081 #[test]
2082 fn interval_to_year_form() {
2083 assert_eq!(
2086 range(&en_us(), "../2020", DateForm::Year).as_deref(),
2087 Some("2020")
2088 );
2089 }
2090
2091 #[test]
2092 fn closed_range_year_month_same_year_collapses() {
2093 assert_eq!(
2096 range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
2097 Some("May–June 2023")
2098 );
2099 }
2100
2101 #[test]
2102 fn chicago_range_format_keeps_same_year_month_ranges_locale_driven() {
2103 assert_eq!(
2104 chicago_range(&en_us(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
2105 Some("May–June 2026")
2106 );
2107 }
2108
2109 #[test]
2110 fn closed_range_season_same_year_collapses() {
2111 assert_eq!(
2114 range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
2115 Some("Spring–Summer 2023")
2116 );
2117 }
2118}