Skip to main content

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