use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{
DateTime, Datelike, Days, Duration, FixedOffset, Local, Months, NaiveDate, NaiveDateTime,
NaiveTime, SecondsFormat, TimeZone, Timelike, Utc, Weekday,
};
use chrono_tz::Tz;
use clap::{CommandFactory, Parser};
use crate::interpreter::{value_to_string, ExecResult, OutputData};
use crate::tools::{
schema_from_clap, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema,
};
pub trait Clock: Send + Sync {
fn now_utc(&self) -> DateTime<Utc>;
}
struct SystemClock;
impl Clock for SystemClock {
fn now_utc(&self) -> DateTime<Utc> {
Utc::now()
}
}
pub struct Date {
clock: Arc<dyn Clock>,
}
impl Default for Date {
fn default() -> Self {
Self {
clock: Arc::new(SystemClock),
}
}
}
impl Date {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
fn with_clock(clock: Arc<dyn Clock>) -> Self {
Self { clock }
}
}
#[derive(Parser, Debug)]
#[command(name = "date", about = "Display the date and time")]
struct DateArgs {
#[arg(short = 'u', long = "utc")]
utc: bool,
#[arg(short = 'd', long = "date", value_name = "STRING")]
date: Option<String>,
#[arg(short = 'r', long = "reference", value_name = "FILE")]
reference: Option<String>,
#[arg(long = "tz", value_name = "ZONE")]
tz: Option<String>,
#[arg(
short = 'I',
long = "iso-8601",
value_name = "FMT",
num_args = 0..=1,
default_missing_value = "date",
require_equals = true,
)]
iso_8601: Option<String>,
#[arg(short = 'R', long = "rfc-2822")]
rfc_2822: bool,
#[arg(long = "rfc-3339", value_name = "FMT")]
rfc_3339: Option<String>,
#[arg(long = "iso", hide = true)]
iso: bool,
#[arg(long = "unix", hide = true)]
unix: bool,
#[arg(long = "format", hide = true, value_name = "FMT")]
format: Option<String>,
#[command(flatten)]
global: GlobalFlags,
args: Vec<String>,
}
enum Zone {
Utc,
Local,
Named(Tz),
}
impl Zone {
fn instant(&self, utc: DateTime<Utc>) -> DateTime<FixedOffset> {
match self {
Zone::Utc => utc.fixed_offset(),
Zone::Local => utc.with_timezone(&Local).fixed_offset(),
Zone::Named(tz) => utc.with_timezone(tz).fixed_offset(),
}
}
}
#[async_trait]
impl Tool for Date {
fn name(&self) -> &str {
"date"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&DateArgs::command(),
"date",
"Display the date and time",
[
("Current date and time", "date"),
("UTC", "date -u"),
("Epoch seconds", "date +%s"),
("ISO 8601 date", "date -I"),
("Relative", "date -d \"2 days ago\""),
("Decode an epoch", "date -d \"@1700000000\""),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
self.execute_argv(args.to_argv(), ctx).await
}
}
impl Date {
async fn execute_argv(&self, argv: Vec<String>, ctx: &mut ExecContext) -> ExecResult {
let parsed = match DateArgs::try_parse_from(
std::iter::once("date".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("date: {e}")),
};
parsed.global.apply(ctx);
match self.run(&parsed, ctx).await {
Ok(result) => result,
Err(msg) => ExecResult::failure(2, msg),
}
}
async fn run(&self, parsed: &DateArgs, ctx: &mut ExecContext) -> Result<ExecResult, String> {
if parsed.date.is_some() && parsed.reference.is_some() {
return Err("date: cannot combine --date and --reference".to_string());
}
let zone = resolve_zone(parsed.utc, parsed.tz.as_deref(), scope_tz(ctx))?;
let mut fmt_positional: Option<&str> = None;
let mut at_positional: Option<&str> = None;
for operand in &parsed.args {
if let Some(stripped) = operand.strip_prefix('+') {
if fmt_positional.is_none() {
fmt_positional = Some(stripped);
}
} else if operand.starts_with('@') {
at_positional = Some(operand);
} else {
return Err(format!(
"date: unrecognized operand '{operand}' (did you mean -d \"{operand}\"?)"
));
}
}
let now_utc = self.clock.now_utc();
let dt: DateTime<FixedOffset> = if let Some(file) = &parsed.reference {
file_mtime(ctx, &zone, file).await?
} else if let Some(spec) = &parsed.date {
resolve_instant(spec, &zone, now_utc)?
} else if let Some(at) = at_positional {
resolve_instant(at, &zone, now_utc)?
} else {
zone.instant(now_utc)
};
let body = format!("{}\n", render(&dt, parsed, fmt_positional)?);
let output = OutputData::text(body.clone()).with_rich_json(build_json(&dt));
Ok(ExecResult::with_output_and_text(output, body))
}
}
fn scope_tz(ctx: &ExecContext) -> Option<String> {
let raw = value_to_string(ctx.scope.get("TZ")?);
if raw.is_empty() {
None
} else {
Some(raw)
}
}
fn resolve_zone(utc: bool, tz_flag: Option<&str>, tz_var: Option<String>) -> Result<Zone, String> {
if utc {
return Ok(Zone::Utc);
}
let name = tz_flag.map(str::to_string).or(tz_var);
match name {
Some(zone) => zone
.parse::<Tz>()
.map(Zone::Named)
.map_err(|_| format!("date: unknown timezone '{zone}' (use an IANA name like Asia/Tokyo)")),
None => Ok(Zone::Local),
}
}
async fn file_mtime(
ctx: &mut ExecContext,
zone: &Zone,
file: &str,
) -> Result<DateTime<FixedOffset>, String> {
let resolved = ctx.resolve_path(file);
let info = ctx
.backend
.stat(Path::new(&resolved))
.await
.map_err(|e| format!("date: {file}: {e}"))?;
let mtime = info
.modified
.ok_or_else(|| format!("date: {file}: no modification time available"))?;
Ok(zone.instant(DateTime::<Utc>::from(mtime)))
}
fn resolve_instant(
spec: &str,
zone: &Zone,
now_utc: DateTime<Utc>,
) -> Result<DateTime<FixedOffset>, String> {
match zone {
Zone::Utc => parse_in_tz(spec, &Utc, now_utc),
Zone::Local => parse_in_tz(spec, &Local, now_utc),
Zone::Named(tz) => parse_in_tz(spec, tz, now_utc),
}
}
fn parse_in_tz<Tz: TimeZone>(
spec: &str,
tz: &Tz,
now_utc: DateTime<Utc>,
) -> Result<DateTime<FixedOffset>, String> {
let base = now_utc.with_timezone(tz);
parse_date_string(spec, base, tz).map(|dt| dt.fixed_offset())
}
fn parse_date_string<Tz: TimeZone>(
spec: &str,
base: DateTime<Tz>,
tz: &Tz,
) -> Result<DateTime<Tz>, String> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return Err("date: empty --date string".to_string());
}
if let Some(rest) = trimmed.strip_prefix('@') {
let secs: i64 = rest
.trim()
.parse()
.map_err(|_| format!("date: invalid epoch timestamp '{trimmed}'"))?;
let utc = DateTime::from_timestamp(secs, 0)
.ok_or_else(|| format!("date: epoch timestamp out of range '{trimmed}'"))?;
return Ok(utc.with_timezone(tz));
}
let lower = trimmed.to_ascii_lowercase();
match lower.as_str() {
"now" | "today" => return Ok(base),
"yesterday" => return base.checked_sub_days(Days::new(1)).ok_or_else(overflow),
"tomorrow" => return base.checked_add_days(Days::new(1)).ok_or_else(overflow),
_ => {}
}
let lower_tokens: Vec<&str> = lower.split_whitespace().collect();
if lower_tokens.len() == 2 {
if let Some(weekday) = parse_weekday(lower_tokens[1]) {
if lower_tokens[0] == "next" {
return shift_to_weekday(base, weekday, true);
} else if lower_tokens[0] == "last" {
return shift_to_weekday(base, weekday, false);
}
}
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
let (anchor, consumed) = match parse_absolute_prefix(&tokens, tz)? {
Some((dt, n)) => (dt, n),
None => (base, 0),
};
let rest = &tokens[consumed..];
if rest.is_empty() {
if consumed > 0 {
return Ok(anchor); }
return Err(format!("date: cannot parse date string '{trimmed}'"));
}
let rest_lower: Vec<String> = rest.iter().map(|t| t.to_ascii_lowercase()).collect();
apply_offset_terms(&rest_lower, anchor)
.map_err(|_| format!("date: cannot parse date string '{trimmed}'"))
}
fn overflow() -> String {
"date: resulting time is out of range".to_string()
}
fn localize<Tz: TimeZone>(tz: &Tz, naive: NaiveDateTime) -> Option<DateTime<Tz>> {
tz.from_local_datetime(&naive).single()
}
fn parse_absolute_prefix<Tz: TimeZone>(
tokens: &[&str],
tz: &Tz,
) -> Result<Option<(DateTime<Tz>, usize)>, String> {
if tokens.is_empty() {
return Ok(None);
}
if tokens.len() >= 2 {
let joined = format!("{} {}", tokens[0], tokens[1]);
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] {
if let Ok(naive) = NaiveDateTime::parse_from_str(&joined, fmt) {
let dt = localize(tz, naive)
.ok_or_else(|| format!("date: ambiguous or invalid local time '{joined}'"))?;
return Ok(Some((dt, 2)));
}
}
}
let head = tokens[0];
if let Ok(dt) = DateTime::parse_from_rfc3339(head) {
return Ok(Some((dt.with_timezone(tz), 1)));
}
for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
if let Ok(naive) = NaiveDateTime::parse_from_str(head, fmt) {
let dt = localize(tz, naive)
.ok_or_else(|| format!("date: ambiguous or invalid local time '{head}'"))?;
return Ok(Some((dt, 1)));
}
}
if let Ok(date) = NaiveDate::parse_from_str(head, "%Y-%m-%d") {
let naive = date.and_time(NaiveTime::MIN);
let dt = localize(tz, naive)
.ok_or_else(|| format!("date: ambiguous or invalid local time '{head}'"))?;
return Ok(Some((dt, 1)));
}
Ok(None)
}
fn apply_offset_terms<Tz: TimeZone>(
tokens: &[String],
base: DateTime<Tz>,
) -> Result<DateTime<Tz>, String> {
let mut slice = tokens;
if slice.first().map(String::as_str) == Some("in") {
slice = &slice[1..];
}
let mut ago = false;
if slice.last().map(String::as_str) == Some("ago") {
ago = true;
slice = &slice[..slice.len() - 1];
}
if slice.is_empty() || slice.len() % 2 != 0 {
return Err("malformed".to_string());
}
if slice[0].parse::<i64>().is_err() {
return Err("not an offset".to_string());
}
let mut dt = base;
let mut i = 0;
while i < slice.len() {
let amount: i64 = slice[i].parse().map_err(|_| "bad number".to_string())?;
let signed = if ago { -amount } else { amount };
dt = apply_unit(dt, signed, &slice[i + 1]).map_err(|_| "bad unit".to_string())?;
i += 2;
}
Ok(dt)
}
fn apply_unit<Tz: TimeZone>(dt: DateTime<Tz>, n: i64, unit: &str) -> Result<DateTime<Tz>, ()> {
let unit = unit.strip_suffix('s').unwrap_or(unit);
let duration = match unit {
"second" | "sec" => Some(Duration::seconds(n)),
"minute" | "min" => Some(Duration::minutes(n)),
"hour" | "hr" => Some(Duration::hours(n)),
_ => None,
};
if let Some(d) = duration {
return dt.checked_add_signed(d).ok_or(());
}
match unit {
"day" => shift_days(dt, n),
"week" | "wk" => shift_days(dt, n.checked_mul(7).ok_or(())?),
"month" | "mon" => shift_months(dt, n),
"year" | "yr" => shift_months(dt, n.checked_mul(12).ok_or(())?),
_ => Err(()),
}
}
fn shift_days<Tz: TimeZone>(dt: DateTime<Tz>, n: i64) -> Result<DateTime<Tz>, ()> {
let magnitude = n.unsigned_abs();
if n >= 0 {
dt.checked_add_days(Days::new(magnitude)).ok_or(())
} else {
dt.checked_sub_days(Days::new(magnitude)).ok_or(())
}
}
fn shift_months<Tz: TimeZone>(dt: DateTime<Tz>, n: i64) -> Result<DateTime<Tz>, ()> {
let magnitude = u32::try_from(n.unsigned_abs()).map_err(|_| ())?;
if n >= 0 {
dt.checked_add_months(Months::new(magnitude)).ok_or(())
} else {
dt.checked_sub_months(Months::new(magnitude)).ok_or(())
}
}
fn parse_weekday(token: &str) -> Option<Weekday> {
Some(match token {
"mon" | "monday" => Weekday::Mon,
"tue" | "tues" | "tuesday" => Weekday::Tue,
"wed" | "weds" | "wednesday" => Weekday::Wed,
"thu" | "thur" | "thurs" | "thursday" => Weekday::Thu,
"fri" | "friday" => Weekday::Fri,
"sat" | "saturday" => Weekday::Sat,
"sun" | "sunday" => Weekday::Sun,
_ => return None,
})
}
fn shift_to_weekday<Tz: TimeZone>(
base: DateTime<Tz>,
target: Weekday,
next: bool,
) -> Result<DateTime<Tz>, String> {
let current = base.weekday().num_days_from_monday() as i64;
let goal = target.num_days_from_monday() as i64;
let days = if next {
let d = (goal - current).rem_euclid(7);
if d == 0 {
7
} else {
d
}
} else {
let d = (current - goal).rem_euclid(7);
-(if d == 0 { 7 } else { d })
};
let shifted = shift_days(base, days).map_err(|()| overflow())?;
shifted
.with_hour(0)
.and_then(|d| d.with_minute(0))
.and_then(|d| d.with_second(0))
.and_then(|d| d.with_nanosecond(0))
.ok_or_else(overflow)
}
fn render(
dt: &DateTime<FixedOffset>,
parsed: &DateArgs,
fmt_positional: Option<&str>,
) -> Result<String, String> {
if let Some(fmt) = fmt_positional.or(parsed.format.as_deref()) {
return format_strftime(dt, fmt);
}
if parsed.rfc_2822 {
return Ok(dt.to_rfc2822());
}
if let Some(fmt) = &parsed.rfc_3339 {
return rfc_3339_variant(dt, fmt);
}
if let Some(fmt) = &parsed.iso_8601 {
return iso_8601_variant(dt, fmt);
}
if parsed.iso {
return iso_8601_variant(dt, "seconds");
}
if parsed.unix {
return Ok(dt.timestamp().to_string());
}
Ok(dt.format("%Y-%m-%d %H:%M:%S").to_string())
}
fn format_strftime(dt: &DateTime<FixedOffset>, fmt: &str) -> Result<String, String> {
let translated = translate_format(fmt);
validate_format(&translated)?;
Ok(dt.format(&translated).to_string())
}
fn translate_format(fmt: &str) -> String {
let mut out = String::with_capacity(fmt.len());
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
if chars.peek() == Some(&'%') {
out.push('%');
out.push('%');
chars.next();
continue;
}
let mut digits = String::new();
while let Some(d) = chars.peek() {
if d.is_ascii_digit() {
digits.push(*d);
chars.next();
} else {
break;
}
}
if chars.peek() == Some(&'N') {
chars.next();
let width = if digits.is_empty() { "9" } else { &digits };
out.push('%');
out.push_str(width);
out.push('f');
} else {
out.push('%');
out.push_str(&digits);
}
}
out
}
fn validate_format(fmt: &str) -> Result<(), String> {
use chrono::format::{Item, StrftimeItems};
for item in StrftimeItems::new(fmt) {
if matches!(item, Item::Error) {
return Err(format!("date: unknown format specifier in '{fmt}'"));
}
}
Ok(())
}
fn iso_8601_variant(dt: &DateTime<FixedOffset>, fmt: &str) -> Result<String, String> {
let pattern = match fmt {
"date" => "%Y-%m-%d",
"hours" => "%Y-%m-%dT%H%:z",
"minutes" => "%Y-%m-%dT%H:%M%:z",
"seconds" => "%Y-%m-%dT%H:%M:%S%:z",
"ns" => "%Y-%m-%dT%H:%M:%S,%9f%:z",
other => {
return Err(format!(
"date: invalid argument '{other}' for --iso-8601 (date|hours|minutes|seconds|ns)"
))
}
};
Ok(dt.format(pattern).to_string())
}
fn rfc_3339_variant(dt: &DateTime<FixedOffset>, fmt: &str) -> Result<String, String> {
let pattern = match fmt {
"date" => "%Y-%m-%d",
"seconds" => "%Y-%m-%d %H:%M:%S%:z",
"ns" => "%Y-%m-%d %H:%M:%S.%9f%:z",
other => {
return Err(format!(
"date: invalid argument '{other}' for --rfc-3339 (date|seconds|ns)"
))
}
};
Ok(dt.format(pattern).to_string())
}
fn build_json(dt: &DateTime<FixedOffset>) -> serde_json::Value {
serde_json::json!({
"iso": dt.to_rfc3339_opts(SecondsFormat::Secs, false),
"epoch": dt.timestamp(),
"utc": dt.with_timezone(&Utc).to_rfc3339_opts(SecondsFormat::Secs, true),
"local": dt.format("%Y-%m-%d %H:%M:%S").to_string(),
"weekday": dt.format("%A").to_string(),
"tz": dt.format("%:z").to_string(),
"offset_seconds": dt.offset().local_minus_utc(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::interpreter::{apply_output_format, OutputFormat};
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
struct FixedClock(DateTime<Utc>);
impl Clock for FixedClock {
fn now_utc(&self) -> DateTime<Utc> {
self.0
}
}
const FIXED_EPOCH: i64 = 1_700_000_000;
fn fixed_date() -> Date {
let instant = DateTime::from_timestamp(FIXED_EPOCH, 0).unwrap();
Date::with_clock(Arc::new(FixedClock(instant)))
}
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
async fn run(date: &Date, parts: &[&str]) -> ExecResult {
let mut ctx = make_ctx();
date.execute_argv(argv(parts), &mut ctx).await
}
#[tokio::test]
async fn unknown_specifier_errors_not_panics() {
let result = run(&fixed_date(), &["+%Q"]).await;
assert_eq!(result.code, 2, "unknown specifier should exit 2");
}
#[tokio::test]
async fn nanos_specifier_is_translated() {
let result = run(&fixed_date(), &["-u", "+%s%N"]).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "1700000000000000000");
}
#[tokio::test]
async fn at_timestamp_decodes_not_echoes() {
let result = run(&fixed_date(), &["-u", "@1700000000"]).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "2023-11-14 22:13:20");
}
#[tokio::test]
async fn at_timestamp_via_dash_d() {
let result = run(&fixed_date(), &["-u", "-d", "@1700000000", "+%Y-%m-%dT%H:%M:%SZ"]).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "2023-11-14T22:13:20Z");
}
#[tokio::test]
async fn malformed_at_is_loud_error() {
let result = run(&fixed_date(), &["-d", "@notanumber"]).await;
assert_eq!(result.code, 2);
}
#[tokio::test]
async fn tz_flag_is_honored() {
let result = run(&fixed_date(), &["--tz", "Asia/Tokyo", "-d", "@1700000000", "+%H:%M"]).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "07:13");
}
#[tokio::test]
async fn tz_var_is_honored_and_differs_from_utc() {
let date = fixed_date();
let mut ctx = make_ctx();
ctx.scope.set_exported("TZ", Value::String("Asia/Tokyo".into()));
let result = date.execute_argv(argv(&["-d", "@1700000000", "+%H"]), &mut ctx).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "07", "TZ var must be honored");
let utc = run(&date, &["-u", "-d", "@1700000000", "+%H"]).await;
assert_eq!(utc.text_out().trim(), "22");
}
#[tokio::test]
async fn unknown_timezone_is_loud_error() {
let result = run(&fixed_date(), &["--tz", "Mars/Olympus", "-d", "now"]).await;
assert_eq!(result.code, 2);
}
#[tokio::test]
async fn relative_yesterday_tomorrow() {
let y = run(&fixed_date(), &["-u", "-d", "yesterday", "+%Y-%m-%d"]).await;
assert_eq!(y.text_out().trim(), "2023-11-13");
let t = run(&fixed_date(), &["-u", "-d", "tomorrow", "+%Y-%m-%d"]).await;
assert_eq!(t.text_out().trim(), "2023-11-15");
}
#[tokio::test]
async fn relative_n_units_ago_and_future() {
let ago = run(&fixed_date(), &["-u", "-d", "2 weeks ago", "+%Y-%m-%d"]).await;
assert_eq!(ago.text_out().trim(), "2023-10-31");
let future = run(&fixed_date(), &["-u", "-d", "+3 days", "+%Y-%m-%d"]).await;
assert_eq!(future.text_out().trim(), "2023-11-17");
let bare_future = run(&fixed_date(), &["-u", "-d", "1 month", "+%Y-%m-%d"]).await;
assert_eq!(bare_future.text_out().trim(), "2023-12-14");
}
#[tokio::test]
async fn relative_next_last_weekday() {
let next_fri = run(&fixed_date(), &["-u", "-d", "next friday", "+%Y-%m-%d"]).await;
assert_eq!(next_fri.text_out().trim(), "2023-11-17");
let last_fri = run(&fixed_date(), &["-u", "-d", "last friday", "+%Y-%m-%d"]).await;
assert_eq!(last_fri.text_out().trim(), "2023-11-10");
let next_tue = run(&fixed_date(), &["-u", "-d", "next tuesday", "+%Y-%m-%d"]).await;
assert_eq!(next_tue.text_out().trim(), "2023-11-21");
}
#[tokio::test]
async fn absolute_date_and_epoch() {
let abs = run(&fixed_date(), &["-u", "-d", "2026-01-01", "+%s"]).await;
assert!(abs.ok());
let expected = NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.timestamp();
assert_eq!(abs.text_out().trim(), expected.to_string());
}
#[tokio::test]
async fn absolute_plus_offset() {
let r = run(&fixed_date(), &["-u", "-d", "2026-06-01 -1 day", "+%Y-%m-%d"]).await;
assert_eq!(r.text_out().trim(), "2026-05-31");
}
#[tokio::test]
async fn garbage_date_string_fails_loud() {
let r = run(&fixed_date(), &["-d", "the day before the war"]).await;
assert_eq!(r.code, 2);
}
#[tokio::test]
async fn iso_8601_default_is_date() {
let r = run(&fixed_date(), &["-u", "-I"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "2023-11-14");
}
#[tokio::test]
async fn iso_8601_seconds() {
let r = run(&fixed_date(), &["-u", "--iso-8601=seconds"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "2023-11-14T22:13:20+00:00");
}
#[tokio::test]
async fn rfc_2822() {
let r = run(&fixed_date(), &["-u", "-R"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "Tue, 14 Nov 2023 22:13:20 +0000");
}
#[tokio::test]
async fn default_and_utc() {
let r = run(&fixed_date(), &["-u"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "2023-11-14 22:13:20");
}
#[tokio::test]
async fn epoch_via_format() {
let r = run(&fixed_date(), &["-u", "+%s"]).await;
assert_eq!(r.text_out().trim(), "1700000000");
}
#[tokio::test]
async fn stray_operand_is_error() {
let r = run(&fixed_date(), &["2023-11-14"]).await;
assert_eq!(r.code, 2, "bare date operand should hint -d");
}
#[tokio::test]
async fn reference_reads_file_mtime() {
let date = fixed_date();
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("file.txt"), b"hi").await.unwrap();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let result = date.execute_argv(argv(&["-u", "-r", "/file.txt", "+%s"]), &mut ctx).await;
assert!(result.ok());
let file_epoch: i64 = result.text_out().trim().parse().unwrap();
assert!(
file_epoch > FIXED_EPOCH,
"-r should read the file mtime ({file_epoch}), not the clock ({FIXED_EPOCH})"
);
}
#[tokio::test]
async fn reference_missing_file_errors() {
let r = run(&fixed_date(), &["-r", "/nonexistent"]).await;
assert_eq!(r.code, 2);
}
async fn json_for(parts: &[&str]) -> serde_json::Value {
let mut ctx = make_ctx();
let result = fixed_date().execute_argv(argv(parts), &mut ctx).await;
let json = apply_output_format(result, OutputFormat::Json);
serde_json::from_str(json.text_out().trim()).expect("date --json is JSON")
}
#[tokio::test]
async fn json_shape_is_consistent_utc() {
let p = json_for(&["-u"]).await;
assert_eq!(p["epoch"], FIXED_EPOCH);
assert_eq!(p["iso"], "2023-11-14T22:13:20+00:00");
assert_eq!(p["utc"], "2023-11-14T22:13:20Z");
assert_eq!(p["local"], "2023-11-14 22:13:20");
assert_eq!(p["weekday"], "Tuesday");
assert_eq!(p["tz"], "+00:00");
assert_eq!(p["offset_seconds"], 0);
}
#[tokio::test]
async fn json_carries_zone_offset() {
let p = json_for(&["--tz", "Asia/Tokyo", "-d", "@1700000000"]).await;
assert_eq!(p["epoch"], 1_700_000_000_i64);
assert_eq!(p["iso"], "2023-11-15T07:13:20+09:00");
assert_eq!(p["utc"], "2023-11-14T22:13:20Z");
assert_eq!(p["local"], "2023-11-15 07:13:20");
assert_eq!(p["weekday"], "Wednesday");
assert_eq!(p["tz"], "+09:00");
assert_eq!(p["offset_seconds"], 32_400);
}
#[tokio::test]
async fn calendar_math_is_dst_correct() {
let instant = Utc.with_ymd_and_hms(2026, 3, 6, 17, 0, 0).unwrap();
let date = Date::with_clock(Arc::new(FixedClock(instant)));
let mut ctx = make_ctx();
let r = date
.execute_argv(
argv(&["--tz", "America/New_York", "-d", "+3 days", "+%Y-%m-%dT%H:%M%:z"]),
&mut ctx,
)
.await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "2026-03-09T12:00-04:00");
}
#[tokio::test]
async fn nanos_width_variants_translate() {
assert_eq!(run(&fixed_date(), &["-u", "+%3N"]).await.text_out().trim(), "000");
assert_eq!(run(&fixed_date(), &["-u", "+%6N"]).await.text_out().trim(), "000000");
assert_eq!(run(&fixed_date(), &["-u", "+%9N"]).await.text_out().trim(), "000000000");
assert_eq!(run(&fixed_date(), &["-u", "+%N"]).await.text_out().trim(), "000000000");
}
#[tokio::test]
async fn escaped_percent_n_is_literal() {
let r = run(&fixed_date(), &["-u", "+%%N"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "%N");
}
#[tokio::test]
async fn nanos_specifier_mid_string() {
let r = run(&fixed_date(), &["-u", "+%H:%N"]).await;
assert!(r.ok());
assert_eq!(r.text_out().trim(), "22:000000000");
}
#[tokio::test]
async fn trailing_bare_percent_errors() {
let r = run(&fixed_date(), &["-u", "+%"]).await;
assert_eq!(r.code, 2, "a dangling % must not panic; exit 2");
}
#[tokio::test]
async fn iso_8601_all_variants() {
let v = |fmt: &'static str| async move {
run(&fixed_date(), &["-u", fmt]).await.text_out().trim().to_string()
};
assert_eq!(v("--iso-8601=hours").await, "2023-11-14T22+00:00");
assert_eq!(v("--iso-8601=minutes").await, "2023-11-14T22:13+00:00");
assert_eq!(v("--iso-8601=ns").await, "2023-11-14T22:13:20,000000000+00:00");
}
#[tokio::test]
async fn iso_8601_bad_fmt_errors() {
let r = run(&fixed_date(), &["-u", "--iso-8601=bananas"]).await;
assert_eq!(r.code, 2);
}
#[tokio::test]
async fn rfc_3339_variants_and_bad_fmt() {
assert_eq!(
run(&fixed_date(), &["-u", "--rfc-3339=date"]).await.text_out().trim(),
"2023-11-14"
);
assert_eq!(
run(&fixed_date(), &["-u", "--rfc-3339=seconds"]).await.text_out().trim(),
"2023-11-14 22:13:20+00:00"
);
assert_eq!(
run(&fixed_date(), &["-u", "--rfc-3339=ns"]).await.text_out().trim(),
"2023-11-14 22:13:20.000000000+00:00"
);
assert_eq!(run(&fixed_date(), &["-u", "--rfc-3339=nope"]).await.code, 2);
}
#[tokio::test]
async fn legacy_hidden_aliases_still_work() {
assert_eq!(run(&fixed_date(), &["-u", "--unix"]).await.text_out().trim(), "1700000000");
assert_eq!(
run(&fixed_date(), &["-u", "--iso"]).await.text_out().trim(),
"2023-11-14T22:13:20+00:00"
);
assert_eq!(
run(&fixed_date(), &["-u", "--format", "%Y"]).await.text_out().trim(),
"2023"
);
}
#[tokio::test]
async fn relative_unit_spellings() {
let f = |spec: &'static str| async move {
run(&fixed_date(), &["-u", "-d", spec, "+%Y-%m-%dT%H:%M:%S"]).await.text_out().trim().to_string()
};
assert_eq!(f("1 second").await, "2023-11-14T22:13:21");
assert_eq!(f("2 secs").await, "2023-11-14T22:13:22");
assert_eq!(f("1 minute").await, "2023-11-14T22:14:20");
assert_eq!(f("3 mins").await, "2023-11-14T22:16:20");
assert_eq!(f("1 hour").await, "2023-11-14T23:13:20");
assert_eq!(f("2 hrs").await, "2023-11-15T00:13:20");
assert_eq!(f("1 day").await, "2023-11-15T22:13:20");
assert_eq!(f("1 wk").await, "2023-11-21T22:13:20");
assert_eq!(f("1 yr").await, "2024-11-14T22:13:20");
assert_eq!(f("2 years").await, "2025-11-14T22:13:20");
}
#[tokio::test]
async fn relative_explicit_negative_and_in_prefix() {
assert_eq!(
run(&fixed_date(), &["-u", "-d=-5 days", "+%Y-%m-%d"]).await.text_out().trim(),
"2023-11-09"
);
assert_eq!(
run(&fixed_date(), &["-u", "-d", "in 3 weeks", "+%Y-%m-%d"]).await.text_out().trim(),
"2023-12-05"
);
}
#[tokio::test]
async fn relative_multi_term() {
assert_eq!(
run(&fixed_date(), &["-u", "-d", "1 hour 30 minutes", "+%H:%M"]).await.text_out().trim(),
"23:43"
);
assert_eq!(
run(&fixed_date(), &["-u", "-d", "1 day 2 hours ago", "+%Y-%m-%dT%H:%M"]).await.text_out().trim(),
"2023-11-13T20:13"
);
}
#[tokio::test]
async fn absolute_datetime_with_time() {
assert_eq!(
run(&fixed_date(), &["-u", "-d", "2026-01-02T09:30:00", "+%Y-%m-%dT%H:%M:%S"]).await.text_out().trim(),
"2026-01-02T09:30:00"
);
assert_eq!(
run(&fixed_date(), &["-u", "-d", "2026-01-02 09:30", "+%H:%M"]).await.text_out().trim(),
"09:30"
);
}
#[tokio::test]
async fn weekday_full_names() {
assert_eq!(
run(&fixed_date(), &["-u", "-d", "next monday", "+%Y-%m-%d"]).await.text_out().trim(),
"2023-11-20"
);
assert_eq!(
run(&fixed_date(), &["-u", "-d", "last sunday", "+%Y-%m-%d"]).await.text_out().trim(),
"2023-11-12"
);
}
#[tokio::test]
async fn now_and_today_equal_clock() {
let now = run(&fixed_date(), &["-u", "-d", "now", "+%s"]).await;
let today = run(&fixed_date(), &["-u", "-d", "today", "+%s"]).await;
assert_eq!(now.text_out().trim(), FIXED_EPOCH.to_string());
assert_eq!(today.text_out().trim(), FIXED_EPOCH.to_string());
}
#[tokio::test]
async fn date_and_reference_conflict() {
let r = run(&fixed_date(), &["-d", "yesterday", "-r", "/tmp/x"]).await;
assert_eq!(r.code, 2, "--date and --reference must not combine");
}
#[tokio::test]
async fn empty_date_string_errors() {
assert_eq!(run(&fixed_date(), &["-d", ""]).await.code, 2);
assert_eq!(run(&fixed_date(), &["-d", " "]).await.code, 2);
}
#[tokio::test]
async fn bad_weekday_errors() {
let r = run(&fixed_date(), &["-d", "next blursday"]).await;
assert_eq!(r.code, 2);
}
#[tokio::test]
async fn overflow_year_errors_not_panics() {
let r = run(&fixed_date(), &["-d", "999999999999 years ago"]).await;
assert_eq!(r.code, 2, "absurd offset must error cleanly, not panic");
}
#[tokio::test]
async fn utc_flag_overrides_tz_var() {
let date = fixed_date();
let mut ctx = make_ctx();
ctx.scope.set_exported("TZ", Value::String("Asia/Tokyo".into()));
let r = date.execute_argv(argv(&["-u", "-d", "@1700000000", "+%H"]), &mut ctx).await;
assert_eq!(r.text_out().trim(), "22");
}
}