use chrono::{DateTime, NaiveDateTime, Utc};
pub const DEFAULT_GRACE_SECONDS: i64 = 300;
#[derive(Debug, thiserror::Error)]
pub enum SchedulingError {
#[error("invalid timestamp format: {0}")]
InvalidFormat(String),
#[error("scheduled time is in the past")]
InThePast,
}
pub fn normalize_scheduled_for(raw: &str) -> Result<String, SchedulingError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(SchedulingError::InvalidFormat("empty string".to_string()));
}
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
let utc: DateTime<Utc> = dt.into();
return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
}
if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
let utc = naive.and_utc();
return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
}
if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") {
let utc = naive.and_utc();
return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
}
Err(SchedulingError::InvalidFormat(trimmed.to_string()))
}
pub fn validate_not_past(utc_iso: &str, grace_seconds: i64) -> Result<(), SchedulingError> {
let dt = NaiveDateTime::parse_from_str(utc_iso.trim_end_matches('Z'), "%Y-%m-%dT%H:%M:%S")
.map_err(|_| SchedulingError::InvalidFormat(utc_iso.to_string()))?;
let utc = dt.and_utc();
let now = Utc::now();
let diff = utc.signed_duration_since(now);
if diff.num_seconds() < -grace_seconds {
return Err(SchedulingError::InThePast);
}
Ok(())
}
pub fn validate_and_normalize(raw: &str, grace_seconds: i64) -> Result<String, SchedulingError> {
let normalized = normalize_scheduled_for(raw)?;
validate_not_past(&normalized, grace_seconds)?;
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_utc_with_z_normalized_unchanged() {
let result = normalize_scheduled_for("2099-12-31T23:59:00Z").unwrap();
assert_eq!(result, "2099-12-31T23:59:00Z");
}
#[test]
fn bare_string_appends_z() {
let result = normalize_scheduled_for("2099-12-31T23:59:00").unwrap();
assert_eq!(result, "2099-12-31T23:59:00Z");
}
#[test]
fn offset_string_converts_to_utc() {
let result = normalize_scheduled_for("2099-12-31T23:59:00+05:30").unwrap();
assert_eq!(result, "2099-12-31T18:29:00Z");
}
#[test]
fn negative_offset_converts_to_utc() {
let result = normalize_scheduled_for("2099-12-31T19:00:00-05:00").unwrap();
assert_eq!(result, "2100-01-01T00:00:00Z");
}
#[test]
fn fractional_seconds_stripped() {
let result = normalize_scheduled_for("2099-12-31T23:59:00.123").unwrap();
assert_eq!(result, "2099-12-31T23:59:00Z");
}
#[test]
fn past_timestamp_rejected() {
let result = validate_and_normalize("2020-01-01T00:00:00Z", 300);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SchedulingError::InThePast));
}
#[test]
fn future_timestamp_accepted() {
let result = validate_and_normalize("2099-12-31T23:59:00Z", 300);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "2099-12-31T23:59:00Z");
}
#[test]
fn near_past_within_grace_accepted() {
let near_past = Utc::now() - chrono::Duration::seconds(120);
let ts = near_past.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let result = validate_and_normalize(&ts, 300);
assert!(result.is_ok());
}
#[test]
fn near_past_beyond_grace_rejected() {
let far_past = Utc::now() - chrono::Duration::seconds(600);
let ts = far_past.format("%Y-%m-%dT%H:%M:%SZ").to_string();
let result = validate_and_normalize(&ts, 300);
assert!(result.is_err());
}
#[test]
fn garbage_string_rejected() {
let result = normalize_scheduled_for("not-a-date");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SchedulingError::InvalidFormat(_)
));
}
#[test]
fn empty_string_rejected() {
let result = normalize_scheduled_for("");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SchedulingError::InvalidFormat(_)
));
}
#[test]
fn whitespace_only_rejected() {
let result = normalize_scheduled_for(" ");
assert!(result.is_err());
}
#[test]
fn bare_offset_backward_compat() {
let result = normalize_scheduled_for("2099-06-15T10:00:00+00:00").unwrap();
assert_eq!(result, "2099-06-15T10:00:00Z");
}
#[test]
fn validate_and_normalize_with_offset() {
let result = validate_and_normalize("2099-12-31T23:59:00+05:30", 300).unwrap();
assert_eq!(result, "2099-12-31T18:29:00Z");
}
}