Skip to main content

todoist_api_rs/sync/
response.rs

1//! Sync API response types.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7// Re-export common types that are used by sync API consumers
8pub use crate::models::{Deadline, Due, Duration, DurationUnit, LocationTrigger, ReminderType};
9
10/// Response from the Sync API endpoint.
11///
12/// Contains all requested resources and metadata about the sync operation.
13///
14/// # Examples
15///
16/// ## Check for command errors
17///
18/// ```
19/// use todoist_api_rs::sync::SyncResponse;
20///
21/// let json = r#"{
22///     "sync_token": "new-token",
23///     "full_sync": false,
24///     "sync_status": {
25///         "cmd-1": "ok",
26///         "cmd-2": {"error_code": 15, "error": "Invalid temporary id"}
27///     }
28/// }"#;
29///
30/// let response: SyncResponse = serde_json::from_str(json).unwrap();
31/// assert!(response.has_errors());
32/// let errors = response.errors();
33/// assert_eq!(errors.len(), 1);
34/// ```
35///
36/// ## Look up real IDs from temp IDs
37///
38/// ```
39/// use todoist_api_rs::sync::SyncResponse;
40///
41/// let json = r#"{
42///     "sync_token": "token",
43///     "full_sync": false,
44///     "temp_id_mapping": {
45///         "temp-123": "real-id-456"
46///     }
47/// }"#;
48///
49/// let response: SyncResponse = serde_json::from_str(json).unwrap();
50/// assert_eq!(response.real_id("temp-123"), Some(&"real-id-456".to_string()));
51/// ```
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct SyncResponse {
54    /// New sync token for subsequent incremental syncs.
55    pub sync_token: String,
56
57    /// Whether this was a full sync (true) or incremental (false).
58    #[serde(default)]
59    pub full_sync: bool,
60
61    /// UTC timestamp when the full sync data was generated.
62    /// For large accounts, this may lag behind real-time.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub full_sync_date_utc: Option<String>,
65
66    /// Array of task (item) objects.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub items: Vec<Item>,
69
70    /// Array of project objects.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub projects: Vec<Project>,
73
74    /// Array of personal label objects.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub labels: Vec<Label>,
77
78    /// Array of section objects.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub sections: Vec<Section>,
81
82    /// Array of task comment (note) objects.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub notes: Vec<Note>,
85
86    /// Array of project comment objects.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub project_notes: Vec<ProjectNote>,
89
90    /// Array of reminder objects.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub reminders: Vec<Reminder>,
93
94    /// Array of filter objects.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub filters: Vec<Filter>,
97
98    /// User object.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub user: Option<User>,
101
102    /// Array of collaborator objects for shared projects.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub collaborators: Vec<Collaborator>,
105
106    /// Array of collaborator state objects.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub collaborator_states: Vec<CollaboratorState>,
109
110    /// Command execution results, keyed by command UUID.
111    /// Values are either "ok" or an error object.
112    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
113    pub sync_status: HashMap<String, CommandResult>,
114
115    /// Mapping of temporary IDs to real IDs for created resources.
116    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
117    pub temp_id_mapping: HashMap<String, String>,
118
119    /// Day orders for tasks.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub day_orders: Option<serde_json::Value>,
122
123    /// Live notifications array.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub live_notifications: Vec<serde_json::Value>,
126
127    /// Last read live notification ID.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub live_notifications_last_read_id: Option<String>,
130
131    /// User settings object.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub user_settings: Option<serde_json::Value>,
134
135    /// User plan limits.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub user_plan_limits: Option<serde_json::Value>,
138
139    /// Productivity stats.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub stats: Option<serde_json::Value>,
142
143    /// Completed info for projects/sections.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub completed_info: Vec<serde_json::Value>,
146
147    /// Location-based reminders.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub locations: Vec<serde_json::Value>,
150}
151
152/// Result of a command execution.
153///
154/// # Examples
155///
156/// ```
157/// use todoist_api_rs::sync::CommandResult;
158///
159/// // Success case
160/// let ok: CommandResult = serde_json::from_str(r#""ok""#).unwrap();
161/// assert!(ok.is_ok());
162///
163/// // Error case
164/// let err: CommandResult = serde_json::from_str(
165///     r#"{"error_code": 15, "error": "Invalid id"}"#
166/// ).unwrap();
167/// assert!(!err.is_ok());
168/// assert_eq!(err.error().unwrap().error_code, 15);
169/// ```
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(untagged)]
172pub enum CommandResult {
173    /// Command succeeded.
174    Ok(String),
175    /// Command failed with error details.
176    Error(CommandError),
177}
178
179impl CommandResult {
180    /// Returns true if the command succeeded.
181    pub fn is_ok(&self) -> bool {
182        matches!(self, CommandResult::Ok(s) if s == "ok")
183    }
184
185    /// Returns the error if the command failed.
186    pub fn error(&self) -> Option<&CommandError> {
187        match self {
188            CommandResult::Error(e) => Some(e),
189            _ => None,
190        }
191    }
192}
193
194/// Error details for a failed command.
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct CommandError {
197    /// Error code.
198    pub error_code: i32,
199    /// Error message.
200    pub error: String,
201}
202
203/// A task (called "item" in the Sync API).
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205pub struct Item {
206    /// The ID of the task.
207    pub id: String,
208
209    /// The ID of the user who owns this task.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub user_id: Option<String>,
212
213    /// The ID of the project this task belongs to.
214    pub project_id: String,
215
216    /// The text content of the task.
217    pub content: String,
218
219    /// A description for the task.
220    #[serde(default)]
221    pub description: String,
222
223    /// Task priority (1 = natural, 4 = very urgent).
224    #[serde(default = "default_priority")]
225    pub priority: i32,
226
227    /// Due date information.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub due: Option<Due>,
230
231    /// Deadline information.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub deadline: Option<Deadline>,
234
235    /// Parent task ID for subtasks.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub parent_id: Option<String>,
238
239    /// Order among siblings.
240    #[serde(default)]
241    pub child_order: i32,
242
243    /// Section ID if the task is in a section.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub section_id: Option<String>,
246
247    /// Order in Today/Next 7 days view.
248    #[serde(default)]
249    pub day_order: i32,
250
251    /// Whether subtasks are collapsed.
252    #[serde(default)]
253    pub is_collapsed: bool,
254
255    /// Labels attached to this task (names, not IDs).
256    #[serde(default)]
257    pub labels: Vec<String>,
258
259    /// ID of user who added this task.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub added_by_uid: Option<String>,
262
263    /// ID of user who assigned this task.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub assigned_by_uid: Option<String>,
266
267    /// ID of user responsible for this task.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub responsible_uid: Option<String>,
270
271    /// Whether the task is completed.
272    #[serde(default)]
273    pub checked: bool,
274
275    /// Whether the task is deleted.
276    #[serde(default)]
277    pub is_deleted: bool,
278
279    /// When the task was added.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub added_at: Option<String>,
282
283    /// When the task was last updated.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub updated_at: Option<String>,
286
287    /// When the task was completed.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub completed_at: Option<String>,
290
291    /// Task duration.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub duration: Option<Duration>,
294}
295
296fn default_priority() -> i32 {
297    1
298}
299
300/// A project.
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct Project {
303    /// The ID of the project.
304    pub id: String,
305
306    /// The name of the project.
307    pub name: String,
308
309    /// The color of the project icon.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub color: Option<String>,
312
313    /// Parent project ID.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub parent_id: Option<String>,
316
317    /// Order among siblings.
318    #[serde(default)]
319    pub child_order: i32,
320
321    /// Whether subprojects are collapsed.
322    #[serde(default)]
323    pub is_collapsed: bool,
324
325    /// Whether the project is shared.
326    #[serde(default)]
327    pub shared: bool,
328
329    /// Whether the project can have assigned tasks.
330    #[serde(default)]
331    pub can_assign_tasks: bool,
332
333    /// Whether the project is deleted.
334    #[serde(default)]
335    pub is_deleted: bool,
336
337    /// Whether the project is archived.
338    #[serde(default)]
339    pub is_archived: bool,
340
341    /// Whether the project is a favorite.
342    #[serde(default)]
343    pub is_favorite: bool,
344
345    /// View style: "list", "board", or "calendar".
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub view_style: Option<String>,
348
349    /// Whether this is the inbox project.
350    #[serde(default)]
351    pub inbox_project: bool,
352
353    /// Folder ID (for workspaces).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub folder_id: Option<String>,
356
357    /// When the project was created.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub created_at: Option<String>,
360
361    /// When the project was last updated.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub updated_at: Option<String>,
364}
365
366/// A section within a project.
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
368pub struct Section {
369    /// The ID of the section.
370    pub id: String,
371
372    /// The name of the section.
373    pub name: String,
374
375    /// The project this section belongs to.
376    pub project_id: String,
377
378    /// Order within the project.
379    #[serde(default)]
380    pub section_order: i32,
381
382    /// Whether tasks are collapsed.
383    #[serde(default)]
384    pub is_collapsed: bool,
385
386    /// Whether the section is deleted.
387    #[serde(default)]
388    pub is_deleted: bool,
389
390    /// Whether the section is archived.
391    #[serde(default)]
392    pub is_archived: bool,
393
394    /// When the section was archived.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub archived_at: Option<String>,
397
398    /// When the section was added.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub added_at: Option<String>,
401
402    /// When the section was last updated.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub updated_at: Option<String>,
405}
406
407/// A personal label.
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
409pub struct Label {
410    /// The ID of the label.
411    pub id: String,
412
413    /// The name of the label.
414    pub name: String,
415
416    /// The color of the label icon.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub color: Option<String>,
419
420    /// Order in the label list.
421    #[serde(default)]
422    pub item_order: i32,
423
424    /// Whether the label is deleted.
425    #[serde(default)]
426    pub is_deleted: bool,
427
428    /// Whether the label is a favorite.
429    #[serde(default)]
430    pub is_favorite: bool,
431}
432
433/// A task comment (called "note" in the Sync API).
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
435pub struct Note {
436    /// The ID of the note.
437    pub id: String,
438
439    /// The task this note belongs to.
440    pub item_id: String,
441
442    /// The content of the note.
443    pub content: String,
444
445    /// When the note was posted.
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub posted_at: Option<String>,
448
449    /// Whether the note is deleted.
450    #[serde(default)]
451    pub is_deleted: bool,
452
453    /// ID of the user who posted this note.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub posted_uid: Option<String>,
456
457    /// File attachment.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub file_attachment: Option<FileAttachment>,
460}
461
462/// A project comment.
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct ProjectNote {
465    /// The ID of the note.
466    pub id: String,
467
468    /// The project this note belongs to.
469    pub project_id: String,
470
471    /// The content of the note.
472    pub content: String,
473
474    /// When the note was posted.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub posted_at: Option<String>,
477
478    /// Whether the note is deleted.
479    #[serde(default)]
480    pub is_deleted: bool,
481
482    /// ID of the user who posted this note.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub posted_uid: Option<String>,
485
486    /// File attachment.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub file_attachment: Option<FileAttachment>,
489}
490
491/// File attachment metadata.
492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493pub struct FileAttachment {
494    /// Resource type (always "file").
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub resource_type: Option<String>,
497
498    /// File name.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub file_name: Option<String>,
501
502    /// File size in bytes.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub file_size: Option<i64>,
505
506    /// File type/MIME type.
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub file_type: Option<String>,
509
510    /// URL to download the file.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub file_url: Option<String>,
513
514    /// Upload state.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub upload_state: Option<String>,
517}
518
519/// A reminder.
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct Reminder {
522    /// The ID of the reminder.
523    pub id: String,
524
525    /// The task this reminder is for.
526    pub item_id: String,
527
528    /// Reminder type: relative, absolute, or location.
529    #[serde(rename = "type")]
530    pub reminder_type: ReminderType,
531
532    /// Due information for the reminder.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub due: Option<Due>,
535
536    /// Minutes before due (for relative reminders).
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub minute_offset: Option<i32>,
539
540    /// Whether the reminder is deleted.
541    #[serde(default)]
542    pub is_deleted: bool,
543
544    /// User ID to notify (typically the current user).
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub notify_uid: Option<String>,
547
548    /// Location name (for location reminders).
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub name: Option<String>,
551
552    /// Location latitude (for location reminders).
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub loc_lat: Option<String>,
555
556    /// Location longitude (for location reminders).
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub loc_long: Option<String>,
559
560    /// Location trigger: on_enter or on_leave (for location reminders).
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub loc_trigger: Option<LocationTrigger>,
563
564    /// Radius around the location in meters (for location reminders).
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub radius: Option<i32>,
567}
568
569/// A saved filter.
570#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
571pub struct Filter {
572    /// The ID of the filter.
573    pub id: String,
574
575    /// The name of the filter.
576    pub name: String,
577
578    /// The filter query string.
579    pub query: String,
580
581    /// The color of the filter icon.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub color: Option<String>,
584
585    /// Order in the filter list.
586    #[serde(default)]
587    pub item_order: i32,
588
589    /// Whether the filter is deleted.
590    #[serde(default)]
591    pub is_deleted: bool,
592
593    /// Whether the filter is a favorite.
594    #[serde(default)]
595    pub is_favorite: bool,
596}
597
598/// Timezone information for a user.
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600pub struct TzInfo {
601    /// The timezone name (e.g., "America/New_York").
602    pub timezone: String,
603
604    /// GMT offset string (e.g., "-05:00").
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub gmt_string: Option<String>,
607
608    /// Hours offset from GMT.
609    #[serde(default)]
610    pub hours: i32,
611
612    /// Minutes offset from GMT.
613    #[serde(default)]
614    pub minutes: i32,
615
616    /// Whether daylight saving time is in effect.
617    #[serde(default)]
618    pub is_dst: i32,
619}
620
621/// User information.
622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
623pub struct User {
624    /// The user's ID.
625    pub id: String,
626
627    /// The user's email.
628    #[serde(default, skip_serializing_if = "Option::is_none")]
629    pub email: Option<String>,
630
631    /// The user's full name.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub full_name: Option<String>,
634
635    /// The user's timezone info.
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub tz_info: Option<TzInfo>,
638
639    /// Inbox project ID.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub inbox_project_id: Option<String>,
642
643    /// Start page preference.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub start_page: Option<String>,
646
647    /// Week start day (1 = Monday, 7 = Sunday).
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub start_day: Option<i32>,
650
651    /// Date format preference.
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub date_format: Option<i32>,
654
655    /// Time format preference (12 or 24).
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub time_format: Option<i32>,
658
659    /// Whether user has premium.
660    #[serde(default)]
661    pub is_premium: bool,
662}
663
664impl User {
665    /// Returns the user's timezone string (e.g., "America/New_York").
666    pub fn timezone(&self) -> Option<&str> {
667        self.tz_info.as_ref().map(|tz| tz.timezone.as_str())
668    }
669}
670
671/// A collaborator on a shared project.
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673pub struct Collaborator {
674    /// The collaborator's user ID.
675    pub id: String,
676
677    /// The collaborator's email.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub email: Option<String>,
680
681    /// The collaborator's full name.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub full_name: Option<String>,
684
685    /// The collaborator's timezone.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub timezone: Option<String>,
688
689    /// URL to the collaborator's avatar.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub image_id: Option<String>,
692}
693
694/// State of a collaborator in a project.
695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696pub struct CollaboratorState {
697    /// Project ID.
698    pub project_id: String,
699
700    /// User ID of the collaborator.
701    pub user_id: String,
702
703    /// State: "invited", "active", "inactive", or "deleted".
704    pub state: String,
705}
706
707impl SyncResponse {
708    /// Returns true if any commands failed.
709    pub fn has_errors(&self) -> bool {
710        self.sync_status.values().any(|r| !r.is_ok())
711    }
712
713    /// Returns all command errors.
714    pub fn errors(&self) -> Vec<(&String, &CommandError)> {
715        self.sync_status
716            .iter()
717            .filter_map(|(uuid, result)| result.error().map(|e| (uuid, e)))
718            .collect()
719    }
720
721    /// Looks up the real ID for a temporary ID.
722    pub fn real_id(&self, temp_id: &str) -> Option<&String> {
723        self.temp_id_mapping.get(temp_id)
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn test_sync_response_deserialize_minimal() {
733        let json = r#"{
734            "sync_token": "abc123",
735            "full_sync": true
736        }"#;
737
738        let response: SyncResponse = serde_json::from_str(json).unwrap();
739        assert_eq!(response.sync_token, "abc123");
740        assert!(response.full_sync);
741        assert!(response.items.is_empty());
742        assert!(response.projects.is_empty());
743    }
744
745    #[test]
746    fn test_sync_response_deserialize_with_items() {
747        let json = r#"{
748            "sync_token": "token123",
749            "full_sync": false,
750            "items": [
751                {
752                    "id": "item-1",
753                    "project_id": "proj-1",
754                    "content": "Buy milk",
755                    "description": "",
756                    "priority": 1,
757                    "checked": false,
758                    "is_deleted": false
759                }
760            ]
761        }"#;
762
763        let response: SyncResponse = serde_json::from_str(json).unwrap();
764        assert_eq!(response.items.len(), 1);
765        assert_eq!(response.items[0].content, "Buy milk");
766    }
767
768    #[test]
769    fn test_sync_response_deserialize_with_projects() {
770        let json = r#"{
771            "sync_token": "token",
772            "full_sync": true,
773            "projects": [
774                {
775                    "id": "proj-1",
776                    "name": "Work",
777                    "color": "blue",
778                    "is_deleted": false,
779                    "is_archived": false,
780                    "is_favorite": true
781                }
782            ]
783        }"#;
784
785        let response: SyncResponse = serde_json::from_str(json).unwrap();
786        assert_eq!(response.projects.len(), 1);
787        assert_eq!(response.projects[0].name, "Work");
788        assert!(response.projects[0].is_favorite);
789    }
790
791    #[test]
792    fn test_sync_response_deserialize_with_sync_status() {
793        let json = r#"{
794            "sync_token": "token",
795            "full_sync": false,
796            "sync_status": {
797                "cmd-1": "ok",
798                "cmd-2": {"error_code": 15, "error": "Invalid temporary id"}
799            }
800        }"#;
801
802        let response: SyncResponse = serde_json::from_str(json).unwrap();
803        assert!(response.sync_status.get("cmd-1").unwrap().is_ok());
804        assert!(!response.sync_status.get("cmd-2").unwrap().is_ok());
805
806        let error = response.sync_status.get("cmd-2").unwrap().error().unwrap();
807        assert_eq!(error.error_code, 15);
808        assert_eq!(error.error, "Invalid temporary id");
809    }
810
811    #[test]
812    fn test_sync_response_deserialize_with_temp_id_mapping() {
813        let json = r#"{
814            "sync_token": "token",
815            "full_sync": false,
816            "temp_id_mapping": {
817                "temp-1": "real-id-1",
818                "temp-2": "real-id-2"
819            }
820        }"#;
821
822        let response: SyncResponse = serde_json::from_str(json).unwrap();
823        assert_eq!(response.real_id("temp-1"), Some(&"real-id-1".to_string()));
824        assert_eq!(response.real_id("temp-2"), Some(&"real-id-2".to_string()));
825        assert_eq!(response.real_id("unknown"), None);
826    }
827
828    #[test]
829    fn test_sync_response_has_errors() {
830        let json = r#"{
831            "sync_token": "token",
832            "full_sync": false,
833            "sync_status": {
834                "cmd-1": "ok",
835                "cmd-2": {"error_code": 15, "error": "Error"}
836            }
837        }"#;
838
839        let response: SyncResponse = serde_json::from_str(json).unwrap();
840        assert!(response.has_errors());
841
842        let errors = response.errors();
843        assert_eq!(errors.len(), 1);
844        assert_eq!(errors[0].0, "cmd-2");
845    }
846
847    #[test]
848    fn test_sync_response_no_errors() {
849        let json = r#"{
850            "sync_token": "token",
851            "full_sync": false,
852            "sync_status": {
853                "cmd-1": "ok",
854                "cmd-2": "ok"
855            }
856        }"#;
857
858        let response: SyncResponse = serde_json::from_str(json).unwrap();
859        assert!(!response.has_errors());
860        assert!(response.errors().is_empty());
861    }
862
863    #[test]
864    fn test_item_deserialize_full() {
865        let json = r#"{
866            "id": "6X7rM8997g3RQmvh",
867            "user_id": "2671355",
868            "project_id": "6Jf8VQXxpwv56VQ7",
869            "content": "Buy Milk",
870            "description": "From the store",
871            "priority": 4,
872            "due": {
873                "date": "2025-01-21",
874                "datetime": "2025-01-21T10:00:00Z",
875                "string": "tomorrow at 10am",
876                "timezone": "America/New_York",
877                "is_recurring": false
878            },
879            "parent_id": null,
880            "child_order": 1,
881            "section_id": "3Ty8VQXxpwv28PK3",
882            "day_order": -1,
883            "is_collapsed": false,
884            "labels": ["Food", "Shopping"],
885            "checked": false,
886            "is_deleted": false,
887            "added_at": "2025-01-21T21:28:43.841504Z",
888            "duration": {"amount": 15, "unit": "minute"}
889        }"#;
890
891        let item: Item = serde_json::from_str(json).unwrap();
892        assert_eq!(item.id, "6X7rM8997g3RQmvh");
893        assert_eq!(item.content, "Buy Milk");
894        assert_eq!(item.description, "From the store");
895        assert_eq!(item.priority, 4);
896        assert!(item.due.is_some());
897        assert_eq!(item.labels, vec!["Food", "Shopping"]);
898
899        let due = item.due.unwrap();
900        assert_eq!(due.date, "2025-01-21");
901        assert_eq!(due.datetime, Some("2025-01-21T10:00:00Z".to_string()));
902
903        let duration = item.duration.unwrap();
904        assert_eq!(duration.amount, 15);
905        assert_eq!(duration.unit, DurationUnit::Minute);
906    }
907
908    #[test]
909    fn test_project_deserialize() {
910        let json = r#"{
911            "id": "6Jf8VQXxpwv56VQ7",
912            "name": "Shopping List",
913            "color": "lime_green",
914            "parent_id": null,
915            "child_order": 1,
916            "is_collapsed": false,
917            "shared": false,
918            "can_assign_tasks": false,
919            "is_deleted": false,
920            "is_archived": false,
921            "is_favorite": false,
922            "view_style": "list",
923            "inbox_project": true
924        }"#;
925
926        let project: Project = serde_json::from_str(json).unwrap();
927        assert_eq!(project.id, "6Jf8VQXxpwv56VQ7");
928        assert_eq!(project.name, "Shopping List");
929        assert_eq!(project.color, Some("lime_green".to_string()));
930        assert!(project.inbox_project);
931        assert!(!project.is_favorite);
932    }
933
934    #[test]
935    fn test_section_deserialize() {
936        let json = r#"{
937            "id": "6Jf8VQXxpwv56VQ7",
938            "name": "Groceries",
939            "project_id": "9Bw8VQXxpwv56ZY2",
940            "section_order": 1,
941            "is_collapsed": false,
942            "is_deleted": false,
943            "is_archived": false
944        }"#;
945
946        let section: Section = serde_json::from_str(json).unwrap();
947        assert_eq!(section.id, "6Jf8VQXxpwv56VQ7");
948        assert_eq!(section.name, "Groceries");
949        assert_eq!(section.project_id, "9Bw8VQXxpwv56ZY2");
950    }
951
952    #[test]
953    fn test_label_deserialize() {
954        let json = r#"{
955            "id": "2156154810",
956            "name": "Food",
957            "color": "lime_green",
958            "item_order": 0,
959            "is_deleted": false,
960            "is_favorite": false
961        }"#;
962
963        let label: Label = serde_json::from_str(json).unwrap();
964        assert_eq!(label.id, "2156154810");
965        assert_eq!(label.name, "Food");
966        assert_eq!(label.color, Some("lime_green".to_string()));
967    }
968
969    #[test]
970    fn test_filter_deserialize() {
971        let json = r#"{
972            "id": "filter-1",
973            "name": "Today's Tasks",
974            "query": "today | overdue",
975            "color": "red",
976            "item_order": 0,
977            "is_deleted": false,
978            "is_favorite": true
979        }"#;
980
981        let filter: Filter = serde_json::from_str(json).unwrap();
982        assert_eq!(filter.id, "filter-1");
983        assert_eq!(filter.name, "Today's Tasks");
984        assert_eq!(filter.query, "today | overdue");
985        assert!(filter.is_favorite);
986    }
987
988    #[test]
989    fn test_reminder_deserialize_relative() {
990        let json = r#"{
991            "id": "reminder-1",
992            "item_id": "item-1",
993            "type": "relative",
994            "minute_offset": 30,
995            "is_deleted": false
996        }"#;
997
998        let reminder: Reminder = serde_json::from_str(json).unwrap();
999        assert_eq!(reminder.id, "reminder-1");
1000        assert_eq!(reminder.item_id, "item-1");
1001        assert_eq!(reminder.reminder_type, ReminderType::Relative);
1002        assert_eq!(reminder.minute_offset, Some(30));
1003    }
1004
1005    #[test]
1006    fn test_reminder_deserialize_absolute() {
1007        let json = r#"{
1008            "id": "reminder-2",
1009            "item_id": "item-1",
1010            "type": "absolute",
1011            "due": {
1012                "date": "2025-01-26",
1013                "datetime": "2025-01-26T10:00:00Z"
1014            },
1015            "is_deleted": false
1016        }"#;
1017
1018        let reminder: Reminder = serde_json::from_str(json).unwrap();
1019        assert_eq!(reminder.id, "reminder-2");
1020        assert_eq!(reminder.reminder_type, ReminderType::Absolute);
1021        assert!(reminder.due.is_some());
1022    }
1023
1024    #[test]
1025    fn test_reminder_deserialize_location() {
1026        let json = r#"{
1027            "id": "reminder-3",
1028            "item_id": "item-1",
1029            "type": "location",
1030            "name": "Home",
1031            "loc_lat": "37.7749",
1032            "loc_long": "-122.4194",
1033            "loc_trigger": "on_enter",
1034            "radius": 100,
1035            "is_deleted": false
1036        }"#;
1037
1038        let reminder: Reminder = serde_json::from_str(json).unwrap();
1039        assert_eq!(reminder.id, "reminder-3");
1040        assert_eq!(reminder.reminder_type, ReminderType::Location);
1041        assert_eq!(reminder.name, Some("Home".to_string()));
1042        assert_eq!(reminder.loc_lat, Some("37.7749".to_string()));
1043        assert_eq!(reminder.loc_long, Some("-122.4194".to_string()));
1044        assert_eq!(reminder.loc_trigger, Some(LocationTrigger::OnEnter));
1045        assert_eq!(reminder.radius, Some(100));
1046    }
1047
1048    #[test]
1049    fn test_note_deserialize() {
1050        let json = r#"{
1051            "id": "note-1",
1052            "item_id": "item-1",
1053            "content": "Remember to check expiration dates",
1054            "posted_at": "2025-01-21T10:00:00Z",
1055            "is_deleted": false
1056        }"#;
1057
1058        let note: Note = serde_json::from_str(json).unwrap();
1059        assert_eq!(note.id, "note-1");
1060        assert_eq!(note.item_id, "item-1");
1061        assert_eq!(note.content, "Remember to check expiration dates");
1062    }
1063
1064    #[test]
1065    fn test_user_deserialize() {
1066        let json = r#"{
1067            "id": "user-1",
1068            "email": "test@example.com",
1069            "full_name": "Test User",
1070            "timezone": "America/New_York",
1071            "inbox_project_id": "inbox-123",
1072            "is_premium": true
1073        }"#;
1074
1075        let user: User = serde_json::from_str(json).unwrap();
1076        assert_eq!(user.id, "user-1");
1077        assert_eq!(user.email, Some("test@example.com".to_string()));
1078        assert_eq!(user.full_name, Some("Test User".to_string()));
1079        assert!(user.is_premium);
1080    }
1081
1082    #[test]
1083    fn test_command_result_ok() {
1084        let result: CommandResult = serde_json::from_str(r#""ok""#).unwrap();
1085        assert!(result.is_ok());
1086        assert!(result.error().is_none());
1087    }
1088
1089    #[test]
1090    fn test_command_result_error() {
1091        let result: CommandResult =
1092            serde_json::from_str(r#"{"error_code": 15, "error": "Invalid temporary id"}"#).unwrap();
1093        assert!(!result.is_ok());
1094        let error = result.error().unwrap();
1095        assert_eq!(error.error_code, 15);
1096        assert_eq!(error.error, "Invalid temporary id");
1097    }
1098}