use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
use super::{Builtin, BuiltinContext, CommandFailure, CommandResult};
use crate::ExitCode;
pub(crate) struct Date;
impl Builtin for Date {
fn name(&self) -> &'static str {
"date"
}
fn run(
&self,
context: &mut BuiltinContext<'_>,
arguments: &[String],
_input: Option<Value>,
) -> Result<CommandResult, CommandFailure> {
let format = parse(arguments)?;
if !context.allow_clock {
return Err(CommandFailure::Status {
message:
"date: command not found: this session cannot read the clock; an operator enables it with --shell-allow-clock"
.to_owned(),
status: ExitCode::NOT_FOUND,
});
}
let elapsed = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| {
CommandFailure::failed("date: the host clock is set before 1970 and cannot be rendered")
})?;
let seconds = i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX);
Ok(CommandResult::value(match format {
Format::EpochSeconds => Value::from(seconds),
Format::Iso8601 => Value::String(render_iso8601(seconds)),
}))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Format {
EpochSeconds,
Iso8601,
}
fn parse(arguments: &[String]) -> Result<Format, CommandFailure> {
let mut format = None;
for argument in arguments {
let selected = match argument.as_str() {
"-u" | "--utc" | "--universal" => continue,
"-I" | "-Is" | "--iso-8601" | "--iso-8601=seconds" => Format::Iso8601,
"+%s" => Format::EpochSeconds,
other if other.starts_with('+') => {
return Err(CommandFailure::usage(format!(
"date: format {other:?} is not supported; this shell renders `+%s` for the epoch second or `-I` for an ISO-8601 instant, and has no `strftime`"
)));
}
other if other.starts_with('-') && other.len() > 1 => {
return Err(CommandFailure::usage(format!(
"date: option not yet supported: {other}; this shell reads the current UTC time only, and cannot set the clock or convert another one"
)));
}
other => {
return Err(CommandFailure::usage(format!(
"date: unexpected argument {other:?}; pass `+%s`, `-I`, or no argument at all"
)));
}
};
if format.is_some_and(|existing| existing != selected) {
return Err(CommandFailure::usage(
"date: pass at most one output format",
));
}
format = Some(selected);
}
Ok(format.unwrap_or(Format::Iso8601))
}
fn render_iso8601(seconds: i64) -> String {
let days = seconds.div_euclid(86_400);
let time = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let (hour, minute, second) = (time / 3_600, (time % 3_600) / 60, time % 60);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days: i64) -> (i64, i64, i64) {
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let shifted_month = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
let month = if shifted_month < 10 {
shifted_month + 3
} else {
shifted_month - 9
};
let year = year_of_era + era * 400 + i64::from(month <= 2);
(year, month, day)
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use crate::{
ExitCode,
builtins::{
CommandFailure,
test_support::{run_builtin, run_builtin_with_clock},
},
};
use super::{Date, render_iso8601};
#[test]
fn the_clock_is_unreachable_until_an_operator_enables_it() {
let failure = run_builtin(&Date, &[], None).expect_err("the clock is off by default");
let CommandFailure::Status { message, status } = failure else {
panic!("an unavailable clock must stay recoverable");
};
assert_eq!(status, ExitCode::NOT_FOUND);
assert!(message.contains("command not found"), "{message}");
assert!(message.contains("--shell-allow-clock"), "{message}");
}
#[test]
fn an_enabled_clock_renders_the_two_supported_formats() {
let iso = run_builtin_with_clock(&Date, &[]).expect("date runs");
let Value::String(rendered) = &iso.value else {
panic!("the default format is a string, found {:?}", iso.value);
};
assert_eq!(rendered.len(), 20, "{rendered}");
assert!(rendered.ends_with('Z'), "{rendered}");
let epoch = run_builtin_with_clock(&Date, &["+%s"]).expect("date runs");
let seconds = epoch.value.as_i64().expect("an epoch second is a number");
assert!(
(1_577_836_800..4_102_444_800).contains(&seconds),
"{seconds}"
);
}
#[test]
fn utc_is_accepted_and_other_flags_are_rejected_by_name() {
assert!(run_builtin_with_clock(&Date, &["-u"]).is_ok());
assert!(run_builtin_with_clock(&Date, &["-u", "-I"]).is_ok());
let failure = run_builtin_with_clock(&Date, &["+%Y-%m-%d"]).expect_err("strftime is out");
let CommandFailure::Status { message, .. } = failure else {
panic!("an unsupported format must stay recoverable");
};
assert!(message.contains("strftime"), "{message}");
assert!(run_builtin_with_clock(&Date, &["-d", "yesterday"]).is_err());
assert!(run_builtin_with_clock(&Date, &["-s", "2020-01-01"]).is_err());
assert!(run_builtin_with_clock(&Date, &["tomorrow"]).is_err());
assert!(run_builtin_with_clock(&Date, &["+%s", "-I"]).is_err());
}
#[test]
fn a_malformed_date_is_a_usage_error_even_with_the_clock_off() {
let failure =
run_builtin(&Date, &["+%Y"], None).expect_err("a bad format is a usage error");
let CommandFailure::Status { status, .. } = failure else {
panic!("a usage error must stay recoverable");
};
assert_eq!(status, ExitCode::SYNTAX);
}
#[test]
fn the_calendar_matches_known_instants() {
assert_eq!(render_iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(render_iso8601(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(render_iso8601(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(render_iso8601(1_754_395_496), "2025-08-05T12:04:56Z");
assert_eq!(render_iso8601(4_102_444_799), "2099-12-31T23:59:59Z");
}
}