use chrono::{DateTime, Local, Utc};
use chrono_tz::Tz;
use clap::Parser;
use std::io::Write;
#[derive(Parser, Debug)]
#[command(
name = "pathdate",
author,
version,
about = "Generate a path-safe ISO8601-like date/datetime/timestamp.",
long_about = None
)]
struct Args {
#[arg(long, conflicts_with = "tz")]
utc: bool,
#[arg(long, value_name = "TIMEZONE", conflicts_with = "utc")]
tz: Option<String>,
#[arg(long = "date", alias = "date-only", conflicts_with_all = ["seconds", "epoch", "epoch_ms"])]
date_only: bool,
#[arg(long = "sec", alias = "seconds", conflicts_with_all = ["date_only", "epoch", "epoch_ms"])]
seconds: bool,
#[arg(long = "ms", alias = "milliseconds", conflicts_with_all = ["date_only", "seconds", "epoch"])]
millis: bool,
#[arg(long, value_name = "FORMAT", conflicts_with_all = ["date_only", "seconds", "millis", "epoch", "epoch_ms", "tight"])]
format: Option<String>,
#[arg(long = "no-newline")]
no_newline: bool,
#[arg(long, conflicts_with_all = ["format", "epoch"])]
tight: bool,
#[arg(long, conflicts_with_all = ["format"])]
epoch: bool,
#[arg(long = "epoch-ms", conflicts_with_all = ["format"])]
epoch_ms: bool,
}
fn build_format(args: &Args) -> String {
if let Some(ref fmt) = args.format {
return fmt.clone();
}
let millis = args.millis;
let secs = args.seconds || millis;
let tight = args.tight;
if args.date_only {
if tight {
return "%Y%m%d".into();
}
return "%Y-%m-%d".into();
}
if tight {
let base = "%Y%m%dT%H%M";
if millis {
return format!("{}%S%3f", base);
}
if secs {
return format!("{}%S", base);
}
return base.into();
}
if millis {
return "%Y-%m-%d-T%H-%M-%S-%3f".into();
}
if secs {
return "%Y-%m-%d-T%H-%M-%S".into();
}
"%Y-%m-%d-T%H%M".into()
}
fn format_datetime(args: &Args) -> String {
let now_utc: DateTime<Utc> = Utc::now();
if args.epoch_ms {
return format!("{}", now_utc.timestamp_millis());
}
if args.epoch {
return format!("{}", now_utc.timestamp());
}
let fmt = build_format(args);
if let Some(ref tz_name) = args.tz {
let tz: Tz = tz_name
.parse()
.unwrap_or_else(|_| panic!("Unknown timezone: {}", tz_name));
let dt = now_utc.with_timezone(&tz);
let formatted = dt.format(&fmt).to_string();
if args.date_only || args.format.is_some() {
return formatted;
}
let abbr = dt.format("%Z").to_string();
return format!("{}{}", formatted, abbr);
}
if args.utc {
let formatted = now_utc.format(&fmt).to_string();
if args.date_only || args.format.is_some() {
return formatted;
}
return format!("{}Z", formatted);
}
let dt: DateTime<Local> = Local::now();
let formatted = dt.format(&fmt).to_string();
if args.date_only || args.format.is_some() {
return formatted;
}
format!("{}L", formatted)
}
fn main() {
let args = Args::parse();
let output = format_datetime(&args);
print!("{}", output);
if !args.no_newline {
println!();
} else {
std::io::stdout().flush().ok();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_args() -> Args {
Args {
utc: false,
tz: None,
date_only: false,
seconds: false,
millis: false,
format: None,
no_newline: false,
tight: false,
epoch: false,
epoch_ms: false,
}
}
#[test]
fn format_default_is_to_minutes() {
let args = default_args();
assert_eq!(build_format(&args), "%Y-%m-%d-T%H%M");
}
#[test]
fn format_seconds_flag() {
let args = Args {
seconds: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y-%m-%d-T%H-%M-%S");
}
#[test]
fn format_millis_flag_implies_seconds() {
let args = Args {
millis: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y-%m-%d-T%H-%M-%S-%3f");
}
#[test]
fn format_date_only() {
let args = Args {
date_only: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y-%m-%d");
}
#[test]
fn format_date_only_tight() {
let args = Args {
date_only: true,
tight: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y%m%d");
}
#[test]
fn format_tight_default() {
let args = Args {
tight: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y%m%dT%H%M");
}
#[test]
fn format_tight_with_seconds() {
let args = Args {
tight: true,
seconds: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y%m%dT%H%M%S");
}
#[test]
fn format_tight_with_millis() {
let args = Args {
tight: true,
millis: true,
..default_args()
};
assert_eq!(build_format(&args), "%Y%m%dT%H%M%S%3f");
}
#[test]
fn format_custom_overrides_everything() {
let args = Args {
format: Some("%d/%m/%Y".into()),
tight: true,
seconds: true,
millis: true,
..default_args()
};
assert_eq!(build_format(&args), "%d/%m/%Y");
}
#[test]
fn output_utc_ends_with_z() {
let args = Args {
utc: true,
..default_args()
};
let out = format_datetime(&args);
assert!(out.ends_with('Z'), "UTC output should end with Z: {}", out);
}
#[test]
fn output_local_ends_with_l() {
let args = default_args();
let out = format_datetime(&args);
assert!(
out.ends_with('L'),
"Local output should end with L: {}",
out
);
}
#[test]
fn output_date_only_no_suffix() {
let args = Args {
date_only: true,
utc: true,
..default_args()
};
let out = format_datetime(&args);
assert_eq!(out.len(), 10, "Date-only output length: {}", out);
assert!(
!out.ends_with('Z'),
"Date-only should have no suffix: {}",
out
);
}
#[test]
fn output_epoch_is_numeric() {
let args = Args {
epoch: true,
..default_args()
};
let out = format_datetime(&args);
assert!(
out.parse::<i64>().is_ok(),
"Epoch output should be numeric: {}",
out
);
let ts = out.parse::<i64>().unwrap();
assert!(ts > 1_700_000_000, "Epoch too small: {}", ts);
}
#[test]
fn output_epoch_ms_is_larger_than_epoch() {
let args_ms = Args {
epoch_ms: true,
..default_args()
};
let args_s = Args {
epoch: true,
..default_args()
};
let ms: i64 = format_datetime(&args_ms).parse().unwrap();
let s: i64 = format_datetime(&args_s).parse().unwrap();
assert!(ms > s * 100, "epoch-ms should be ~1000x epoch-s");
}
#[test]
fn output_with_seconds_has_extra_segment() {
let args_min = Args {
utc: true,
..default_args()
};
let args_sec = Args {
utc: true,
seconds: true,
..default_args()
};
let out_min = format_datetime(&args_min);
let out_sec = format_datetime(&args_sec);
assert!(
out_sec.len() > out_min.len(),
"seconds output should be longer"
);
}
#[test]
fn output_with_millis_has_extra_segment() {
let args_sec = Args {
utc: true,
seconds: true,
..default_args()
};
let args_ms = Args {
utc: true,
millis: true,
..default_args()
};
let out_sec = format_datetime(&args_sec);
let out_ms = format_datetime(&args_ms);
assert!(
out_ms.len() > out_sec.len(),
"millis output should be longer than seconds"
);
}
#[test]
fn output_tight_has_no_hyphens_in_time() {
let args = Args {
utc: true,
tight: true,
..default_args()
};
let out = format_datetime(&args);
let body = &out[..out.len() - 1];
assert!(
!body.contains('-'),
"Tight output should have no hyphens: {}",
out
);
}
#[test]
fn output_tight_date_only_compact() {
let args = Args {
date_only: true,
tight: true,
utc: true,
..default_args()
};
let out = format_datetime(&args);
assert_eq!(out.len(), 8, "Tight date-only should be 8 chars: {}", out);
assert!(!out.contains('-'));
}
#[test]
fn output_custom_format() {
let args = Args {
utc: true,
format: Some("%Y/%m/%d".into()),
..default_args()
};
let out = format_datetime(&args);
assert!(out.contains('/'), "Custom format output: {}", out);
assert!(
!out.ends_with('Z'),
"Custom format should not add Z: {}",
out
);
}
#[test]
fn output_named_tz_appends_abbreviation() {
let args = Args {
tz: Some("UTC".into()),
..default_args()
};
let out = format_datetime(&args);
assert!(
out.ends_with("UTC"),
"Named UTC tz should end with 'UTC': {}",
out
);
}
#[test]
fn output_named_tz_new_york() {
let args = Args {
tz: Some("America/New_York".into()),
..default_args()
};
let out = format_datetime(&args);
assert!(
out.ends_with("EST") || out.ends_with("EDT"),
"New York tz suffix unexpected: {}",
out
);
}
#[test]
fn millis_without_explicit_seconds_still_includes_seconds() {
let args = Args {
utc: true,
millis: true,
..default_args()
};
let fmt = build_format(&args);
assert!(fmt.contains("%S"), "millis format should contain %S");
}
#[test]
fn tight_millis_no_colons_or_hyphens_in_body() {
let args = Args {
utc: true,
tight: true,
millis: true,
..default_args()
};
let out = format_datetime(&args);
let body = out.trim_end_matches('Z');
assert!(!body.contains(':'), "tight output should have no colons");
assert!(
!body.contains('-'),
"tight output should have no hyphens: {}",
body
);
}
#[test]
fn epoch_ms_flag_ignores_date_and_format_flags() {
let args = Args {
epoch_ms: true,
date_only: true,
utc: true,
format: Some("%Y".into()),
..default_args()
};
let out = format_datetime(&args);
assert!(
out.parse::<i64>().is_ok(),
"epoch-ms should still be numeric: {}",
out
);
}
}