use crate::reference::{DateValue, Reference};
use crate::values::{ComponentValues, ProcHints, ProcValues, RenderOptions};
use citum_edtf::{Edtf, Timezone, UnspecifiedYear, Year};
use citum_schema::locale::{GeneralTerm, TermForm};
use citum_schema::options::dates::{DateRangeFormat, TimeFormat};
use citum_schema::reference::types::RefDate;
use citum_schema::reference::{ClassExtension, WorkRelation};
use citum_schema::template::{
DateForm, DateVariable as TemplateDateVar, TemplateComponent, TemplateDate,
};
fn month_to_string(month: u32, months: &[String]) -> String {
if month > 0 {
let index = month - 1;
if let Some(month_name) = months.get(index as usize) {
month_name.clone()
} else {
String::new()
}
} else {
String::new()
}
}
fn extract_month_numeric(date: &DateValue) -> Option<String> {
let RefDate::Edtf(edtf) = date.parse() else {
return None;
};
let month = edtf.month()?;
(1..=12).contains(&month).then(|| format!("{month:02}"))
}
fn extract_month(date: &DateValue, months: &[String], seasons: &[String]) -> String {
let parsed_date = date.parse();
let edtf = match parsed_date {
RefDate::Edtf(edtf) => edtf,
RefDate::Literal(_) => return String::new(),
};
match edtf.month() {
Some(month) => month_to_string(month, months),
None => match edtf.season() {
Some(season) => month_to_string(season, seasons),
None => String::new(),
},
}
}
fn event_date(reference: &Reference) -> Option<DateValue> {
match reference.extension() {
ClassExtension::Event(event) => event.date.clone(),
ClassExtension::Monograph(monograph) => embedded_event_date(monograph.event.as_ref()?),
ClassExtension::SerialComponent(component) => {
embedded_event_date(component.event.as_ref()?)
}
ClassExtension::AudioVisual(audio_visual) => {
embedded_event_date(audio_visual.event.as_ref()?)
}
_ => None,
}
}
fn embedded_event_date(relation: &WorkRelation) -> Option<DateValue> {
let WorkRelation::Embedded(reference) = relation else {
return None;
};
let ClassExtension::Event(event) = reference.extension() else {
return None;
};
event.date.clone()
}
fn unspecified_year_delta(u: &UnspecifiedYear) -> i64 {
match u {
UnspecifiedYear::None => 0,
UnspecifiedYear::One => 9,
UnspecifiedYear::Two => 99,
UnspecifiedYear::Three => 999,
UnspecifiedYear::Four => 9999,
}
}
fn format_display_year(
year: &Year,
date_terms: &citum_schema::locale::DateTerms,
era_labels: &citum_schema::options::dates::EraLabels,
_neg_unspecified: &citum_schema::options::dates::NegativeUnspecifiedYears,
range_delimiter: &str,
) -> String {
if year.unspecified != UnspecifiedYear::None && year.value > 0 {
let mut s = year.value.to_string();
let unspec_count = match year.unspecified {
UnspecifiedYear::One => 1,
UnspecifiedYear::Two => 2,
UnspecifiedYear::Three => 3,
UnspecifiedYear::Four => 4,
_ => 0,
};
for _ in 0..unspec_count {
if let Some(last) = s.pop()
&& last != '0'
{
s.push('X');
}
}
if s.len() < year.value.to_string().len() {
let diff = year.value.to_string().len() - s.len();
for _ in 0..diff {
s.push('X');
}
}
return s;
}
if year.unspecified != UnspecifiedYear::None && year.value <= 0 {
let delta = unspecified_year_delta(&year.unspecified);
let astronomical_min = year.value - delta;
let astronomical_max = year.value;
let historical_end = 1 - astronomical_max;
let historical_start = 1 - astronomical_min;
let era_term = match era_labels {
citum_schema::options::dates::EraLabels::Default => {
date_terms.before_era.as_deref().unwrap_or("")
}
citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
citum_schema::options::dates::EraLabels::BceCe => {
date_terms.bce.as_deref().unwrap_or("")
}
};
if era_term.is_empty() {
format!("{historical_start}{range_delimiter}{historical_end}")
} else {
format!("{historical_start}{range_delimiter}{historical_end} {era_term}")
}
} else if year.value <= 0 {
let historical_year = 1 - year.value;
let era_term = match era_labels {
citum_schema::options::dates::EraLabels::Default => {
date_terms.before_era.as_deref().unwrap_or("")
}
citum_schema::options::dates::EraLabels::BcAd => date_terms.bc.as_deref().unwrap_or(""),
citum_schema::options::dates::EraLabels::BceCe => {
date_terms.bce.as_deref().unwrap_or("")
}
};
if era_term.is_empty() {
historical_year.to_string()
} else {
format!("{historical_year} {era_term}")
}
} else {
let era_term = match era_labels {
citum_schema::options::dates::EraLabels::Default => "",
citum_schema::options::dates::EraLabels::BcAd => date_terms.ad.as_deref().unwrap_or(""),
citum_schema::options::dates::EraLabels::BceCe => {
date_terms.ce.as_deref().unwrap_or("")
}
};
if era_term.is_empty() {
year.value.to_string()
} else {
format!("{} {}", year.value, era_term)
}
}
}
fn format_display_year_legacy(year: &Year, before_era: Option<&str>) -> String {
if year.unspecified != UnspecifiedYear::None {
return year.to_string();
}
if year.value <= 0 {
let historical_year = 1 - year.value;
if let Some(term) = before_era.filter(|term| !term.is_empty()) {
format!("{historical_year} {term}")
} else {
historical_year.to_string()
}
} else {
year.value.to_string()
}
}
#[allow(dead_code, reason = "kept for backwards compatibility")]
fn extract_display_year_legacy(date: &DateValue, before_era: Option<&str>) -> String {
match date.parse() {
RefDate::Edtf(edtf) => match edtf {
Edtf::Date(date) => format_display_year_legacy(&date.year, before_era),
Edtf::Interval(interval) => {
format_display_year_legacy(&interval.start.year, before_era)
}
Edtf::IntervalFrom(date) | Edtf::IntervalTo(date) => {
format_display_year_legacy(&date.year, before_era)
}
},
RefDate::Literal(_) => String::new(),
}
}
fn format_time(
time: citum_edtf::Time,
format: &TimeFormat,
show_seconds: bool,
show_timezone: bool,
am_term: Option<&str>,
pm_term: Option<&str>,
utc_term: Option<&str>,
) -> String {
let (display_hour, period) = match format {
TimeFormat::Hour12 => {
let (h, p) = if time.hour == 0 {
(12u32, am_term.unwrap_or("AM"))
} else if time.hour < 12 {
(time.hour, am_term.unwrap_or("AM"))
} else if time.hour == 12 {
(12u32, pm_term.unwrap_or("PM"))
} else {
(time.hour - 12, pm_term.unwrap_or("PM"))
};
(h, Some(p))
}
TimeFormat::Hour24 => (time.hour, None),
};
let time_str = if show_seconds {
format!("{:02}:{:02}:{:02}", display_hour, time.minute, time.second)
} else {
format!("{:02}:{:02}", display_hour, time.minute)
};
let with_period = match period {
Some(p) => format!("{time_str} {p}"),
None => time_str,
};
if show_timezone {
let tz_str = match time.timezone {
Some(Timezone::Utc) => utc_term.unwrap_or("UTC").to_string(),
Some(Timezone::Offset(mins)) => {
let sign = if mins >= 0 { '+' } else { '-' };
let abs = mins.unsigned_abs();
format!("{}{:02}:{:02}", sign, abs / 60, abs % 60)
}
None => String::new(),
};
if tz_str.is_empty() {
with_period
} else {
format!("{with_period} {tz_str}")
}
} else {
with_period
}
}
fn format_date_range(
date: &DateValue,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
let delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
match date.parse() {
RefDate::Edtf(Edtf::Interval(interval)) => {
format_closed_range(date, &interval, form, locale, date_config, delimiter)
}
RefDate::Edtf(Edtf::IntervalFrom(_)) => {
let start = format_single_date(date, form, locale, date_config)?;
if let Some(end_marker) = date_config
.and_then(|c| c.open_range_marker.as_deref())
.or(locale.dates.open_ended_term.as_deref())
{
Some(format!("{start}{delimiter}{end_marker}"))
} else {
Some(start)
}
}
_ => format_single_date(date, form, locale, date_config),
}
}
fn format_closed_range(
date: &DateValue,
interval: &citum_edtf::Interval,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
delimiter: &str,
) -> Option<String> {
if let Some(rendered) =
format_chicago_year_range(interval, form, locale, date_config, delimiter)
{
return Some(rendered);
}
let same_year = interval.start.year.value == interval.end.year.value;
let both_have_month =
interval.start.month_or_season.is_some() && interval.end.month_or_season.is_some();
if same_year
&& (both_have_month || matches!(form, DateForm::Year))
&& let Some(collapsed) = format_same_year_range(
&interval.start,
&interval.end,
form,
locale,
date_config,
delimiter,
)
{
return Some(collapsed);
}
let start = format_single_date(date, form, locale, date_config);
let end = format_single_date(
&DateValue::new(interval.end.to_string()),
form,
locale,
date_config,
);
match (start, end) {
(Some(s), Some(e)) => Some(format!("{s}{delimiter}{e}")),
(Some(s), None) => Some(s),
(None, Some(e)) => Some(e),
(None, None) => None,
}
}
fn format_chicago_year_range(
interval: &citum_edtf::Interval,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
delimiter: &str,
) -> Option<String> {
if !matches!(form, DateForm::Year)
|| !matches!(
date_config.map(|config| &config.range_format),
Some(DateRangeFormat::Chicago)
)
|| interval.start.year.unspecified != UnspecifiedYear::None
|| interval.end.year.unspecified != UnspecifiedYear::None
|| interval.start.month_or_season.is_some()
|| interval.end.month_or_season.is_some()
{
return None;
}
let start_is_bce = interval.start.year.value <= 0;
let end_is_bce = interval.end.year.value <= 0;
if start_is_bce != end_is_bce || interval.end.year.value <= interval.start.year.value {
return None;
}
let start = display_year_number(interval.start.year.value)?;
let end = display_year_number(interval.end.year.value)?;
let abbreviated_end = crate::values::number::format_chicago_range_end(start, end);
let era = chicago_year_range_era_suffix(start_is_bce, locale, date_config);
Some(format!("{start}{delimiter}{abbreviated_end}{era}"))
}
fn display_year_number(year: i64) -> Option<u32> {
let historical_year = if year <= 0 {
1_i64.checked_sub(year)?
} else {
year
};
u32::try_from(historical_year).ok()
}
fn chicago_year_range_era_suffix(
is_bce: bool,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> String {
use citum_schema::options::dates::EraLabels;
let era_labels = date_config
.map(|config| &config.era_labels)
.unwrap_or(&EraLabels::Default);
let label = match (is_bce, era_labels) {
(true, EraLabels::Default) => locale.dates.before_era.as_deref(),
(true, EraLabels::BcAd) => locale.dates.bc.as_deref(),
(true, EraLabels::BceCe) => locale.dates.bce.as_deref(),
(false, EraLabels::Default) => None,
(false, EraLabels::BcAd) => locale.dates.ad.as_deref(),
(false, EraLabels::BceCe) => locale.dates.ce.as_deref(),
};
label
.filter(|value| !value.is_empty())
.map(|value| format!(" {value}"))
.unwrap_or_default()
}
fn format_same_year_range(
start: &citum_edtf::Date,
end: &citum_edtf::Date,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
delimiter: &str,
) -> Option<String> {
let start_date = DateValue::new(start.to_string());
let end_date = DateValue::new(end.to_string());
let start_fragment = format_same_year_fragment(&start_date, form, locale, date_config)?;
let end_fragment = format_same_year_fragment(&end_date, form, locale, date_config)?;
let shared_year = date_form_displays_year(form)
.then(|| format_single_date(&start_date, &DateForm::Year, locale, date_config))
.flatten();
if let Some(pattern_id) = date_range_pattern_id(form)
&& let Some(rendered) = locale.resolve_date_range_pattern(
pattern_id,
&start_fragment,
&end_fragment,
shared_year.as_deref(),
)
{
return Some(rendered);
}
match form {
DateForm::Full => {
let end_full = format_single_date(&end_date, &DateForm::Full, locale, date_config)?;
Some(format!("{start_fragment}{delimiter}{end_full}"))
}
DateForm::YearMonth => {
let end_full =
format_single_date(&end_date, &DateForm::YearMonth, locale, date_config)?;
Some(format!("{start_fragment}{delimiter}{end_full}"))
}
DateForm::YearMonthDay => {
let start_full =
format_single_date(&start_date, &DateForm::YearMonthDay, locale, date_config)?;
Some(format!("{start_full}{delimiter}{end_fragment}"))
}
_ => None,
}
}
fn date_range_pattern_id(form: &DateForm) -> Option<&'static str> {
match form {
DateForm::Year => Some("pattern.date-range-year"),
DateForm::Month => Some("pattern.date-range-month"),
DateForm::MonthDay => Some("pattern.date-range-month-day"),
DateForm::YearMonth => Some("pattern.date-range-year-month"),
DateForm::Full => Some("pattern.date-range-full"),
DateForm::YearMonthDay => Some("pattern.date-range-year-month-day"),
DateForm::DayMonthAbbrYear => Some("pattern.date-range-day-month-abbr-year"),
DateForm::MonthAbbrDayYear => Some("pattern.date-range-month-abbr-day-year"),
_ => None,
}
}
fn format_same_year_fragment(
date: &DateValue,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
match form {
DateForm::Year => format_single_date(date, &DateForm::Year, locale, date_config),
DateForm::Month | DateForm::YearMonth => {
format_single_date(date, &DateForm::Month, locale, date_config)
}
DateForm::Full | DateForm::MonthDay | DateForm::YearMonthDay => {
format_single_date(date, &DateForm::MonthDay, locale, date_config)
}
DateForm::DayMonthAbbrYear | DateForm::MonthAbbrDayYear => {
format_abbreviated_month_day_fragment(date, form, locale, date_config)
}
_ => None,
}
}
fn format_abbreviated_month_day_fragment(
date: &DateValue,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
let numeric_months = date_config
.is_some_and(|config| config.month == citum_schema::options::MonthFormat::Numeric);
if numeric_months && let Some(month) = extract_month_numeric(date) {
return Some(match date.day() {
Some(day) => format!("{month}-{day:02}"),
None => month,
});
}
let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
if month.is_empty() {
return None;
}
match (form, date.day()) {
(DateForm::DayMonthAbbrYear, Some(day)) => Some(format!("{day} {month}")),
(DateForm::MonthAbbrDayYear, Some(day)) => Some(format!("{month} {day}")),
(_, None) => Some(month),
_ => None,
}
}
fn append_note<F: crate::render::format::OutputFormat<Output = String>>(
fmt: &F,
formatted: String,
date: &DateValue,
date_config: Option<&citum_schema::options::dates::DateConfig>,
reference: &Reference,
options: &RenderOptions<'_>,
) -> String {
let Some(note) = date.note.as_deref().filter(|n| !n.is_empty()) else {
return formatted;
};
let Some(wrap) = date_config.and_then(|c| c.note_wrap.as_ref()) else {
return formatted;
};
let content = fmt.text(note);
let content = fmt.inner_affix(
wrap.inner_prefix.as_deref().unwrap_or_default(),
content,
wrap.inner_suffix.as_deref().unwrap_or_default(),
);
let marks = crate::render::format::QuoteMarks::from(&options.locale.grammar_options);
let item_language = crate::values::effective_item_language(reference);
let (script, realization) = crate::values::punctuation_realization_context(
item_language.as_deref(),
options.config.multilingual.as_ref(),
options.locale.punctuation_realization.as_ref(),
);
let wrapped = fmt.wrap_punctuation(
&wrap.punctuation,
content,
&marks,
script,
realization.as_deref(),
);
format!("{formatted}{wrapped}")
}
fn apply_date_markers(
value: String,
date: &DateValue,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> String {
let mut result = value;
if date.is_approximate()
&& let Some(marker) = date_config.and_then(|c| c.approximation_marker.as_ref())
{
let suffix = date_config
.and_then(|c| c.approximation_marker_suffix.as_deref())
.unwrap_or("");
result = format!("{marker}{result}{suffix}");
}
if date.is_uncertain()
&& let Some(marker) = date_config.and_then(|c| c.uncertainty_marker.as_ref())
{
let prefix = date_config
.and_then(|c| c.uncertainty_marker_prefix.as_deref())
.unwrap_or("");
result = format!("{prefix}{result}{marker}");
}
result
}
fn compute_disamb_suffix<F: crate::render::format::OutputFormat<Output = String>>(
date: &DateValue,
form: &DateForm,
hints: &ProcHints,
options: &RenderOptions<'_>,
fmt: &F,
) -> Option<String> {
if hints.disamb_condition && date_form_displays_year(form) && !date.year().is_empty() {
compute_disamb_suffix_label(hints, options, fmt)
} else {
None
}
}
fn compute_disamb_suffix_label<F: crate::render::format::OutputFormat<Output = String>>(
hints: &ProcHints,
options: &RenderOptions<'_>,
fmt: &F,
) -> Option<String> {
let use_suffix = options
.config
.effective_processing()
.config()
.disambiguate
.as_ref()
.is_some_and(|d| d.year_suffix);
if hints.disamb_condition && use_suffix {
int_to_letter(hints.group_index as u32).map(|s| fmt.text(&s))
} else {
None
}
}
fn date_form_displays_year(form: &DateForm) -> bool {
!matches!(form, DateForm::MonthDay)
}
fn append_no_date_disamb_suffix(value: &mut String, suffix: &str, options: &RenderOptions<'_>) {
let delimiter = options.config.dates.as_ref().map_or("-", |date_config| {
date_config.no_date_year_suffix_delimiter.as_str()
});
value.push_str(delimiter);
value.push_str(suffix);
}
fn inline_disamb_suffix(formatted: &str, form: &DateForm, year: &str, suffix: &str) -> String {
if year.is_empty() || suffix.is_empty() {
return formatted.to_string();
}
let year_index = match form {
DateForm::Year | DateForm::YearMonthDay => formatted.find(year),
DateForm::YearMonth
| DateForm::Full
| DateForm::DayMonthAbbrYear
| DateForm::MonthAbbrDayYear => formatted.rfind(year),
DateForm::MonthDay => None,
_ => None,
};
let Some(index) = year_index else {
return format!("{formatted}{suffix}");
};
let year_end = index + year.len();
#[allow(clippy::string_slice, reason = "indices derived from find/rfind")]
let result = format!(
"{}{}{}{}",
&formatted[..index],
year,
suffix,
&formatted[year_end..]
);
result
}
#[allow(
clippy::too_many_lines,
reason = "date formatting handles 6 form variants"
)]
fn format_single_date(
date: &DateValue,
form: &DateForm,
locale: &citum_schema::locale::Locale,
date_config: Option<&citum_schema::options::dates::DateConfig>,
) -> Option<String> {
let default_era = citum_schema::options::dates::EraLabels::Default;
let default_neg_unspec = citum_schema::options::dates::NegativeUnspecifiedYears::default();
let era_labels = date_config.map(|c| &c.era_labels).unwrap_or(&default_era);
let neg_unspecified = date_config
.map(|c| &c.negative_unspecified_years)
.unwrap_or(&default_neg_unspec);
let range_delimiter = date_config.map_or("–", |c| c.range_delimiter.as_str());
let numeric_months =
date_config.is_some_and(|c| c.month == citum_schema::options::MonthFormat::Numeric);
let extract_year = |d: &DateValue| -> String {
match d.parse() {
RefDate::Edtf(edtf) => match edtf {
Edtf::Date(dt) => format_display_year(
&dt.year,
&locale.dates,
era_labels,
neg_unspecified,
range_delimiter,
),
Edtf::Interval(interval) => format_display_year(
&interval.start.year,
&locale.dates,
era_labels,
neg_unspecified,
range_delimiter,
),
Edtf::IntervalFrom(dt) | Edtf::IntervalTo(dt) => format_display_year(
&dt.year,
&locale.dates,
era_labels,
neg_unspecified,
range_delimiter,
),
},
RefDate::Literal(_) => String::new(),
}
};
match form {
DateForm::Year => {
let year = extract_year(date);
if year.is_empty() { None } else { Some(year) }
}
DateForm::YearMonth => {
let year = extract_year(date);
if year.is_empty() {
return None;
}
if numeric_months && let Some(month) = extract_month_numeric(date) {
return Some(format!("{year}-{month}"));
}
let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
let month_opt = (!month.is_empty()).then_some(month.as_str());
if let Some(rendered) =
locale.resolve_date_pattern("pattern.date-year-month", Some(&year), month_opt, None)
{
return Some(rendered);
}
if month.is_empty() {
Some(year)
} else {
Some(format!("{month} {year}"))
}
}
DateForm::Month => {
if numeric_months && let Some(month) = extract_month_numeric(date) {
return Some(month);
}
let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
if month.is_empty() { None } else { Some(month) }
}
DateForm::MonthDay => {
if numeric_months && let Some(month) = extract_month_numeric(date) {
return Some(match date.day() {
Some(d) => format!("{month}-{d:02}"),
None => month,
});
}
let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
if month.is_empty() {
return None;
}
let day = date.day();
if let Some(rendered) =
locale.resolve_date_pattern("pattern.date-month-day", None, Some(&month), day)
{
return Some(rendered);
}
match day {
Some(d) => Some(format!("{month} {d}")),
None => Some(month),
}
}
DateForm::Full => {
let year = extract_year(date);
if year.is_empty() {
return None;
}
let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
let day = date.day();
let numeric_base = if numeric_months {
extract_month_numeric(date).map(|month| match day {
Some(d) => format!("{year}-{month}-{d:02}"),
None => format!("{year}-{month}"),
})
} else {
None
};
let base = numeric_base
.or_else(|| {
locale.resolve_date_pattern(
"pattern.date-full",
Some(&year),
(!month.is_empty()).then_some(month.as_str()),
day,
)
})
.unwrap_or_else(|| match (month.is_empty(), day) {
(true, _) => year.clone(),
(false, None) => format!("{month} {year}"),
(false, Some(d)) => format!("{month} {d}, {year}"),
});
if let (Some(time_fmt), Some(time)) = (
date_config.and_then(|c| c.time_format.as_ref()),
date.time(),
) {
let show_secs = date_config.is_some_and(|c| c.show_seconds);
let show_tz = date_config.is_some_and(|c| c.show_timezone);
let time_str = format_time(
time,
time_fmt,
show_secs,
show_tz,
locale.dates.am.as_deref(),
locale.dates.pm.as_deref(),
locale.dates.timezone_utc.as_deref(),
);
Some(format!("{base}, {time_str}"))
} else {
Some(base)
}
}
DateForm::YearMonthDay => {
let year = extract_year(date);
if year.is_empty() {
return None;
}
if numeric_months && let Some(month) = extract_month_numeric(date) {
return Some(match date.day() {
Some(d) => format!("{year}-{month}-{d:02}"),
None => format!("{year}-{month}"),
});
}
let month = extract_month(date, &locale.dates.months.long, &locale.dates.seasons);
let day = date.day();
let month_opt = (!month.is_empty()).then_some(month.as_str());
if let Some(rendered) = locale.resolve_date_pattern(
"pattern.date-year-month-day",
Some(&year),
month_opt,
day,
) {
return Some(rendered);
}
match (month.is_empty(), day) {
(true, _) => Some(year),
(false, None) => Some(format!("{year}, {month}")),
(false, Some(d)) => Some(format!("{year}, {month} {d}")),
}
}
DateForm::DayMonthAbbrYear => {
let year = extract_year(date);
if year.is_empty() {
return None;
}
let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
let day = date.day();
let month_opt = (!month.is_empty()).then_some(month.as_str());
if let Some(rendered) = locale.resolve_date_pattern(
"pattern.date-day-month-abbr-year",
Some(&year),
month_opt,
day,
) {
return Some(rendered);
}
match (month.is_empty(), day) {
(true, _) => Some(year),
(false, None) => Some(format!("{month} {year}")),
(false, Some(d)) => Some(format!("{d} {month} {year}")),
}
}
DateForm::MonthAbbrDayYear => {
let year = extract_year(date);
if year.is_empty() {
return None;
}
let month = extract_month(date, &locale.dates.months.short, &locale.dates.seasons);
let day = date.day();
let month_opt = (!month.is_empty()).then_some(month.as_str());
if let Some(rendered) = locale.resolve_date_pattern(
"pattern.date-month-abbr-day-year",
Some(&year),
month_opt,
day,
) {
return Some(rendered);
}
match (month.is_empty(), day) {
(true, _) => Some(year),
(false, None) => Some(format!("{month} {year}")),
(false, Some(d)) => Some(format!("{month} {d}, {year}")),
}
}
_ => Some(extract_year(date)),
}
}
pub(crate) fn apply_fallback_component_rendering<
F: crate::render::format::OutputFormat<Output = String>,
>(
fmt: &F,
value: &str,
pre_formatted: bool,
rendering: &citum_schema::template::Rendering,
reference: &Reference,
options: &RenderOptions<'_>,
) -> F::Output {
let mut output = if pre_formatted {
fmt.join(vec![value.to_string()], "")
} else {
fmt.text(value)
};
if let Some(wrap_config) = rendering.wrap.as_ref() {
let (script, realization) = crate::values::punctuation_realization_context(
crate::values::effective_item_language(reference).as_deref(),
options.config.multilingual.as_ref(),
options.locale.punctuation_realization.as_ref(),
);
output = fmt.wrap_punctuation(
&wrap_config.punctuation,
output,
&crate::render::format::QuoteMarks::default(),
script,
realization.as_deref(),
);
}
let (script, realization) = crate::values::punctuation_realization_context(
crate::values::effective_item_language(reference).as_deref(),
options.config.multilingual.as_ref(),
options.locale.punctuation_realization.as_ref(),
);
let prefix = rendering
.prefix
.as_ref()
.map(|punctuation| {
crate::render::format::realize_punctuation(
punctuation,
script,
realization.as_deref(),
crate::render::format::PunctuationPosition::Prefix,
)
})
.unwrap_or_default();
let suffix = rendering
.suffix
.as_ref()
.map(|punctuation| {
crate::render::format::realize_punctuation(
punctuation,
script,
realization.as_deref(),
crate::render::format::PunctuationPosition::Suffix,
)
})
.unwrap_or_default();
if !prefix.is_empty() || !suffix.is_empty() {
output = crate::render::format::apply_punctuation_affixes(
fmt,
rendering
.prefix
.as_ref()
.map(|punctuation| (punctuation, prefix.as_ref())),
output,
rendering
.suffix
.as_ref()
.map(|punctuation| (punctuation, suffix.as_ref())),
);
}
output
}
impl ComponentValues for TemplateDate {
fn values<F: crate::render::format::OutputFormat<Output = String>>(
&self,
reference: &Reference,
hints: &ProcHints,
options: &RenderOptions<'_>,
) -> Option<ProcValues<F::Output>> {
let fmt = F::default();
let date_opt: Option<DateValue> = match self.date {
TemplateDateVar::Issued => reference.effective_issued_date(),
TemplateDateVar::Accessed => reference.accessed(),
TemplateDateVar::OriginalPublished => reference.original_date(),
TemplateDateVar::EventDate => event_date(reference),
TemplateDateVar::Copyright => reference.copyright(),
TemplateDateVar::Printing => reference.printing(),
_ => None,
};
let Some(date) = date_opt.filter(|d| !d.is_empty()) else {
if let Some(fallbacks) = &self.fallback {
for component in fallbacks {
if let Some(values) = component.values::<F>(reference, hints, options) {
let mut output = apply_fallback_component_rendering(
&fmt,
&values.value,
values.pre_formatted,
component.rendering(),
reference,
options,
);
if matches!(self.date, TemplateDateVar::Issued)
&& self.suppress_disamb_suffix != Some(true)
&& matches!(component, TemplateComponent::Message(_))
&& let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt)
{
append_no_date_disamb_suffix(&mut output, &suffix, options);
}
return Some(ProcValues {
value: output,
prefix: None,
suffix: None,
url: values.url,
substituted_key: values.substituted_key,
pre_formatted: true,
});
}
}
return None;
}
if matches!(self.date, TemplateDateVar::Issued)
&& let Some(mut nd) = options.locale.resolved_general_term(
&GeneralTerm::NoDate,
&TermForm::Short,
None,
)
{
if let Some(suffix) = compute_disamb_suffix_label(hints, options, &fmt) {
append_no_date_disamb_suffix(&mut nd, &suffix, options);
}
return Some(ProcValues {
value: nd,
prefix: None,
suffix: None,
url: None,
substituted_key: None,
pre_formatted: false,
});
}
return None;
};
let locale = options.locale;
let date_config = options.config.dates.as_ref();
let effective_form = self.form.clone();
let formatted = format_date_range(&date, &effective_form, locale, date_config);
let formatted = formatted.map(|value| apply_date_markers(value, &date, date_config));
let disamb_suffix = (matches!(self.date, TemplateDateVar::Issued)
&& self.suppress_disamb_suffix != Some(true))
.then(|| compute_disamb_suffix(&date, &effective_form, hints, options, &fmt))
.flatten();
formatted.map(|value| {
let (value, suffix) = if let Some(ref suffix) = disamb_suffix {
(
inline_disamb_suffix(&value, &effective_form, &date.year(), suffix),
None,
)
} else {
(value, None)
};
let value = if self.suppress_note == Some(true) {
value
} else {
append_note(&fmt, value, &date, date_config, reference, options)
};
ProcValues {
value,
prefix: None,
suffix,
url: crate::values::resolve_effective_url(
self.links.as_ref(),
options.config.links.as_ref(),
reference,
citum_schema::options::LinkAnchor::Component,
),
substituted_key: None,
pre_formatted: false,
}
})
}
}
#[must_use]
pub fn int_to_letter(n: u32) -> Option<String> {
if n == 0 {
return None;
}
let mut result = String::new();
let mut num = n - 1;
loop {
result.push((b'a' + (num % 26) as u8) as char);
if num < 26 {
break;
}
num = num / 26 - 1;
}
Some(result.chars().rev().collect())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
#[test]
fn test_int_to_letter() {
assert_eq!(int_to_letter(1), Some("a".to_string()));
assert_eq!(int_to_letter(2), Some("b".to_string()));
assert_eq!(int_to_letter(26), Some("z".to_string()));
assert_eq!(int_to_letter(27), Some("aa".to_string()));
assert_eq!(int_to_letter(52), Some("az".to_string()));
assert_eq!(int_to_letter(53), Some("ba".to_string()));
assert_eq!(int_to_letter(0), None);
}
#[test]
fn test_apply_date_markers_uncertainty_suffix_only_by_default() {
let date = DateValue::new("1750?");
let config = citum_schema::options::dates::DateConfig::default();
let result = apply_date_markers("1750".to_string(), &date, Some(&config));
assert_eq!(result, "1750?");
}
#[test]
fn test_apply_date_markers_uncertainty_paired_brackets() {
let date = DateValue::new("1750?");
let config = citum_schema::options::dates::DateConfig {
uncertainty_marker: Some("?]".to_string()),
uncertainty_marker_prefix: Some("[".to_string()),
..citum_schema::options::dates::DateConfig::default()
};
let result = apply_date_markers("1750".to_string(), &date, Some(&config));
assert_eq!(result, "[1750?]");
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod time_tests {
use super::*;
use citum_edtf::{Time, Timezone};
#[test]
fn test_format_time_12h_utc() {
let time = Time {
hour: 23,
minute: 20,
second: 30,
timezone: Some(Timezone::Utc),
};
let result = format_time(
time,
&TimeFormat::Hour12,
false,
true,
Some("AM"),
Some("PM"),
Some("UTC"),
);
assert_eq!(result, "11:20 PM UTC");
}
#[test]
fn test_format_time_24h_utc() {
let time = Time {
hour: 23,
minute: 20,
second: 30,
timezone: Some(Timezone::Utc),
};
let result = format_time(
time,
&TimeFormat::Hour24,
false,
true,
None,
None,
Some("UTC"),
);
assert_eq!(result, "23:20 UTC");
}
#[test]
fn test_format_time_with_offset() {
let time = Time {
hour: 10,
minute: 10,
second: 10,
timezone: Some(Timezone::Offset(330)),
};
let result = format_time(
time,
&TimeFormat::Hour24,
false,
true,
None,
None,
Some("UTC"),
);
assert_eq!(result, "10:10 +05:30");
}
#[test]
fn test_format_time_no_timezone() {
let time = Time {
hour: 14,
minute: 30,
second: 0,
timezone: None,
};
let result = format_time(time, &TimeFormat::Hour24, false, false, None, None, None);
assert_eq!(result, "14:30");
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod era_tests {
use super::*;
use citum_edtf::{UnspecifiedYear, Year};
use citum_schema::locale::{DateTerms, Locale};
use citum_schema::options::dates::{EraLabels, NegativeUnspecifiedYears};
fn en_terms() -> DateTerms {
Locale::en_us().dates
}
#[test]
fn positive_year_default_no_suffix() {
let year = Year {
value: 54,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "54");
}
#[test]
fn positive_year_bc_ad() {
let year = Year {
value: 54,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::BcAd,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "54 AD");
}
#[test]
fn positive_year_bce_ce() {
let year = Year {
value: 54,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::BceCe,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "54 CE");
}
#[test]
fn negative_year_default() {
let year = Year {
value: -43,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "44 BC");
}
#[test]
fn negative_year_bc_ad() {
let year = Year {
value: -43,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::BcAd,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "44 BC");
}
#[test]
fn negative_year_bce_ce() {
let year = Year {
value: -43,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::BceCe,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "44 BCE");
}
#[test]
fn positive_unspecified_ones() {
let year = Year {
value: 1990,
unspecified: UnspecifiedYear::One,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "199X");
}
#[test]
fn positive_unspecified_two() {
let year = Year {
value: 1900,
unspecified: UnspecifiedYear::Two,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "19XX");
}
#[test]
fn negative_unspecified_range() {
let year = Year {
value: -90,
unspecified: UnspecifiedYear::One,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "100–91 BC");
}
#[test]
fn negative_unspecified_century() {
let year = Year {
value: 0,
unspecified: UnspecifiedYear::Two,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "100–1 BC");
}
#[test]
fn backwards_compat_negative_year() {
let year = Year {
value: -99,
unspecified: UnspecifiedYear::None,
};
let result = format_display_year(
&year,
&en_terms(),
&EraLabels::Default,
&NegativeUnspecifiedYears::Range,
"–",
);
assert_eq!(result, "100 BC");
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "Panicking is acceptable in tests."
)]
mod locale_pattern_tests {
use super::*;
use citum_schema::locale::Locale;
fn en_us() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
.expect("en-US locale should parse")
}
fn es_es() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
.expect("es-ES locale should parse")
}
fn eu_es() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/eu-ES.yaml"))
.expect("eu-ES locale should parse")
}
fn full(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::Full,
locale,
None,
)
.expect("date should render")
}
fn month_day(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::MonthDay,
locale,
None,
)
.expect("date should render")
}
#[test]
fn en_us_full_unchanged_by_pattern_machinery() {
assert_eq!(full(&en_us(), "2023-01-12"), "January 12, 2023");
}
#[test]
fn en_us_month_day_unchanged_by_pattern_machinery() {
assert_eq!(month_day(&en_us(), "2023-01-12"), "January 12");
}
#[test]
fn en_us_month_form_renders_month_name_only() {
let out = format_single_date(
&DateValue::new("2023-06".to_string()),
&DateForm::Month,
&en_us(),
None,
);
assert_eq!(out.as_deref(), Some("June"));
}
#[test]
fn en_us_month_form_renders_season_name() {
let out = format_single_date(
&DateValue::new("2023-21".to_string()),
&DateForm::Month,
&en_us(),
None,
);
assert_eq!(out.as_deref(), Some("Spring"));
}
#[test]
fn en_us_year_month_form_renders_season_and_year() {
let out = format_single_date(
&DateValue::new("2023-21".to_string()),
&DateForm::YearMonth,
&en_us(),
None,
);
assert_eq!(out.as_deref(), Some("Spring 2023"));
}
#[test]
fn en_us_full_form_renders_season_and_year() {
assert_eq!(full(&en_us(), "2023-21"), "Spring 2023");
}
#[test]
fn es_es_year_month_form_renders_localized_season() {
let out = format_single_date(
&DateValue::new("2023-23".to_string()),
&DateForm::YearMonth,
&es_es(),
None,
);
assert_eq!(out.as_deref(), Some("otoño de 2023"));
}
#[test]
fn es_es_full_uses_locale_pattern() {
assert_eq!(full(&es_es(), "2023-01-12"), "12 de enero de 2023");
}
#[test]
fn es_es_month_day_uses_locale_pattern() {
assert_eq!(month_day(&es_es(), "2023-01-12"), "12 de enero");
}
#[test]
fn eu_es_full_uses_locale_pattern() {
assert_eq!(full(&eu_es(), "2023-01-12"), "2023ko urtarrilaren 12a");
}
#[test]
fn eu_es_month_day_uses_locale_pattern() {
assert_eq!(month_day(&eu_es(), "2023-01-12"), "urtarrilaren 12a");
}
fn year_month(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::YearMonth,
locale,
None,
)
.expect("date should render")
}
fn year_month_day(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::YearMonthDay,
locale,
None,
)
.expect("date should render")
}
fn day_month_abbr_year(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::DayMonthAbbrYear,
locale,
None,
)
.expect("date should render")
}
fn month_abbr_day_year(locale: &Locale, edtf: &str) -> String {
format_single_date(
&DateValue::new(edtf.to_string()),
&DateForm::MonthAbbrDayYear,
locale,
None,
)
.expect("date should render")
}
#[test]
fn en_us_year_month_unchanged_by_pattern_machinery() {
assert_eq!(year_month(&en_us(), "2023-01"), "January 2023");
}
#[test]
fn en_us_year_month_day_unchanged_by_pattern_machinery() {
assert_eq!(year_month_day(&en_us(), "2023-01-12"), "2023, January 12");
}
#[test]
fn en_us_day_month_abbr_year_unchanged_by_pattern_machinery() {
assert_eq!(day_month_abbr_year(&en_us(), "2023-01-12"), "12 Jan. 2023");
}
#[test]
fn en_us_month_abbr_day_year_unchanged_by_pattern_machinery() {
assert_eq!(month_abbr_day_year(&en_us(), "2023-01-12"), "Jan. 12, 2023");
}
#[test]
fn es_es_year_month_uses_locale_pattern() {
assert_eq!(year_month(&es_es(), "2023-01"), "enero de 2023");
}
#[test]
fn eu_es_year_month_uses_locale_pattern() {
assert_eq!(year_month(&eu_es(), "2023-01"), "2023ko urtarrila");
}
#[test]
fn year_month_missing_month_falls_back_to_year() {
assert_eq!(year_month(&es_es(), "2023"), "2023");
}
#[test]
fn es_es_year_month_day_uses_locale_pattern() {
assert_eq!(year_month_day(&es_es(), "2023-01-12"), "2023, 12 de enero");
}
#[test]
fn es_es_year_month_day_missing_day_falls_back() {
assert_eq!(year_month_day(&es_es(), "2023-01"), "2023, enero");
}
#[test]
fn es_es_day_month_abbr_year_uses_locale_pattern() {
assert_eq!(
day_month_abbr_year(&es_es(), "2023-01-12"),
"12 ene. de 2023"
);
}
#[test]
fn es_es_day_month_abbr_year_missing_day_falls_back() {
assert_eq!(day_month_abbr_year(&es_es(), "2023-01"), "ene. 2023");
}
#[test]
fn es_es_month_abbr_day_year_uses_locale_pattern() {
assert_eq!(
month_abbr_day_year(&es_es(), "2023-01-12"),
"ene. 12 de 2023"
);
}
#[test]
fn es_es_month_abbr_day_year_missing_day_falls_back() {
assert_eq!(month_abbr_day_year(&es_es(), "2023-01"), "ene. 2023");
}
#[test]
fn pattern_missing_day_falls_back_to_english_assembly() {
assert_eq!(full(&es_es(), "2023-01"), "enero 2023");
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "Panicking is acceptable in tests."
)]
mod numeric_month_tests {
use super::*;
use citum_schema::locale::Locale;
use citum_schema::options::MonthFormat;
use citum_schema::options::dates::DateConfig;
fn en_us() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
.expect("en-US locale should parse")
}
fn numeric_config() -> DateConfig {
DateConfig {
month: MonthFormat::Numeric,
..Default::default()
}
}
fn render(form: DateForm, edtf: &str) -> Option<String> {
format_single_date(
&DateValue::new(edtf.to_string()),
&form,
&en_us(),
Some(&numeric_config()),
)
}
#[test]
fn given_month_numeric_when_year_month_day_then_iso_hyphenated() {
assert_eq!(
render(DateForm::YearMonthDay, "2024-01-15").as_deref(),
Some("2024-01-15")
);
}
#[test]
fn given_month_numeric_when_day_missing_then_year_month_only() {
assert_eq!(
render(DateForm::YearMonthDay, "2024-01").as_deref(),
Some("2024-01")
);
}
#[test]
fn given_month_numeric_when_year_only_then_plain_year() {
assert_eq!(
render(DateForm::YearMonthDay, "2024").as_deref(),
Some("2024")
);
}
#[test]
fn given_month_numeric_when_year_month_form_then_hyphenated() {
assert_eq!(
render(DateForm::YearMonth, "2024-03").as_deref(),
Some("2024-03")
);
}
#[test]
fn given_month_numeric_when_month_day_form_then_zero_padded() {
assert_eq!(
render(DateForm::MonthDay, "2024-03-05").as_deref(),
Some("03-05")
);
}
#[test]
fn given_month_numeric_when_full_form_then_iso_hyphenated() {
assert_eq!(
render(DateForm::Full, "2024-01-15").as_deref(),
Some("2024-01-15")
);
}
#[test]
fn given_month_numeric_when_season_date_then_textual_fallback() {
assert_eq!(
render(DateForm::YearMonth, "2024-22").as_deref(),
Some("Summer 2024")
);
}
#[test]
fn given_long_month_config_when_year_month_day_then_unchanged() {
let out = format_single_date(
&DateValue::new("2024-01-15".to_string()),
&DateForm::YearMonthDay,
&en_us(),
None,
);
assert_eq!(out.as_deref(), Some("2024, January 15"));
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "Panicking is acceptable in tests."
)]
mod range_tests {
use super::*;
use citum_schema::locale::Locale;
use citum_schema::options::dates::{DateConfig, EraLabels};
fn en_us() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/en-US.yaml"))
.expect("en-US locale should parse")
}
fn es_es() -> Locale {
Locale::from_yaml_str(include_str!("../../../../locales/es-ES.yaml"))
.expect("es-ES locale should parse")
}
fn range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
format_date_range(&DateValue::new(edtf.to_string()), &form, locale, None)
}
fn chicago_range(locale: &Locale, edtf: &str, form: DateForm) -> Option<String> {
let config = DateConfig {
range_format: DateRangeFormat::Chicago,
..Default::default()
};
format_date_range(
&DateValue::new(edtf.to_string()),
&form,
locale,
Some(&config),
)
}
fn range_with_config(
locale: &Locale,
edtf: &str,
form: DateForm,
config: &DateConfig,
) -> Option<String> {
format_date_range(
&DateValue::new(edtf.to_string()),
&form,
locale,
Some(config),
)
}
#[test]
fn closed_range_year_form_regression() {
assert_eq!(
range(&en_us(), "2020/2022", DateForm::Year).as_deref(),
Some("2020–2022")
);
}
#[test]
fn chicago_year_range_condenses_the_end_year() {
assert_eq!(
chicago_range(&en_us(), "2021/2026", DateForm::Year).as_deref(),
Some("2021–26")
);
}
#[test]
fn chicago_range_format_keeps_cross_year_month_ranges_expanded() {
assert_eq!(
chicago_range(&en_us(), "2021-05/2026-06", DateForm::YearMonth).as_deref(),
Some("May 2021–June 2026")
);
}
#[test]
fn shared_year_month_range_uses_spanish_mf2_pattern() {
assert_eq!(
range(&es_es(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
Some("mayo a junio, 2026")
);
}
#[test]
fn shared_year_full_range_uses_spanish_mf2_pattern() {
assert_eq!(
range(&es_es(), "2026-05-14/2026-06-02", DateForm::Full).as_deref(),
Some("14 de mayo a 2 de junio de 2026")
);
}
#[test]
fn chicago_year_range_condenses_same_era_bce_years() {
let config = DateConfig {
range_format: DateRangeFormat::Chicago,
era_labels: EraLabels::BceCe,
..Default::default()
};
assert_eq!(
range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
Some("327–21 BCE")
);
}
#[test]
fn expanded_same_era_bce_years_keep_both_endpoints() {
let config = DateConfig {
era_labels: EraLabels::BceCe,
..Default::default()
};
assert_eq!(
range_with_config(&en_us(), "-0326/-0020", DateForm::Year, &config).as_deref(),
Some("327 BCE–21 BCE")
);
}
#[test]
fn chicago_year_range_preserves_cross_era_endpoints() {
let config = DateConfig {
range_format: DateRangeFormat::Chicago,
era_labels: EraLabels::BcAd,
..Default::default()
};
assert_eq!(
range_with_config(&en_us(), "-0114/0010", DateForm::Year, &config).as_deref(),
Some("115 BC–10 AD")
);
}
#[test]
fn chicago_year_range_keeps_reversed_input_expanded() {
assert_eq!(
chicago_range(&en_us(), "2026/2021", DateForm::Year).as_deref(),
Some("2026–2021")
);
}
#[test]
fn chicago_range_format_does_not_condense_non_four_digit_or_unspecified_years() {
assert_eq!(
chicago_range(&en_us(), "0999/1000", DateForm::Year).as_deref(),
Some("999–1000")
);
assert_eq!(
chicago_range(&en_us(), "202u/203u", DateForm::Year).as_deref(),
Some("202X–203X")
);
}
#[test]
fn closed_range_full_form_different_years() {
assert_eq!(
range(&en_us(), "2023-05-14/2024-06-02", DateForm::Full).as_deref(),
Some("May 14, 2023–June 2, 2024")
);
}
#[test]
fn closed_range_full_form_same_year_collapses() {
assert_eq!(
range(&en_us(), "2023-05-14/2023-06-02", DateForm::Full).as_deref(),
Some("May 14–June 2, 2023")
);
}
#[test]
fn closed_range_year_month_day_same_year_collapses() {
assert_eq!(
range(&en_us(), "2023-05-14/2023-06-02", DateForm::YearMonthDay).as_deref(),
Some("2023, May 14–June 2")
);
}
#[test]
fn closed_range_full_form_es_es_locale_pattern() {
assert_eq!(
range(&es_es(), "2023-01-12/2024-02-03", DateForm::Full).as_deref(),
Some("12 de enero de 2023–3 de febrero de 2024")
);
}
#[test]
fn interval_to_year_form() {
assert_eq!(
range(&en_us(), "../2020", DateForm::Year).as_deref(),
Some("2020")
);
}
#[test]
fn closed_range_year_month_same_year_collapses() {
assert_eq!(
range(&en_us(), "2023-05/2023-06", DateForm::YearMonth).as_deref(),
Some("May–June 2023")
);
}
#[test]
fn chicago_range_format_keeps_same_year_month_ranges_locale_driven() {
assert_eq!(
chicago_range(&en_us(), "2026-05/2026-06", DateForm::YearMonth).as_deref(),
Some("May–June 2026")
);
}
#[test]
fn closed_range_season_same_year_collapses() {
assert_eq!(
range(&en_us(), "2023-21/2023-22", DateForm::YearMonth).as_deref(),
Some("Spring–Summer 2023")
);
}
}