1use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Utc};
8use thiserror::Error;
9
10const THINGS_EPOCH_YEAR: i32 = 2001;
12
13const MAX_YEAR: i32 = 2100;
15
16const MIN_REASONABLE_TIMESTAMP: i64 = -31536000; const MAX_REASONABLE_TIMESTAMP: i64 = 3_124_224_000; #[non_exhaustive]
24#[derive(Debug, Error, Clone, PartialEq)]
25pub enum DateConversionError {
26 #[error("Date is before Things 3 epoch (2001-01-01): {0}")]
28 BeforeEpoch(NaiveDate),
29
30 #[error("Date timestamp {0} is invalid or would cause overflow")]
32 InvalidTimestamp(i64),
33
34 #[error("Date is too far in the future (after year {MAX_YEAR}): {0}")]
36 TooFarFuture(NaiveDate),
37
38 #[error("Date conversion overflow during calculation")]
40 Overflow,
41
42 #[error("Failed to parse date string '{string}': {reason}")]
44 ParseError { string: String, reason: String },
45}
46
47#[non_exhaustive]
49#[derive(Debug, Error, Clone, PartialEq)]
50pub enum DateValidationError {
51 #[error("Deadline {deadline} cannot be before start date {start_date}")]
53 DeadlineBeforeStartDate {
54 start_date: NaiveDate,
55 deadline: NaiveDate,
56 },
57
58 #[error("Date conversion failed: {0}")]
60 ConversionFailed(#[from] DateConversionError),
61}
62
63pub fn is_valid_things_timestamp(seconds: i64) -> bool {
74 (MIN_REASONABLE_TIMESTAMP..=MAX_REASONABLE_TIMESTAMP).contains(&seconds)
75}
76
77pub fn safe_things_date_to_naive_date(
92 seconds_since_2001: i64,
93) -> Result<NaiveDate, DateConversionError> {
94 if !is_valid_things_timestamp(seconds_since_2001) {
96 return Err(DateConversionError::InvalidTimestamp(seconds_since_2001));
97 }
98
99 let base_date = Utc
101 .with_ymd_and_hms(THINGS_EPOCH_YEAR, 1, 1, 0, 0, 0)
102 .single()
103 .ok_or(DateConversionError::Overflow)?;
104
105 let date_time = base_date
107 .checked_add_signed(chrono::Duration::seconds(seconds_since_2001))
108 .ok_or(DateConversionError::Overflow)?;
109
110 let naive_date = date_time.date_naive();
111
112 if naive_date.year() > MAX_YEAR {
114 return Err(DateConversionError::TooFarFuture(naive_date));
115 }
116
117 Ok(naive_date)
118}
119
120pub fn safe_naive_date_to_things_timestamp(date: NaiveDate) -> Result<i64, DateConversionError> {
134 let epoch_date =
136 NaiveDate::from_ymd_opt(THINGS_EPOCH_YEAR, 1, 1).ok_or(DateConversionError::Overflow)?;
137
138 if date < epoch_date {
139 return Err(DateConversionError::BeforeEpoch(date));
140 }
141
142 if date.year() > MAX_YEAR {
144 return Err(DateConversionError::TooFarFuture(date));
145 }
146
147 let base_date = Utc
149 .with_ymd_and_hms(THINGS_EPOCH_YEAR, 1, 1, 0, 0, 0)
150 .single()
151 .ok_or(DateConversionError::Overflow)?;
152
153 let date_time = date
155 .and_time(NaiveTime::from_hms_opt(0, 0, 0).ok_or(DateConversionError::Overflow)?)
156 .and_local_timezone(Utc)
157 .single()
158 .ok_or(DateConversionError::Overflow)?;
159
160 let seconds = date_time.signed_duration_since(base_date).num_seconds();
162
163 Ok(seconds)
164}
165
166pub fn validate_date_range(
175 start_date: Option<NaiveDate>,
176 deadline: Option<NaiveDate>,
177) -> Result<(), DateValidationError> {
178 if let (Some(start), Some(end)) = (start_date, deadline) {
179 if end < start {
180 return Err(DateValidationError::DeadlineBeforeStartDate {
181 start_date: start,
182 deadline: end,
183 });
184 }
185 }
186 Ok(())
187}
188
189pub fn format_date_for_display(date: Option<NaiveDate>) -> String {
197 match date {
198 Some(d) => d.format("%Y-%m-%d").to_string(),
199 None => "None".to_string(),
200 }
201}
202
203pub fn parse_date_from_string(s: &str) -> Result<NaiveDate, DateConversionError> {
228 NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| DateConversionError::ParseError {
229 string: s.to_string(),
230 reason: e.to_string(),
231 })
232}
233
234pub fn is_date_in_past(date: NaiveDate) -> bool {
242 date < Utc::now().date_naive()
243}
244
245pub fn is_date_in_future(date: NaiveDate) -> bool {
253 date > Utc::now().date_naive()
254}
255
256pub fn add_days(date: NaiveDate, days: i64) -> Result<NaiveDate, DateConversionError> {
265 date.checked_add_signed(chrono::Duration::days(days))
266 .ok_or(DateConversionError::Overflow)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_is_valid_things_timestamp() {
275 assert!(is_valid_things_timestamp(0)); assert!(is_valid_things_timestamp(86400)); assert!(is_valid_things_timestamp(31536000)); assert!(!is_valid_things_timestamp(-100000000));
282
283 assert!(!is_valid_things_timestamp(4000000000));
285 }
286
287 #[test]
288 fn test_safe_things_date_conversion_epoch() {
289 let date = safe_things_date_to_naive_date(0).unwrap();
291 assert_eq!(date, NaiveDate::from_ymd_opt(2001, 1, 1).unwrap());
292 }
293
294 #[test]
295 fn test_safe_things_date_conversion_normal() {
296 let date = safe_things_date_to_naive_date(86400).unwrap();
298 assert_eq!(date, NaiveDate::from_ymd_opt(2001, 1, 2).unwrap());
299 }
300
301 #[test]
302 fn test_safe_things_date_conversion_invalid() {
303 assert!(safe_things_date_to_naive_date(10000000000).is_err());
305
306 assert!(safe_things_date_to_naive_date(-100000000).is_err());
308 }
309
310 #[test]
311 fn test_safe_naive_date_to_things_timestamp_epoch() {
312 let date = NaiveDate::from_ymd_opt(2001, 1, 1).unwrap();
313 let timestamp = safe_naive_date_to_things_timestamp(date).unwrap();
314 assert_eq!(timestamp, 0);
315 }
316
317 #[test]
318 fn test_safe_naive_date_to_things_timestamp_normal() {
319 let date = NaiveDate::from_ymd_opt(2001, 1, 2).unwrap();
320 let timestamp = safe_naive_date_to_things_timestamp(date).unwrap();
321 assert_eq!(timestamp, 86400);
322 }
323
324 #[test]
325 fn test_safe_naive_date_to_things_timestamp_before_epoch() {
326 let date = NaiveDate::from_ymd_opt(2000, 12, 31).unwrap();
327 let result = safe_naive_date_to_things_timestamp(date);
328 assert!(matches!(result, Err(DateConversionError::BeforeEpoch(_))));
329 }
330
331 #[test]
332 fn test_safe_naive_date_to_things_timestamp_too_far_future() {
333 let date = NaiveDate::from_ymd_opt(2150, 1, 1).unwrap();
334 let result = safe_naive_date_to_things_timestamp(date);
335 assert!(matches!(result, Err(DateConversionError::TooFarFuture(_))));
336 }
337
338 #[test]
339 fn test_round_trip_conversion() {
340 let original_date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
341 let timestamp = safe_naive_date_to_things_timestamp(original_date).unwrap();
342 let converted_date = safe_things_date_to_naive_date(timestamp).unwrap();
343 assert_eq!(original_date, converted_date);
344 }
345
346 #[test]
347 fn test_validate_date_range_valid() {
348 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
349 let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
350 assert!(validate_date_range(Some(start), Some(end)).is_ok());
351 }
352
353 #[test]
354 fn test_validate_date_range_invalid() {
355 let start = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
356 let end = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
357 let result = validate_date_range(Some(start), Some(end));
358 assert!(matches!(
359 result,
360 Err(DateValidationError::DeadlineBeforeStartDate { .. })
361 ));
362 }
363
364 #[test]
365 fn test_validate_date_range_same_date() {
366 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
367 assert!(validate_date_range(Some(date), Some(date)).is_ok());
368 }
369
370 #[test]
371 fn test_validate_date_range_only_start() {
372 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
373 assert!(validate_date_range(Some(start), None).is_ok());
374 }
375
376 #[test]
377 fn test_validate_date_range_only_end() {
378 let end = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
379 assert!(validate_date_range(None, Some(end)).is_ok());
380 }
381
382 #[test]
383 fn test_validate_date_range_both_none() {
384 assert!(validate_date_range(None, None).is_ok());
385 }
386
387 #[test]
388 fn test_format_date_for_display() {
389 let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
390 assert_eq!(format_date_for_display(Some(date)), "2024-06-15");
391 assert_eq!(format_date_for_display(None), "None");
392 }
393
394 #[test]
395 fn test_parse_date_from_string_valid() {
396 let date = parse_date_from_string("2024-06-15").unwrap();
397 assert_eq!(date, NaiveDate::from_ymd_opt(2024, 6, 15).unwrap());
398 }
399
400 #[test]
401 fn test_parse_date_from_string_invalid() {
402 assert!(parse_date_from_string("invalid").is_err());
403 assert!(parse_date_from_string("2024-13-01").is_err());
404 assert!(parse_date_from_string("2024-06-32").is_err());
405 }
406
407 #[test]
408 fn test_add_days_positive() {
409 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
410 let new_date = add_days(date, 10).unwrap();
411 assert_eq!(new_date, NaiveDate::from_ymd_opt(2024, 1, 11).unwrap());
412 }
413
414 #[test]
415 fn test_add_days_negative() {
416 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
417 let new_date = add_days(date, -10).unwrap();
418 assert_eq!(new_date, NaiveDate::from_ymd_opt(2024, 1, 5).unwrap());
419 }
420}