Skip to main content

tuitbot_core/
scheduling.rs

1//! Shared scheduling validation and normalization.
2//!
3//! # Timestamp contract
4//!
5//! All `scheduled_for` values are stored as UTC ISO-8601 with trailing `Z`.
6//! The posting engine compares `scheduled_for <= datetime('now')` — both sides
7//! are UTC, so comparisons are correct without conversion.
8//!
9//! ## Accepted input formats
10//!
11//! - `"2026-03-10T14:00:00Z"` — preferred UTC (returned as-is)
12//! - `"2026-03-10T14:00:00"` — bare string, treated as UTC for backward compat
13//! - `"2026-03-10T14:00:00+05:00"` — offset, converted to UTC
14//!
15//! ## Account timezone rules
16//!
17//! The account timezone (`ScheduleConfig.timezone`) is the canonical user-facing
18//! timezone. The frontend converts user-selected date/time from account timezone
19//! to UTC before sending to the server. The server never interprets timezone —
20//! it only validates format, rejects past timestamps, and normalizes to UTC.
21
22use chrono::{DateTime, NaiveDateTime, Utc};
23
24/// Default grace period in seconds for past-schedule rejection.
25/// Allows slight clock skew between client and server.
26pub const DEFAULT_GRACE_SECONDS: i64 = 300; // 5 minutes
27
28/// Errors from scheduling validation.
29#[derive(Debug, thiserror::Error)]
30pub enum SchedulingError {
31    /// The timestamp string could not be parsed.
32    #[error("invalid timestamp format: {0}")]
33    InvalidFormat(String),
34    /// The scheduled time is in the past (beyond the grace period).
35    #[error("scheduled time is in the past")]
36    InThePast,
37}
38
39/// Validate and normalize a `scheduled_for` timestamp to UTC with `Z` suffix.
40///
41/// Accepts UTC (`Z`), bare strings (treated as UTC), and offset strings
42/// (converted to UTC). Returns a normalized `YYYY-MM-DDTHH:MM:SSZ` string.
43pub fn normalize_scheduled_for(raw: &str) -> Result<String, SchedulingError> {
44    let trimmed = raw.trim();
45    if trimmed.is_empty() {
46        return Err(SchedulingError::InvalidFormat("empty string".to_string()));
47    }
48
49    // Try RFC 3339 / ISO 8601 with timezone info (handles both Z and offsets)
50    if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
51        let utc: DateTime<Utc> = dt.into();
52        return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
53    }
54
55    // Try bare datetime (no timezone) — treat as UTC
56    if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
57        let utc = naive.and_utc();
58        return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
59    }
60
61    // Try with fractional seconds
62    if let Ok(naive) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") {
63        let utc = naive.and_utc();
64        return Ok(utc.format("%Y-%m-%dT%H:%M:%SZ").to_string());
65    }
66
67    Err(SchedulingError::InvalidFormat(trimmed.to_string()))
68}
69
70/// Reject timestamps that are more than `grace_seconds` in the past.
71pub fn validate_not_past(utc_iso: &str, grace_seconds: i64) -> Result<(), SchedulingError> {
72    let dt = NaiveDateTime::parse_from_str(utc_iso.trim_end_matches('Z'), "%Y-%m-%dT%H:%M:%S")
73        .map_err(|_| SchedulingError::InvalidFormat(utc_iso.to_string()))?;
74
75    let utc = dt.and_utc();
76    let now = Utc::now();
77    let diff = utc.signed_duration_since(now);
78
79    if diff.num_seconds() < -grace_seconds {
80        return Err(SchedulingError::InThePast);
81    }
82
83    Ok(())
84}
85
86/// Combined: normalize + validate not in the past.
87pub fn validate_and_normalize(raw: &str, grace_seconds: i64) -> Result<String, SchedulingError> {
88    let normalized = normalize_scheduled_for(raw)?;
89    validate_not_past(&normalized, grace_seconds)?;
90    Ok(normalized)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn valid_utc_with_z_normalized_unchanged() {
99        let result = normalize_scheduled_for("2099-12-31T23:59:00Z").unwrap();
100        assert_eq!(result, "2099-12-31T23:59:00Z");
101    }
102
103    #[test]
104    fn bare_string_appends_z() {
105        let result = normalize_scheduled_for("2099-12-31T23:59:00").unwrap();
106        assert_eq!(result, "2099-12-31T23:59:00Z");
107    }
108
109    #[test]
110    fn offset_string_converts_to_utc() {
111        // +05:30 means the local time is 5:30 ahead of UTC
112        // 2099-12-31T23:59:00+05:30 → 2099-12-31T18:29:00Z
113        let result = normalize_scheduled_for("2099-12-31T23:59:00+05:30").unwrap();
114        assert_eq!(result, "2099-12-31T18:29:00Z");
115    }
116
117    #[test]
118    fn negative_offset_converts_to_utc() {
119        // -05:00 means 5 hours behind UTC
120        // 2099-12-31T19:00:00-05:00 → 2100-01-01T00:00:00Z
121        let result = normalize_scheduled_for("2099-12-31T19:00:00-05:00").unwrap();
122        assert_eq!(result, "2100-01-01T00:00:00Z");
123    }
124
125    #[test]
126    fn fractional_seconds_stripped() {
127        let result = normalize_scheduled_for("2099-12-31T23:59:00.123").unwrap();
128        assert_eq!(result, "2099-12-31T23:59:00Z");
129    }
130
131    #[test]
132    fn past_timestamp_rejected() {
133        let result = validate_and_normalize("2020-01-01T00:00:00Z", 300);
134        assert!(result.is_err());
135        assert!(matches!(result.unwrap_err(), SchedulingError::InThePast));
136    }
137
138    #[test]
139    fn future_timestamp_accepted() {
140        let result = validate_and_normalize("2099-12-31T23:59:00Z", 300);
141        assert!(result.is_ok());
142        assert_eq!(result.unwrap(), "2099-12-31T23:59:00Z");
143    }
144
145    #[test]
146    fn near_past_within_grace_accepted() {
147        // Create a timestamp 2 minutes in the past (within 300s grace)
148        let near_past = Utc::now() - chrono::Duration::seconds(120);
149        let ts = near_past.format("%Y-%m-%dT%H:%M:%SZ").to_string();
150        let result = validate_and_normalize(&ts, 300);
151        assert!(result.is_ok());
152    }
153
154    #[test]
155    fn near_past_beyond_grace_rejected() {
156        // Create a timestamp 10 minutes in the past (beyond 300s grace)
157        let far_past = Utc::now() - chrono::Duration::seconds(600);
158        let ts = far_past.format("%Y-%m-%dT%H:%M:%SZ").to_string();
159        let result = validate_and_normalize(&ts, 300);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn garbage_string_rejected() {
165        let result = normalize_scheduled_for("not-a-date");
166        assert!(result.is_err());
167        assert!(matches!(
168            result.unwrap_err(),
169            SchedulingError::InvalidFormat(_)
170        ));
171    }
172
173    #[test]
174    fn empty_string_rejected() {
175        let result = normalize_scheduled_for("");
176        assert!(result.is_err());
177        assert!(matches!(
178            result.unwrap_err(),
179            SchedulingError::InvalidFormat(_)
180        ));
181    }
182
183    #[test]
184    fn whitespace_only_rejected() {
185        let result = normalize_scheduled_for("   ");
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn bare_offset_backward_compat() {
191        // Bare string with offset should convert to UTC
192        let result = normalize_scheduled_for("2099-06-15T10:00:00+00:00").unwrap();
193        assert_eq!(result, "2099-06-15T10:00:00Z");
194    }
195
196    #[test]
197    fn validate_and_normalize_with_offset() {
198        let result = validate_and_normalize("2099-12-31T23:59:00+05:30", 300).unwrap();
199        assert_eq!(result, "2099-12-31T18:29:00Z");
200    }
201}