Skip to main content

things3_core/database/
conversions.rs

1//! Timestamp, date, tag-blob conversions and small enum mappers.
2#![allow(deprecated)]
3//!
4//! Things 3 stores dates as seconds since 2001-01-01 UTC (a `REAL` column for
5//! creation/modification, `INTEGER` for start/deadline). These helpers convert
6//! between that representation and `chrono` types, plus two small `from_i32`
7//! mappers for the integer task-status/type columns.
8
9use crate::error::{Result as ThingsResult, ThingsError};
10use crate::models::{TaskStatus, TaskType};
11use chrono::NaiveDate;
12
13/// Convert f64 timestamp to i64 safely
14pub(crate) fn safe_timestamp_convert(ts_f64: f64) -> i64 {
15    // Use try_from to avoid clippy warnings about casting
16    if ts_f64.is_finite() && ts_f64 >= 0.0 {
17        // Use a reasonable upper bound for timestamps (year 2100)
18        let max_timestamp = 4_102_444_800_f64; // 2100-01-01 00:00:00 UTC
19        if ts_f64 <= max_timestamp {
20            // Convert via string to avoid precision loss warnings
21            let ts_str = format!("{:.0}", ts_f64.trunc());
22            ts_str.parse::<i64>().unwrap_or(0)
23        } else {
24            0 // Use epoch if too large
25        }
26    } else {
27        0 // Use epoch if invalid
28    }
29}
30
31/// Convert Things 3 date value (seconds since 2001-01-01) to NaiveDate
32pub(crate) fn things_date_to_naive_date(seconds_since_2001: i64) -> Option<chrono::NaiveDate> {
33    use chrono::{TimeZone, Utc};
34
35    if seconds_since_2001 <= 0 {
36        return None;
37    }
38
39    // Base date: 2001-01-01 00:00:00 UTC
40    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
41
42    // Add seconds to get the actual date
43    let date_time = base_date + chrono::Duration::seconds(seconds_since_2001);
44
45    Some(date_time.date_naive())
46}
47
48/// Convert NaiveDate to Things 3 timestamp (seconds since 2001-01-01)
49pub fn naive_date_to_things_timestamp(date: NaiveDate) -> i64 {
50    use chrono::{NaiveTime, TimeZone, Utc};
51
52    // Base date: 2001-01-01 00:00:00 UTC
53    let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
54
55    // Convert NaiveDate to DateTime at midnight UTC
56    let date_time = date
57        .and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
58        .and_local_timezone(Utc)
59        .single()
60        .unwrap();
61
62    // Calculate seconds difference
63    date_time.timestamp() - base_date.timestamp()
64}
65
66/// Serialize tags to Things 3 binary format
67/// Note: This is a simplified implementation using JSON
68/// The actual Things 3 binary format is proprietary
69pub fn serialize_tags_to_blob(tags: &[String]) -> ThingsResult<Vec<u8>> {
70    serde_json::to_vec(tags)
71        .map_err(|e| ThingsError::unknown(format!("Failed to serialize tags: {e}")))
72}
73
74/// Deserialize tags from Things 3 binary format
75pub fn deserialize_tags_from_blob(blob: &[u8]) -> ThingsResult<Vec<String>> {
76    if blob.is_empty() {
77        return Ok(Vec::new());
78    }
79    serde_json::from_slice(blob)
80        .map_err(|e| ThingsError::unknown(format!("Failed to deserialize tags: {e}")))
81}
82
83impl TaskStatus {
84    pub(crate) fn from_i32(value: i32) -> Option<Self> {
85        match value {
86            0 => Some(TaskStatus::Incomplete),
87            2 => Some(TaskStatus::Canceled),
88            3 => Some(TaskStatus::Completed),
89            _ => None,
90        }
91    }
92}
93
94impl TaskType {
95    pub(crate) fn from_i32(value: i32) -> Option<Self> {
96        match value {
97            0 => Some(TaskType::Todo),
98            1 => Some(TaskType::Project),
99            2 => Some(TaskType::Heading),
100            3 => Some(TaskType::Area),
101            _ => None,
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_task_status_from_i32() {
112        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
113        assert_eq!(TaskStatus::from_i32(1), None); // unused in real Things 3
114        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
115        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Completed));
116        assert_eq!(TaskStatus::from_i32(4), None);
117        assert_eq!(TaskStatus::from_i32(-1), None);
118    }
119
120    #[test]
121    fn test_task_type_from_i32() {
122        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
123        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
124        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
125        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
126        assert_eq!(TaskType::from_i32(4), None);
127        assert_eq!(TaskType::from_i32(-1), None);
128    }
129
130    #[test]
131    fn test_safe_timestamp_convert_edge_cases() {
132        // Test normal timestamp
133        assert_eq!(safe_timestamp_convert(1_609_459_200.0), 1_609_459_200); // 2021-01-01
134
135        // Test zero
136        assert_eq!(safe_timestamp_convert(0.0), 0);
137
138        // Test negative (should return 0)
139        assert_eq!(safe_timestamp_convert(-1.0), 0);
140
141        // Test infinity (should return 0)
142        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
143
144        // Test NaN (should return 0)
145        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
146
147        // Test very large timestamp (should return 0)
148        assert_eq!(safe_timestamp_convert(5_000_000_000.0), 0);
149
150        // Test max valid timestamp
151        let max_timestamp = 4_102_444_800_f64; // 2100-01-01
152        assert_eq!(safe_timestamp_convert(max_timestamp), 4_102_444_800);
153    }
154
155    #[test]
156    fn test_task_status_from_i32_all_variants() {
157        assert_eq!(TaskStatus::from_i32(0), Some(TaskStatus::Incomplete));
158        assert_eq!(TaskStatus::from_i32(1), None); // unused in real Things 3
159        assert_eq!(TaskStatus::from_i32(2), Some(TaskStatus::Canceled));
160        assert_eq!(TaskStatus::from_i32(3), Some(TaskStatus::Completed));
161        assert_eq!(TaskStatus::from_i32(999), None);
162        assert_eq!(TaskStatus::from_i32(-1), None);
163    }
164
165    #[test]
166    fn test_task_type_from_i32_all_variants() {
167        assert_eq!(TaskType::from_i32(0), Some(TaskType::Todo));
168        assert_eq!(TaskType::from_i32(1), Some(TaskType::Project));
169        assert_eq!(TaskType::from_i32(2), Some(TaskType::Heading));
170        assert_eq!(TaskType::from_i32(3), Some(TaskType::Area));
171        assert_eq!(TaskType::from_i32(999), None);
172        assert_eq!(TaskType::from_i32(-1), None);
173    }
174
175    #[test]
176    fn test_things_date_negative_returns_none() {
177        // Negative values should return None
178        assert_eq!(things_date_to_naive_date(-1), None);
179        assert_eq!(things_date_to_naive_date(-100), None);
180        assert_eq!(things_date_to_naive_date(i64::MIN), None);
181    }
182
183    #[test]
184    fn test_things_date_zero_returns_none() {
185        // Zero should return None (no date set)
186        assert_eq!(things_date_to_naive_date(0), None);
187    }
188
189    #[test]
190    fn test_things_date_boundary_2001() {
191        use chrono::Datelike;
192        // 1 second after 2001-01-01 00:00:00 should be 2001-01-01
193        let result = things_date_to_naive_date(1);
194        assert!(result.is_some());
195
196        let date = result.unwrap();
197        assert_eq!(date.year(), 2001);
198        assert_eq!(date.month(), 1);
199        assert_eq!(date.day(), 1);
200    }
201
202    #[test]
203    fn test_things_date_one_day() {
204        use chrono::Datelike;
205        // 86400 seconds = 1 day (60 * 60 * 24), should be 2001-01-02
206        let seconds_per_day = 86400i64;
207        let result = things_date_to_naive_date(seconds_per_day);
208        assert!(result.is_some());
209
210        let date = result.unwrap();
211        assert_eq!(date.year(), 2001);
212        assert_eq!(date.month(), 1);
213        assert_eq!(date.day(), 2);
214    }
215
216    #[test]
217    fn test_things_date_one_year() {
218        use chrono::Datelike;
219        // ~365 days should be around 2002-01-01 (365 days * 86400 seconds/day)
220        let seconds_per_year = 365 * 86400i64;
221        let result = things_date_to_naive_date(seconds_per_year);
222        assert!(result.is_some());
223
224        let date = result.unwrap();
225        assert_eq!(date.year(), 2002);
226    }
227
228    #[test]
229    fn test_things_date_current_era() {
230        use chrono::Datelike;
231        // Test a date in the current era (2024)
232        // Days from 2001-01-01 to 2024-01-01 = ~8401 days
233        // Calculation: (2024-2001) * 365 + leap days (2004, 2008, 2012, 2016, 2020) = 23 * 365 + 5 = 8400
234        let days_to_2024 = 8401i64;
235        let seconds_to_2024 = days_to_2024 * 86400;
236
237        let result = things_date_to_naive_date(seconds_to_2024);
238        assert!(result.is_some());
239
240        let date = result.unwrap();
241        assert_eq!(date.year(), 2024);
242    }
243
244    #[test]
245    fn test_things_date_leap_year() {
246        use chrono::{Datelike, TimeZone, Utc};
247        // Test Feb 29, 2004 (leap year)
248        // Days from 2001-01-01 to 2004-02-29
249        let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
250        let target_date = Utc.with_ymd_and_hms(2004, 2, 29, 0, 0, 0).single().unwrap();
251        let seconds_diff = (target_date - base_date).num_seconds();
252
253        let result = things_date_to_naive_date(seconds_diff);
254        assert!(result.is_some());
255
256        let date = result.unwrap();
257        assert_eq!(date.year(), 2004);
258        assert_eq!(date.month(), 2);
259        assert_eq!(date.day(), 29);
260    }
261
262    #[test]
263    fn test_safe_timestamp_convert_normal_values() {
264        // Normal timestamp values should convert correctly
265        let ts = 1_700_000_000.0; // Around 2023
266        let result = safe_timestamp_convert(ts);
267        assert_eq!(result, 1_700_000_000);
268    }
269
270    #[test]
271    fn test_safe_timestamp_convert_zero() {
272        // Zero should return zero
273        assert_eq!(safe_timestamp_convert(0.0), 0);
274    }
275
276    #[test]
277    fn test_safe_timestamp_convert_negative() {
278        // Negative values should return zero (safe fallback)
279        assert_eq!(safe_timestamp_convert(-1.0), 0);
280        assert_eq!(safe_timestamp_convert(-1000.0), 0);
281    }
282
283    #[test]
284    fn test_safe_timestamp_convert_infinity() {
285        // Infinity should return zero (safe fallback)
286        assert_eq!(safe_timestamp_convert(f64::INFINITY), 0);
287        assert_eq!(safe_timestamp_convert(f64::NEG_INFINITY), 0);
288    }
289
290    #[test]
291    fn test_safe_timestamp_convert_nan() {
292        // NaN should return zero (safe fallback)
293        assert_eq!(safe_timestamp_convert(f64::NAN), 0);
294    }
295
296    #[test]
297    fn test_date_roundtrip_known_dates() {
298        use chrono::{Datelike, TimeZone, Utc};
299        // Test roundtrip conversion for known dates
300        // Note: Starting from 2001-01-02 because 2001-01-01 is the base date (0 seconds)
301        // and things_date_to_naive_date returns None for values <= 0
302        let test_cases = vec![
303            (2001, 1, 2), // Start from day 2 since day 1 is the base (0 seconds)
304            (2010, 6, 15),
305            (2020, 12, 31),
306            (2024, 2, 29), // Leap year
307            (2025, 7, 4),
308        ];
309
310        for (year, month, day) in test_cases {
311            let base_date = Utc.with_ymd_and_hms(2001, 1, 1, 0, 0, 0).single().unwrap();
312            let target_date = Utc
313                .with_ymd_and_hms(year, month, day, 0, 0, 0)
314                .single()
315                .unwrap();
316            let seconds = (target_date - base_date).num_seconds();
317
318            let converted = things_date_to_naive_date(seconds);
319            assert!(
320                converted.is_some(),
321                "Failed to convert {}-{:02}-{:02}",
322                year,
323                month,
324                day
325            );
326
327            let result_date = converted.unwrap();
328            assert_eq!(
329                result_date.year(),
330                year,
331                "Year mismatch for {}-{:02}-{:02}",
332                year,
333                month,
334                day
335            );
336            assert_eq!(
337                result_date.month(),
338                month,
339                "Month mismatch for {}-{:02}-{:02}",
340                year,
341                month,
342                day
343            );
344            assert_eq!(
345                result_date.day(),
346                day,
347                "Day mismatch for {}-{:02}-{:02}",
348                year,
349                month,
350                day
351            );
352        }
353    }
354}