tuitbot_core/
scheduling.rs1use chrono::{DateTime, NaiveDateTime, Utc};
23
24pub const DEFAULT_GRACE_SECONDS: i64 = 300; #[derive(Debug, thiserror::Error)]
30pub enum SchedulingError {
31 #[error("invalid timestamp format: {0}")]
33 InvalidFormat(String),
34 #[error("scheduled time is in the past")]
36 InThePast,
37}
38
39pub 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 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 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 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
70pub 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
86pub 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 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 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 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 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 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}