things3_core/database/
mappers.rs

1//! Row mapping utilities for converting database rows to domain models
2//!
3//! This module provides reusable mapping functions to eliminate duplication
4//! in Task construction from SQL query results.
5
6use crate::{
7    database::{safe_timestamp_convert, things_date_to_naive_date, things_uuid_to_uuid},
8    error::Result as ThingsResult,
9    models::{Task, TaskStatus, TaskType},
10};
11use chrono::{DateTime, Utc};
12use sqlx::sqlite::SqliteRow;
13use sqlx::Row;
14use uuid::Uuid;
15
16/// Parse a UUID string with fallback to Things UUID conversion
17///
18/// First attempts to parse as a standard UUID format, then falls back
19/// to the Things 3 UUID conversion if that fails.
20pub fn parse_uuid_with_fallback(uuid_str: &str) -> Uuid {
21    Uuid::parse_str(uuid_str).unwrap_or_else(|_| things_uuid_to_uuid(uuid_str))
22}
23
24/// Parse an optional UUID string with fallback
25///
26/// Handles `Option<String>` from database columns, returning None if the
27/// input is None, otherwise using the fallback UUID parsing logic.
28pub fn parse_optional_uuid(opt_str: Option<String>) -> Option<Uuid> {
29    opt_str.map(|s| {
30        Uuid::parse_str(&s)
31            .ok()
32            .unwrap_or_else(|| things_uuid_to_uuid(&s))
33    })
34}
35
36/// Map a database row to a Task struct
37///
38/// This function centralizes all the logic for constructing a Task from
39/// a SQLite row, including UUID parsing, date conversion, and field mapping.
40///
41/// # Errors
42///
43/// Returns an error if required fields are missing or cannot be converted
44pub fn map_task_row(row: &SqliteRow) -> ThingsResult<Task> {
45    let uuid_str: String = row.get("uuid");
46    let uuid = parse_uuid_with_fallback(&uuid_str);
47
48    let title: String = row.get("title");
49
50    let status_i32: i32 = row.get("status");
51    let status = match status_i32 {
52        1 => TaskStatus::Completed,
53        2 => TaskStatus::Canceled,
54        3 => TaskStatus::Trashed,
55        _ => TaskStatus::Incomplete,
56    };
57
58    let type_i32: i32 = row.get("type");
59    let task_type = match type_i32 {
60        1 => TaskType::Project,
61        2 => TaskType::Heading,
62        _ => TaskType::Todo,
63    };
64
65    let notes: Option<String> = row.get("notes");
66
67    let start_date = row
68        .get::<Option<i64>, _>("startDate")
69        .and_then(things_date_to_naive_date);
70
71    let deadline = row
72        .get::<Option<i64>, _>("deadline")
73        .and_then(things_date_to_naive_date);
74
75    let creation_ts: f64 = row.get("creationDate");
76    let created = {
77        let ts = safe_timestamp_convert(creation_ts);
78        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
79    };
80
81    let modification_ts: f64 = row.get("userModificationDate");
82    let modified = {
83        let ts = safe_timestamp_convert(modification_ts);
84        DateTime::from_timestamp(ts, 0).unwrap_or_else(Utc::now)
85    };
86
87    let stop_date = row.get::<Option<f64>, _>("stopDate").and_then(|ts| {
88        let ts_i64 = safe_timestamp_convert(ts);
89        DateTime::from_timestamp(ts_i64, 0)
90    });
91
92    let project_uuid = row
93        .get::<Option<String>, _>("project")
94        .map(|s| parse_uuid_with_fallback(&s));
95
96    let area_uuid = row
97        .get::<Option<String>, _>("area")
98        .map(|s| parse_uuid_with_fallback(&s));
99
100    let parent_uuid = row
101        .get::<Option<String>, _>("heading")
102        .map(|s| parse_uuid_with_fallback(&s));
103
104    // Try to get cachedTags as binary data and parse it
105    let tags = row
106        .get::<Option<Vec<u8>>, _>("cachedTags")
107        .and_then(|blob| {
108            // Parse the JSON blob into a Vec<String>
109            crate::database::deserialize_tags_from_blob(&blob).ok()
110        })
111        .unwrap_or_default();
112
113    Ok(Task {
114        uuid,
115        title,
116        status,
117        task_type,
118        notes,
119        start_date,
120        deadline,
121        created,
122        modified,
123        stop_date,
124        project_uuid,
125        area_uuid,
126        parent_uuid,
127        tags,
128        children: Vec::new(),
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_parse_uuid_with_fallback_standard() {
138        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
139        let uuid = parse_uuid_with_fallback(uuid_str);
140        assert_eq!(uuid.to_string(), uuid_str);
141    }
142
143    #[test]
144    fn test_parse_uuid_with_fallback_things_format() {
145        // Things 3 uses a different format - should fall back to things_uuid_to_uuid
146        let things_id = "ABC123XYZ";
147        let uuid1 = parse_uuid_with_fallback(things_id);
148        let uuid2 = parse_uuid_with_fallback(things_id);
149        // Should be consistent
150        assert_eq!(uuid1, uuid2);
151    }
152
153    #[test]
154    fn test_parse_optional_uuid_some() {
155        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
156        let result = parse_optional_uuid(Some(uuid_str.to_string()));
157        assert!(result.is_some());
158        assert_eq!(result.unwrap().to_string(), uuid_str);
159    }
160
161    #[test]
162    fn test_parse_optional_uuid_none() {
163        let result = parse_optional_uuid(None);
164        assert!(result.is_none());
165    }
166}