things3_core/database/
date_utils.rs

1//! Date validation and conversion utilities for Things 3
2//!
3//! This module provides safe date conversion between Things 3's internal format
4//! (seconds since 2001-01-01) and standard date types, along with comprehensive
5//! validation to ensure date consistency.
6
7use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone, Utc};
8use thiserror::Error;
9
10/// Things 3 epoch: 2001-01-01 00:00:00 UTC
11const THINGS_EPOCH_YEAR: i32 = 2001;
12
13/// Maximum reasonable year for Things 3 dates (year 2100)
14const MAX_YEAR: i32 = 2100;
15
16/// Minimum reasonable timestamp (2000-01-01, before Things 3 was created but allows some leeway)
17const MIN_REASONABLE_TIMESTAMP: i64 = -31536000; // ~1 year before epoch
18
19/// Maximum reasonable timestamp (2100-01-01)
20const MAX_REASONABLE_TIMESTAMP: i64 = 3_124_224_000; // ~99 years after epoch
21
22/// Errors that can occur during date conversion
23#[derive(Debug, Error, Clone, PartialEq)]
24pub enum DateConversionError {
25    /// Date is before the Things 3 epoch (2001-01-01)
26    #[error("Date is before Things 3 epoch (2001-01-01): {0}")]
27    BeforeEpoch(NaiveDate),
28
29    /// Date timestamp is invalid or would cause overflow
30    #[error("Date timestamp {0} is invalid or would cause overflow")]
31    InvalidTimestamp(i64),
32
33    /// Date is too far in the future (after 2100)
34    #[error("Date is too far in the future (after year {MAX_YEAR}): {0}")]
35    TooFarFuture(NaiveDate),
36
37    /// Date conversion resulted in overflow
38    #[error("Date conversion overflow during calculation")]
39    Overflow,
40
41    /// Date string parsing failed
42    #[error("Failed to parse date string '{string}': {reason}")]
43    ParseError { string: String, reason: String },
44}
45
46/// Errors that can occur during date validation
47#[derive(Debug, Error, Clone, PartialEq)]
48pub enum DateValidationError {
49    /// Deadline cannot be before start date
50    #[error("Deadline {deadline} cannot be before start date {start_date}")]
51    DeadlineBeforeStartDate {
52        start_date: NaiveDate,
53        deadline: NaiveDate,
54    },
55
56    /// Date conversion failed
57    #[error("Date conversion failed: {0}")]
58    ConversionFailed(#[from] DateConversionError),
59}
60
61/// Check if a Things 3 timestamp is within a reasonable range
62///
63/// Things 3 was released in 2009, so dates before 2000 are suspicious.
64/// Dates after 2100 are likely errors or overflow.
65///
66/// # Arguments
67/// * `seconds` - Seconds since 2001-01-01
68///
69/// # Returns
70/// `true` if the timestamp is reasonable, `false` otherwise
71pub fn is_valid_things_timestamp(seconds: i64) -> bool {
72    (MIN_REASONABLE_TIMESTAMP..=MAX_REASONABLE_TIMESTAMP).contains(&seconds)
73}
74
75/// Convert Things 3 timestamp to NaiveDate with comprehensive error handling
76///
77/// Things 3 stores dates as seconds since 2001-01-01 00:00:00 UTC.
78///
79/// # Arguments
80/// * `seconds_since_2001` - Seconds since the Things 3 epoch
81///
82/// # Returns
83/// `Ok(NaiveDate)` if conversion succeeds, `Err` with detailed error otherwise
84///
85/// # Errors
86/// Returns error if:
87/// - Timestamp is invalid or would cause overflow
88/// - Resulting date is before 2000 or after 2100
89pub fn safe_things_date_to_naive_date(
90    seconds_since_2001: i64,
91) -> Result<NaiveDate, DateConversionError> {
92    // Check for reasonable range
93    if !is_valid_things_timestamp(seconds_since_2001) {
94        return Err(DateConversionError::InvalidTimestamp(seconds_since_2001));
95    }
96
97    // Base date: 2001-01-01 00:00:00 UTC
98    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    // Add seconds to get the actual date
104    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    // Verify the result is reasonable
111    if naive_date.year() > MAX_YEAR {
112        return Err(DateConversionError::TooFarFuture(naive_date));
113    }
114
115    Ok(naive_date)
116}
117
118/// Convert NaiveDate to Things 3 timestamp with validation
119///
120/// # Arguments
121/// * `date` - The date to convert
122///
123/// # Returns
124/// `Ok(i64)` timestamp if conversion succeeds, `Err` with detailed error otherwise
125///
126/// # Errors
127/// Returns error if:
128/// - Date is before the Things 3 epoch (2001-01-01)
129/// - Date is too far in the future (after 2100)
130/// - Calculation would overflow
131pub fn safe_naive_date_to_things_timestamp(date: NaiveDate) -> Result<i64, DateConversionError> {
132    // Check if date is before epoch
133    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    // Check if date is too far in the future
141    if date.year() > MAX_YEAR {
142        return Err(DateConversionError::TooFarFuture(date));
143    }
144
145    // Base date: 2001-01-01 00:00:00 UTC
146    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    // Convert NaiveDate to DateTime at midnight UTC
152    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    // Calculate seconds difference
159    let seconds = date_time.signed_duration_since(base_date).num_seconds();
160
161    Ok(seconds)
162}
163
164/// Validate that a deadline is not before a start date
165///
166/// # Arguments
167/// * `start_date` - Optional start date
168/// * `deadline` - Optional deadline
169///
170/// # Returns
171/// `Ok(())` if dates are valid or None, `Err` if deadline is before start date
172pub 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
187/// Format a date for display, handling None gracefully
188///
189/// # Arguments
190/// * `date` - Optional date to format
191///
192/// # Returns
193/// ISO 8601 formatted date string, or "None" if date is None
194pub 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
201/// Parse a date from a string, supporting multiple formats
202///
203/// Currently supports:
204/// - ISO 8601: "YYYY-MM-DD" (e.g., "2024-12-31")
205///
206/// Future formats to consider:
207/// - US format: "MM/DD/YYYY"
208/// - European format: "DD/MM/YYYY"
209/// - Natural language: "today", "tomorrow", "next week"
210///
211/// # Arguments
212/// * `s` - String to parse
213///
214/// # Returns
215/// `Ok(NaiveDate)` if parsing succeeds, `Err(DateConversionError)` otherwise
216///
217/// # Examples
218/// ```
219/// use things3_core::database::parse_date_from_string;
220/// use chrono::NaiveDate;
221///
222/// let date = parse_date_from_string("2024-12-31").unwrap();
223/// assert_eq!(date, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
224/// ```
225pub 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
232/// Check if a date is in the past
233///
234/// # Arguments
235/// * `date` - Date to check
236///
237/// # Returns
238/// `true` if the date is before today (UTC), `false` otherwise
239pub fn is_date_in_past(date: NaiveDate) -> bool {
240    date < Utc::now().date_naive()
241}
242
243/// Check if a date is in the future
244///
245/// # Arguments
246/// * `date` - Date to check
247///
248/// # Returns
249/// `true` if the date is after today (UTC), `false` otherwise
250pub fn is_date_in_future(date: NaiveDate) -> bool {
251    date > Utc::now().date_naive()
252}
253
254/// Add days to a date with overflow checking
255///
256/// # Arguments
257/// * `date` - Starting date
258/// * `days` - Number of days to add (can be negative)
259///
260/// # Returns
261/// `Ok(NaiveDate)` if successful, `Err` if overflow would occur
262pub 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        // Valid timestamps
274        assert!(is_valid_things_timestamp(0)); // Epoch
275        assert!(is_valid_things_timestamp(86400)); // 1 day after
276        assert!(is_valid_things_timestamp(31536000)); // 1 year after
277
278        // Invalid - too far in past
279        assert!(!is_valid_things_timestamp(-100000000));
280
281        // Invalid - too far in future (beyond 2100)
282        assert!(!is_valid_things_timestamp(4000000000));
283    }
284
285    #[test]
286    fn test_safe_things_date_conversion_epoch() {
287        // Epoch should convert to 2001-01-01
288        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        // 1 day after epoch
295        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        // Way too far in future
302        assert!(safe_things_date_to_naive_date(10000000000).is_err());
303
304        // Way too far in past
305        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}