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