#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ReferenceTime {
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
utc_offset_secs: i32,
}
impl ReferenceTime {
pub fn from_unix_timestamp(secs: i64) -> Self {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
Self {
year,
month,
day,
hour: (rem / 3600) as u32,
minute: ((rem % 3600) / 60) as u32,
second: (rem % 60) as u32,
utc_offset_secs: 0,
}
}
pub fn from_local(
year: i64,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
utc_offset_secs: i32,
) -> Self {
Self {
year,
month,
day,
hour,
minute,
second,
utc_offset_secs,
}
}
pub(crate) fn now() -> Self {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self::from_unix_timestamp(secs)
}
pub(crate) fn date(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
pub(crate) fn time(&self) -> String {
format!(
"{:02}:{:02}:{:02} {}",
self.hour,
self.minute,
self.second,
self.zone_label()
)
}
pub(crate) fn year_string(&self) -> String {
self.year.to_string()
}
fn zone_label(&self) -> String {
if self.utc_offset_secs == 0 {
"UTC".to_string()
} else {
let sign = if self.utc_offset_secs < 0 { '-' } else { '+' };
let abs = self.utc_offset_secs.unsigned_abs();
let hours = abs / 3600;
let minutes = (abs % 3600) / 60;
format!("{sign}{hours:02}{minutes:02}")
}
}
}
pub(crate) const DATETIME_ATTRIBUTE_NAMES: [&str; 8] = [
"localdate",
"localtime",
"localdatetime",
"localyear",
"docdate",
"doctime",
"docdatetime",
"docyear",
];
pub(crate) fn is_datetime_attribute(name: &str) -> bool {
DATETIME_ATTRIBUTE_NAMES.contains(&name)
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct DatetimeInputs {
pub(crate) reference_time: Option<ReferenceTime>,
pub(crate) input_mtime: Option<ReferenceTime>,
}
impl DatetimeInputs {
pub(crate) fn new(
reference_time: Option<ReferenceTime>,
input_mtime: Option<ReferenceTime>,
) -> Option<Box<Self>> {
if reference_time.is_none() && input_mtime.is_none() {
None
} else {
Some(Box::new(Self {
reference_time,
input_mtime,
}))
}
}
pub(crate) fn capture(&self) -> DatetimeContext {
DatetimeContext::capture(self.reference_time.as_ref(), self.input_mtime.as_ref())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct DatetimeContext {
now: ReferenceTime,
doc: ReferenceTime,
}
impl DatetimeContext {
pub(crate) fn capture(
reference_time: Option<&ReferenceTime>,
input_mtime: Option<&ReferenceTime>,
) -> Self {
let now = reference_time
.cloned()
.or_else(source_date_epoch_from_env)
.unwrap_or_else(ReferenceTime::now);
let doc = input_mtime.cloned().unwrap_or_else(|| now.clone());
Self { now, doc }
}
pub(crate) fn resolve<F>(&self, name: &str, explicit: F) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
match name {
"localdate" => Some(self.date("localdate", &self.now, &explicit)),
"localtime" => Some(self.time("localtime", &self.now, &explicit)),
"localyear" => self.year("localyear", "localdate", &self.now, &explicit),
"localdatetime" => Some(self.datetime(
"localdatetime",
"localdate",
"localtime",
&self.now,
&explicit,
)),
"docdate" => Some(self.date("docdate", &self.doc, &explicit)),
"doctime" => Some(self.time("doctime", &self.doc, &explicit)),
"docyear" => self.year("docyear", "docdate", &self.doc, &explicit),
"docdatetime" => {
Some(self.datetime("docdatetime", "docdate", "doctime", &self.doc, &explicit))
}
_ => None,
}
}
fn date<F: Fn(&str) -> Option<String>>(
&self,
attr: &str,
instant: &ReferenceTime,
explicit: &F,
) -> String {
explicit(attr).unwrap_or_else(|| instant.date())
}
fn time<F: Fn(&str) -> Option<String>>(
&self,
attr: &str,
instant: &ReferenceTime,
explicit: &F,
) -> String {
explicit(attr).unwrap_or_else(|| instant.time())
}
fn year<F: Fn(&str) -> Option<String>>(
&self,
year_attr: &str,
date_attr: &str,
instant: &ReferenceTime,
explicit: &F,
) -> Option<String> {
if let Some(year) = explicit(year_attr) {
return Some(year);
}
match explicit(date_attr) {
Some(date) => year_from_date(&date),
None => Some(instant.year_string()),
}
}
fn datetime<F: Fn(&str) -> Option<String>>(
&self,
datetime_attr: &str,
date_attr: &str,
time_attr: &str,
instant: &ReferenceTime,
explicit: &F,
) -> String {
explicit(datetime_attr).unwrap_or_else(|| {
let date = self.date(date_attr, instant, explicit);
let time = self.time(time_attr, instant, explicit);
format!("{date} {time}")
})
}
}
fn source_date_epoch_from_env() -> Option<ReferenceTime> {
parse_source_date_epoch(&std::env::var("SOURCE_DATE_EPOCH").unwrap_or_default())
}
fn parse_source_date_epoch(raw: &str) -> Option<ReferenceTime> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let secs = trimmed.parse::<i64>().ok()?;
Some(ReferenceTime::from_unix_timestamp(secs))
}
fn year_from_date(date: &str) -> Option<String> {
if date.find('-') == Some(4) {
date.get(..4).map(str::to_string)
} else {
None
}
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u32; let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
(if month <= 2 { y + 1 } else { y }, month, day)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
#[test]
fn from_unix_timestamp_epoch() {
let t = ReferenceTime::from_unix_timestamp(0);
assert_eq!(t.date(), "1970-01-01");
assert_eq!(t.time(), "00:00:00 UTC");
assert_eq!(t.year_string(), "1970");
}
#[test]
fn from_unix_timestamp_known_instant() {
let t = ReferenceTime::from_unix_timestamp(1_420_106_400);
assert_eq!(t.date(), "2015-01-01");
assert_eq!(t.time(), "10:00:00 UTC");
assert_eq!(t.year_string(), "2015");
}
#[test]
fn from_unix_timestamp_leap_day() {
let t = ReferenceTime::from_unix_timestamp(1_456_790_399);
assert_eq!(t.date(), "2016-02-29");
assert_eq!(t.time(), "23:59:59 UTC");
}
#[test]
fn from_unix_timestamp_before_epoch() {
let t = ReferenceTime::from_unix_timestamp(-1);
assert_eq!(t.date(), "1969-12-31");
assert_eq!(t.time(), "23:59:59 UTC");
}
#[test]
fn from_local_positive_offset() {
let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600);
assert_eq!(t.date(), "2019-01-02");
assert_eq!(t.time(), "03:04:05 +0600");
assert_eq!(t.year_string(), "2019");
}
#[test]
fn from_local_negative_offset() {
let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, -(7 * 3600));
assert_eq!(t.time(), "03:04:05 -0700");
}
#[test]
fn from_local_half_hour_offset() {
let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 5 * 3600 + 30 * 60);
assert_eq!(t.time(), "03:04:05 +0530");
}
#[test]
fn from_local_zero_offset_prints_utc() {
let t = ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 0);
assert_eq!(t.time(), "03:04:05 UTC");
}
#[test]
fn parse_source_date_epoch_valid() {
let t = parse_source_date_epoch("1420106400").expect("valid epoch");
assert_eq!(t.date(), "2015-01-01");
assert_eq!(t.time(), "10:00:00 UTC");
}
#[test]
fn parse_source_date_epoch_trims_whitespace() {
assert!(parse_source_date_epoch(" 1420106400 ").is_some());
}
#[test]
fn parse_source_date_epoch_rejects_empty_and_malformed() {
assert!(parse_source_date_epoch("").is_none());
assert!(parse_source_date_epoch(" ").is_none());
assert!(parse_source_date_epoch("not-a-number").is_none());
assert!(parse_source_date_epoch("12.5").is_none());
}
#[test]
fn year_from_date_extracts_yyyy_prefix() {
assert_eq!(year_from_date("2015-01-01").as_deref(), Some("2015"));
assert_eq!(year_from_date("2015-1-1").as_deref(), Some("2015"));
}
#[test]
fn year_from_date_rejects_other_shapes() {
assert_eq!(year_from_date("2015"), None); assert_eq!(year_from_date("20-15-01"), None); assert_eq!(year_from_date("January 1, 2015"), None);
assert_eq!(year_from_date(""), None);
}
#[test]
fn is_datetime_attribute_recognizes_the_family() {
for name in DATETIME_ATTRIBUTE_NAMES {
assert!(is_datetime_attribute(name), "{name} should be recognized");
}
assert!(!is_datetime_attribute("docfile"));
assert!(!is_datetime_attribute("frog"));
}
fn ctx() -> DatetimeContext {
DatetimeContext {
now: ReferenceTime::from_unix_timestamp(1_420_106_400),
doc: ReferenceTime::from_local(2019, 1, 2, 3, 4, 5, 6 * 3600),
}
}
#[test]
fn resolve_computes_the_family_from_instants() {
let ctx = ctx();
let none = |_: &str| None;
assert_eq!(
ctx.resolve("localdate", none).as_deref(),
Some("2015-01-01")
);
assert_eq!(
ctx.resolve("localtime", none).as_deref(),
Some("10:00:00 UTC")
);
assert_eq!(ctx.resolve("localyear", none).as_deref(), Some("2015"));
assert_eq!(
ctx.resolve("localdatetime", none).as_deref(),
Some("2015-01-01 10:00:00 UTC")
);
assert_eq!(ctx.resolve("docdate", none).as_deref(), Some("2019-01-02"));
assert_eq!(
ctx.resolve("doctime", none).as_deref(),
Some("03:04:05 +0600")
);
assert_eq!(ctx.resolve("docyear", none).as_deref(), Some("2019"));
assert_eq!(
ctx.resolve("docdatetime", none).as_deref(),
Some("2019-01-02 03:04:05 +0600")
);
assert_eq!(ctx.resolve("frog", none), None);
}
#[test]
fn resolve_honors_explicit_overrides() {
let ctx = ctx();
let explicit = |name: &str| match name {
"docdate" => Some("2015-01-01".to_string()),
"doctime" => Some("10:00:00-0700".to_string()),
_ => None,
};
assert_eq!(ctx.resolve("docyear", explicit).as_deref(), Some("2015"));
assert_eq!(
ctx.resolve("docdatetime", explicit).as_deref(),
Some("2015-01-01 10:00:00-0700")
);
let explicit = |name: &str| match name {
"docdate" => Some("2015-01-01".to_string()),
_ => None,
};
assert_eq!(
ctx.resolve("docdatetime", explicit).as_deref(),
Some("2015-01-01 03:04:05 +0600")
);
let explicit = |name: &str| match name {
"docdate" => Some("January 1".to_string()),
_ => None,
};
assert_eq!(ctx.resolve("docyear", explicit), None);
let explicit = |name: &str| match name {
"docyear" => Some("1999".to_string()),
_ => None,
};
assert_eq!(ctx.resolve("docyear", explicit).as_deref(), Some("1999"));
}
}