Skip to main content

todoist_api_rs/sync/
request.rs

1//! Sync API request types.
2
3use serde::{Deserialize, Serialize};
4
5/// Valid command types for the Todoist Sync API.
6///
7/// This enum provides type-safe command types that serialize to the snake_case
8/// format expected by the API (e.g., `ItemAdd` → `"item_add"`).
9///
10/// See: <https://developer.todoist.com/sync/v9/#sync-commands>
11///
12/// # Examples
13///
14/// ```
15/// use todoist_api_rs::sync::SyncCommandType;
16///
17/// let cmd_type = SyncCommandType::ItemAdd;
18/// let json = serde_json::to_string(&cmd_type).unwrap();
19/// assert_eq!(json, "\"item_add\"");
20/// ```
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SyncCommandType {
24    // Item commands
25    /// Add a new task/item
26    ItemAdd,
27    /// Update an existing task/item
28    ItemUpdate,
29    /// Move a task to a different project or section
30    ItemMove,
31    /// Delete a task/item
32    ItemDelete,
33    /// Complete/close a task
34    ItemClose,
35    /// Complete a task with a specific completion timestamp
36    ItemComplete,
37    /// Reopen a completed task
38    ItemUncomplete,
39    /// Archive a task
40    ItemArchive,
41    /// Unarchive a task
42    ItemUnarchive,
43    /// Reorder tasks within a project/section
44    ItemReorder,
45    /// Update day orders for tasks
46    ItemUpdateDayOrders,
47    /// Update the completion date of a task
48    ItemUpdateDateCompleted,
49
50    // Project commands
51    /// Add a new project
52    ProjectAdd,
53    /// Update an existing project
54    ProjectUpdate,
55    /// Move a project (change parent)
56    ProjectMove,
57    /// Delete a project
58    ProjectDelete,
59    /// Archive a project
60    ProjectArchive,
61    /// Unarchive a project
62    ProjectUnarchive,
63    /// Reorder projects
64    ProjectReorder,
65
66    // Section commands
67    /// Add a new section
68    SectionAdd,
69    /// Update an existing section
70    SectionUpdate,
71    /// Move a section to a different project
72    SectionMove,
73    /// Delete a section
74    SectionDelete,
75    /// Archive a section
76    SectionArchive,
77    /// Unarchive a section
78    SectionUnarchive,
79    /// Reorder sections within a project
80    SectionReorder,
81
82    // Label commands
83    /// Add a new label
84    LabelAdd,
85    /// Update an existing label
86    LabelUpdate,
87    /// Delete a label
88    LabelDelete,
89    /// Update label ordering
90    LabelUpdateOrders,
91
92    // Note/Comment commands
93    /// Add a note/comment to a task
94    NoteAdd,
95    /// Update an existing note/comment
96    NoteUpdate,
97    /// Delete a note/comment
98    NoteDelete,
99
100    // Project Note commands
101    /// Add a note to a project
102    ProjectNoteAdd,
103    /// Update an existing project note
104    ProjectNoteUpdate,
105    /// Delete a project note
106    ProjectNoteDelete,
107
108    // Reminder commands
109    /// Add a reminder to a task
110    ReminderAdd,
111    /// Update an existing reminder
112    ReminderUpdate,
113    /// Delete a reminder
114    ReminderDelete,
115
116    // Filter commands
117    /// Add a custom filter
118    FilterAdd,
119    /// Update an existing filter
120    FilterUpdate,
121    /// Delete a filter
122    FilterDelete,
123    /// Update filter ordering
124    FilterUpdateOrders,
125}
126
127/// Request body for the Sync API endpoint.
128///
129/// The Sync API uses `application/x-www-form-urlencoded` format, where
130/// `resource_types` and `commands` are JSON-encoded strings.
131///
132/// # Examples
133///
134/// ## Full sync to fetch all data
135///
136/// ```
137/// use todoist_api_rs::sync::SyncRequest;
138///
139/// let request = SyncRequest::full_sync();
140/// assert_eq!(request.sync_token, "*");
141/// assert_eq!(request.resource_types, vec!["all"]);
142/// ```
143///
144/// ## Incremental sync with stored token
145///
146/// ```
147/// use todoist_api_rs::sync::SyncRequest;
148///
149/// let request = SyncRequest::incremental("abc123token");
150/// assert_eq!(request.sync_token, "abc123token");
151/// ```
152///
153/// ## Fetch specific resource types
154///
155/// ```
156/// use todoist_api_rs::sync::SyncRequest;
157///
158/// let request = SyncRequest::full_sync()
159///     .with_resource_types(vec!["items".to_string(), "projects".to_string()]);
160/// assert_eq!(request.resource_types, vec!["items", "projects"]);
161/// ```
162#[derive(Debug, Clone, PartialEq)]
163pub struct SyncRequest {
164    /// Sync token for incremental sync. Use "*" for a full sync.
165    pub sync_token: String,
166
167    /// Resource types to fetch. Use `["all"]` for all resources.
168    pub resource_types: Vec<String>,
169
170    /// Commands to execute (for write operations).
171    pub commands: Vec<SyncCommand>,
172}
173
174/// Internal struct for form encoding.
175/// The Sync API expects resource_types and commands as JSON-encoded strings.
176#[derive(Serialize)]
177struct SyncRequestForm<'a> {
178    sync_token: &'a str,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    resource_types: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    commands: Option<String>,
183}
184
185impl SyncRequest {
186    /// Creates a new SyncRequest for a full sync of all resources.
187    pub fn full_sync() -> Self {
188        Self {
189            sync_token: "*".to_string(),
190            resource_types: vec!["all".to_string()],
191            commands: Vec::new(),
192        }
193    }
194
195    /// Creates a new SyncRequest for an incremental sync.
196    pub fn incremental(sync_token: impl Into<String>) -> Self {
197        Self {
198            sync_token: sync_token.into(),
199            resource_types: vec!["all".to_string()],
200            commands: Vec::new(),
201        }
202    }
203
204    /// Creates a new SyncRequest with only commands (for write operations).
205    pub fn with_commands(commands: Vec<SyncCommand>) -> Self {
206        Self {
207            sync_token: "*".to_string(),
208            resource_types: Vec::new(),
209            commands,
210        }
211    }
212
213    /// Creates a new SyncRequest with pre-allocated command capacity.
214    ///
215    /// Use this when you know ahead of time how many commands will be added,
216    /// to avoid reallocations during batch operations.
217    ///
218    /// # Examples
219    ///
220    /// ```
221    /// use todoist_api_rs::sync::SyncRequest;
222    ///
223    /// // Pre-allocate for a batch of 100 commands
224    /// let mut request = SyncRequest::with_commands_capacity(100);
225    /// assert!(request.commands.capacity() >= 100);
226    /// ```
227    pub fn with_commands_capacity(capacity: usize) -> Self {
228        Self {
229            sync_token: "*".to_string(),
230            resource_types: Vec::new(),
231            commands: Vec::with_capacity(capacity),
232        }
233    }
234
235    /// Sets specific resource types to fetch.
236    pub fn with_resource_types(mut self, types: Vec<String>) -> Self {
237        self.resource_types = types;
238        self
239    }
240
241    /// Adds commands to the request.
242    pub fn add_commands(mut self, commands: Vec<SyncCommand>) -> Self {
243        self.commands.extend(commands);
244        self
245    }
246
247    /// Serializes the request to form-urlencoded format.
248    ///
249    /// The Sync API expects:
250    /// - `sync_token`: string
251    /// - `resource_types`: JSON-encoded array of strings
252    /// - `commands`: JSON-encoded array of command objects (if any)
253    pub fn to_form_body(&self) -> String {
254        let form = SyncRequestForm {
255            sync_token: &self.sync_token,
256            resource_types: if self.resource_types.is_empty() {
257                None
258            } else {
259                Some(
260                    serde_json::to_string(&self.resource_types)
261                        .expect("resource_types serialization should not fail"),
262                )
263            },
264            commands: if self.commands.is_empty() {
265                None
266            } else {
267                Some(
268                    serde_json::to_string(&self.commands)
269                        .expect("commands serialization should not fail"),
270                )
271            },
272        };
273
274        serde_urlencoded::to_string(&form).expect("form serialization should not fail")
275    }
276}
277
278/// A command to execute via the Sync API.
279///
280/// Commands are write operations that modify resources in Todoist.
281/// Each command has a UUID for idempotency and optional temp_id for
282/// creating resources that can be referenced by other commands.
283///
284/// # Examples
285///
286/// ## Create a simple command using type-safe builder
287///
288/// ```
289/// use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
290/// use serde_json::json;
291///
292/// let cmd = SyncCommand::new(SyncCommandType::ItemClose, json!({"id": "task-123"}));
293/// assert_eq!(cmd.command_type, SyncCommandType::ItemClose);
294/// assert!(cmd.temp_id.is_none());
295/// ```
296///
297/// ## Use convenience builders for common operations
298///
299/// ```
300/// use todoist_api_rs::sync::SyncCommand;
301///
302/// let cmd = SyncCommand::item_close("task-123");
303/// assert!(cmd.args["id"].as_str() == Some("task-123"));
304/// ```
305///
306/// ## Create a command with temp_id for new resources
307///
308/// ```
309/// use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
310/// use serde_json::json;
311///
312/// // When creating a new item, use temp_id so you can reference it in subsequent commands
313/// let cmd = SyncCommand::with_temp_id(
314///     SyncCommandType::ItemAdd,
315///     "temp-task-1",
316///     json!({"content": "Buy groceries", "project_id": "inbox"})
317/// );
318/// assert_eq!(cmd.temp_id, Some("temp-task-1".to_string()));
319/// ```
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct SyncCommand {
322    /// The type of command (e.g., ItemAdd, ProjectUpdate).
323    #[serde(rename = "type")]
324    pub command_type: SyncCommandType,
325
326    /// Unique identifier for this command (for idempotency).
327    pub uuid: String,
328
329    /// Temporary ID for newly created resources.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub temp_id: Option<String>,
332
333    /// Command-specific arguments.
334    pub args: serde_json::Value,
335}
336
337impl SyncCommand {
338    /// Creates a new command with a generated UUID.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
344    /// use serde_json::json;
345    ///
346    /// let cmd = SyncCommand::new(SyncCommandType::ItemClose, json!({"id": "task-123"}));
347    /// assert_eq!(cmd.command_type, SyncCommandType::ItemClose);
348    /// ```
349    pub fn new(command_type: SyncCommandType, args: serde_json::Value) -> Self {
350        Self {
351            command_type,
352            uuid: uuid::Uuid::new_v4().to_string(),
353            temp_id: None,
354            args,
355        }
356    }
357
358    /// Creates a new command with a temp_id for resource creation.
359    ///
360    /// Use this when creating new resources that need to be referenced by
361    /// subsequent commands in the same batch.
362    ///
363    /// # Examples
364    ///
365    /// ```
366    /// use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
367    /// use serde_json::json;
368    ///
369    /// let cmd = SyncCommand::with_temp_id(
370    ///     SyncCommandType::ItemAdd,
371    ///     "temp-123",
372    ///     json!({"content": "Buy groceries"})
373    /// );
374    /// assert_eq!(cmd.temp_id, Some("temp-123".to_string()));
375    /// ```
376    pub fn with_temp_id(
377        command_type: SyncCommandType,
378        temp_id: impl Into<String>,
379        args: serde_json::Value,
380    ) -> Self {
381        Self {
382            command_type,
383            uuid: uuid::Uuid::new_v4().to_string(),
384            temp_id: Some(temp_id.into()),
385            args,
386        }
387    }
388
389    /// Creates a new command with explicit UUID and temp_id.
390    ///
391    /// Use this when you need deterministic UUIDs for testing or idempotency.
392    pub fn with_uuid_and_temp_id(
393        command_type: SyncCommandType,
394        uuid: impl Into<String>,
395        temp_id: impl Into<String>,
396        args: serde_json::Value,
397    ) -> Self {
398        Self {
399            command_type,
400            uuid: uuid.into(),
401            temp_id: Some(temp_id.into()),
402            args,
403        }
404    }
405
406    // =========================================================================
407    // Item command builders
408    // =========================================================================
409
410    /// Creates an item_close command to complete a task.
411    ///
412    /// # Examples
413    ///
414    /// ```
415    /// use todoist_api_rs::sync::SyncCommand;
416    ///
417    /// let cmd = SyncCommand::item_close("task-123");
418    /// assert_eq!(cmd.args["id"], "task-123");
419    /// ```
420    pub fn item_close(id: impl Into<String>) -> Self {
421        Self::new(
422            SyncCommandType::ItemClose,
423            serde_json::json!({ "id": id.into() }),
424        )
425    }
426
427    /// Creates an item_uncomplete command to reopen a task.
428    ///
429    /// # Examples
430    ///
431    /// ```
432    /// use todoist_api_rs::sync::SyncCommand;
433    ///
434    /// let cmd = SyncCommand::item_uncomplete("task-123");
435    /// assert_eq!(cmd.args["id"], "task-123");
436    /// ```
437    pub fn item_uncomplete(id: impl Into<String>) -> Self {
438        Self::new(
439            SyncCommandType::ItemUncomplete,
440            serde_json::json!({ "id": id.into() }),
441        )
442    }
443
444    /// Creates an item_delete command to delete a task.
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// use todoist_api_rs::sync::SyncCommand;
450    ///
451    /// let cmd = SyncCommand::item_delete("task-123");
452    /// assert_eq!(cmd.args["id"], "task-123");
453    /// ```
454    pub fn item_delete(id: impl Into<String>) -> Self {
455        Self::new(
456            SyncCommandType::ItemDelete,
457            serde_json::json!({ "id": id.into() }),
458        )
459    }
460
461    // =========================================================================
462    // Project command builders
463    // =========================================================================
464
465    /// Creates a project_delete command to delete a project.
466    ///
467    /// # Examples
468    ///
469    /// ```
470    /// use todoist_api_rs::sync::SyncCommand;
471    ///
472    /// let cmd = SyncCommand::project_delete("proj-123");
473    /// assert_eq!(cmd.args["id"], "proj-123");
474    /// ```
475    pub fn project_delete(id: impl Into<String>) -> Self {
476        Self::new(
477            SyncCommandType::ProjectDelete,
478            serde_json::json!({ "id": id.into() }),
479        )
480    }
481
482    /// Creates a project_archive command to archive a project.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use todoist_api_rs::sync::SyncCommand;
488    ///
489    /// let cmd = SyncCommand::project_archive("proj-123");
490    /// assert_eq!(cmd.args["id"], "proj-123");
491    /// ```
492    pub fn project_archive(id: impl Into<String>) -> Self {
493        Self::new(
494            SyncCommandType::ProjectArchive,
495            serde_json::json!({ "id": id.into() }),
496        )
497    }
498
499    /// Creates a project_unarchive command to unarchive a project.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// use todoist_api_rs::sync::SyncCommand;
505    ///
506    /// let cmd = SyncCommand::project_unarchive("proj-123");
507    /// assert_eq!(cmd.args["id"], "proj-123");
508    /// ```
509    pub fn project_unarchive(id: impl Into<String>) -> Self {
510        Self::new(
511            SyncCommandType::ProjectUnarchive,
512            serde_json::json!({ "id": id.into() }),
513        )
514    }
515
516    // =========================================================================
517    // Section command builders
518    // =========================================================================
519
520    /// Creates a section_delete command to delete a section.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// use todoist_api_rs::sync::SyncCommand;
526    ///
527    /// let cmd = SyncCommand::section_delete("section-123");
528    /// assert_eq!(cmd.args["id"], "section-123");
529    /// ```
530    pub fn section_delete(id: impl Into<String>) -> Self {
531        Self::new(
532            SyncCommandType::SectionDelete,
533            serde_json::json!({ "id": id.into() }),
534        )
535    }
536
537    /// Creates a section_archive command to archive a section.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use todoist_api_rs::sync::SyncCommand;
543    ///
544    /// let cmd = SyncCommand::section_archive("section-123");
545    /// assert_eq!(cmd.args["id"], "section-123");
546    /// ```
547    pub fn section_archive(id: impl Into<String>) -> Self {
548        Self::new(
549            SyncCommandType::SectionArchive,
550            serde_json::json!({ "id": id.into() }),
551        )
552    }
553
554    /// Creates a section_unarchive command to unarchive a section.
555    ///
556    /// # Examples
557    ///
558    /// ```
559    /// use todoist_api_rs::sync::SyncCommand;
560    ///
561    /// let cmd = SyncCommand::section_unarchive("section-123");
562    /// assert_eq!(cmd.args["id"], "section-123");
563    /// ```
564    pub fn section_unarchive(id: impl Into<String>) -> Self {
565        Self::new(
566            SyncCommandType::SectionUnarchive,
567            serde_json::json!({ "id": id.into() }),
568        )
569    }
570
571    // =========================================================================
572    // Label command builders
573    // =========================================================================
574
575    /// Creates a label_delete command to delete a label.
576    ///
577    /// # Examples
578    ///
579    /// ```
580    /// use todoist_api_rs::sync::SyncCommand;
581    ///
582    /// let cmd = SyncCommand::label_delete("label-123");
583    /// assert_eq!(cmd.args["id"], "label-123");
584    /// ```
585    pub fn label_delete(id: impl Into<String>) -> Self {
586        Self::new(
587            SyncCommandType::LabelDelete,
588            serde_json::json!({ "id": id.into() }),
589        )
590    }
591
592    // =========================================================================
593    // Note/Comment command builders
594    // =========================================================================
595
596    /// Creates a note_delete command to delete a task comment.
597    ///
598    /// # Examples
599    ///
600    /// ```
601    /// use todoist_api_rs::sync::SyncCommand;
602    ///
603    /// let cmd = SyncCommand::note_delete("note-123");
604    /// assert_eq!(cmd.args["id"], "note-123");
605    /// ```
606    pub fn note_delete(id: impl Into<String>) -> Self {
607        Self::new(
608            SyncCommandType::NoteDelete,
609            serde_json::json!({ "id": id.into() }),
610        )
611    }
612
613    /// Creates a project_note_delete command to delete a project comment.
614    ///
615    /// # Examples
616    ///
617    /// ```
618    /// use todoist_api_rs::sync::SyncCommand;
619    ///
620    /// let cmd = SyncCommand::project_note_delete("note-123");
621    /// assert_eq!(cmd.args["id"], "note-123");
622    /// ```
623    pub fn project_note_delete(id: impl Into<String>) -> Self {
624        Self::new(
625            SyncCommandType::ProjectNoteDelete,
626            serde_json::json!({ "id": id.into() }),
627        )
628    }
629
630    // =========================================================================
631    // Reminder command builders
632    // =========================================================================
633
634    /// Creates a reminder_delete command to delete a reminder.
635    ///
636    /// # Examples
637    ///
638    /// ```
639    /// use todoist_api_rs::sync::SyncCommand;
640    ///
641    /// let cmd = SyncCommand::reminder_delete("reminder-123");
642    /// assert_eq!(cmd.args["id"], "reminder-123");
643    /// ```
644    pub fn reminder_delete(id: impl Into<String>) -> Self {
645        Self::new(
646            SyncCommandType::ReminderDelete,
647            serde_json::json!({ "id": id.into() }),
648        )
649    }
650
651    // =========================================================================
652    // Filter command builders
653    // =========================================================================
654
655    /// Creates a filter_delete command to delete a filter.
656    ///
657    /// # Examples
658    ///
659    /// ```
660    /// use todoist_api_rs::sync::SyncCommand;
661    ///
662    /// let cmd = SyncCommand::filter_delete("filter-123");
663    /// assert_eq!(cmd.args["id"], "filter-123");
664    /// ```
665    pub fn filter_delete(id: impl Into<String>) -> Self {
666        Self::new(
667            SyncCommandType::FilterDelete,
668            serde_json::json!({ "id": id.into() }),
669        )
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn test_sync_request_full_sync() {
679        let request = SyncRequest::full_sync();
680        assert_eq!(request.sync_token, "*");
681        assert_eq!(request.resource_types, vec!["all"]);
682        assert!(request.commands.is_empty());
683    }
684
685    #[test]
686    fn test_sync_request_incremental() {
687        let request = SyncRequest::incremental("abc123token");
688        assert_eq!(request.sync_token, "abc123token");
689        assert_eq!(request.resource_types, vec!["all"]);
690    }
691
692    #[test]
693    fn test_sync_request_with_commands() {
694        let cmd = SyncCommand::new(
695            SyncCommandType::ItemAdd,
696            serde_json::json!({"content": "Test"}),
697        );
698        let request = SyncRequest::with_commands(vec![cmd]);
699        assert_eq!(request.commands.len(), 1);
700        assert!(request.resource_types.is_empty());
701    }
702
703    #[test]
704    fn test_sync_request_with_resource_types() {
705        let request = SyncRequest::full_sync()
706            .with_resource_types(vec!["items".to_string(), "projects".to_string()]);
707        assert_eq!(request.resource_types, vec!["items", "projects"]);
708    }
709
710    #[test]
711    fn test_sync_request_to_form_body_full_sync() {
712        let request = SyncRequest::full_sync();
713        let body = request.to_form_body();
714
715        // Decode and verify the fields are correctly encoded
716        let decoded: std::collections::HashMap<String, String> =
717            serde_urlencoded::from_str(&body).unwrap();
718        assert_eq!(decoded.get("sync_token").unwrap(), "*");
719        let resource_types: Vec<String> =
720            serde_json::from_str(decoded.get("resource_types").unwrap()).unwrap();
721        assert_eq!(resource_types, vec!["all"]);
722    }
723
724    #[test]
725    fn test_sync_request_to_form_body_with_token() {
726        let request = SyncRequest::incremental("mytoken123");
727        let body = request.to_form_body();
728
729        assert!(body.contains("sync_token=mytoken123"));
730    }
731
732    #[test]
733    fn test_sync_request_to_form_body_with_commands() {
734        let cmd = SyncCommand {
735            command_type: SyncCommandType::ItemAdd,
736            uuid: "test-uuid".to_string(),
737            temp_id: Some("temp-123".to_string()),
738            args: serde_json::json!({"content": "Buy milk"}),
739        };
740        let request = SyncRequest::with_commands(vec![cmd]);
741        let body = request.to_form_body();
742
743        // Verify commands are included and properly encoded
744        assert!(body.contains("commands="));
745        // Decode the form body and check the commands field
746        let decoded: std::collections::HashMap<String, String> =
747            serde_urlencoded::from_str(&body).unwrap();
748        let commands_json = decoded.get("commands").unwrap();
749        assert!(commands_json.contains("item_add"));
750        assert!(commands_json.contains("test-uuid"));
751        assert!(commands_json.contains("temp-123"));
752        assert!(commands_json.contains("Buy milk"));
753    }
754
755    #[test]
756    fn test_sync_request_to_form_body_multiple_resource_types() {
757        let request = SyncRequest::full_sync()
758            .with_resource_types(vec!["items".to_string(), "projects".to_string()]);
759        let body = request.to_form_body();
760
761        // Decode the form body and check the resource_types field
762        let decoded: std::collections::HashMap<String, String> =
763            serde_urlencoded::from_str(&body).unwrap();
764        let resource_types_json = decoded.get("resource_types").unwrap();
765        let types: Vec<String> = serde_json::from_str(resource_types_json).unwrap();
766        assert_eq!(types, vec!["items", "projects"]);
767    }
768
769    #[test]
770    fn test_sync_command_new() {
771        let cmd = SyncCommand::new(
772            SyncCommandType::ItemAdd,
773            serde_json::json!({"content": "Test"}),
774        );
775        assert_eq!(cmd.command_type, SyncCommandType::ItemAdd);
776        assert!(cmd.temp_id.is_none());
777        // UUID should be a valid UUID
778        assert!(uuid::Uuid::parse_str(&cmd.uuid).is_ok());
779    }
780
781    #[test]
782    fn test_sync_command_with_temp_id() {
783        let cmd = SyncCommand::with_temp_id(
784            SyncCommandType::ItemAdd,
785            "temp-123",
786            serde_json::json!({"content": "Test"}),
787        );
788        assert_eq!(cmd.command_type, SyncCommandType::ItemAdd);
789        assert_eq!(cmd.temp_id, Some("temp-123".to_string()));
790    }
791
792    #[test]
793    fn test_sync_command_with_uuid_and_temp_id() {
794        let cmd = SyncCommand::with_uuid_and_temp_id(
795            SyncCommandType::ProjectAdd,
796            "my-uuid",
797            "temp-456",
798            serde_json::json!({"name": "Project"}),
799        );
800        assert_eq!(cmd.command_type, SyncCommandType::ProjectAdd);
801        assert_eq!(cmd.uuid, "my-uuid");
802        assert_eq!(cmd.temp_id, Some("temp-456".to_string()));
803    }
804
805    #[test]
806    fn test_sync_command_serialize() {
807        let cmd = SyncCommand {
808            command_type: SyncCommandType::ItemAdd,
809            uuid: "cmd-uuid".to_string(),
810            temp_id: Some("temp-id".to_string()),
811            args: serde_json::json!({"content": "Task", "project_id": "proj-123"}),
812        };
813
814        let json = serde_json::to_string(&cmd).unwrap();
815        assert!(json.contains(r#""type":"item_add""#));
816        assert!(json.contains(r#""uuid":"cmd-uuid""#));
817        assert!(json.contains(r#""temp_id":"temp-id""#));
818        assert!(json.contains(r#""content":"Task""#));
819    }
820
821    #[test]
822    fn test_sync_command_serialize_without_temp_id() {
823        let cmd = SyncCommand {
824            command_type: SyncCommandType::ItemClose,
825            uuid: "cmd-uuid".to_string(),
826            temp_id: None,
827            args: serde_json::json!({"id": "item-123"}),
828        };
829
830        let json = serde_json::to_string(&cmd).unwrap();
831        assert!(!json.contains("temp_id"));
832    }
833
834    #[test]
835    fn test_sync_command_deserialize() {
836        let json = r#"{
837            "type": "item_add",
838            "uuid": "abc-123",
839            "temp_id": "temp-xyz",
840            "args": {"content": "Test task"}
841        }"#;
842
843        let cmd: SyncCommand = serde_json::from_str(json).unwrap();
844        assert_eq!(cmd.command_type, SyncCommandType::ItemAdd);
845        assert_eq!(cmd.uuid, "abc-123");
846        assert_eq!(cmd.temp_id, Some("temp-xyz".to_string()));
847        assert_eq!(cmd.args["content"], "Test task");
848    }
849
850    // =========================================================================
851    // SyncCommandType enum tests
852    // =========================================================================
853
854    #[test]
855    fn test_command_type_serializes_to_snake_case() {
856        let cmd_type = SyncCommandType::ItemAdd;
857        let json = serde_json::to_string(&cmd_type).unwrap();
858        assert_eq!(json, "\"item_add\"");
859    }
860
861    #[test]
862    fn test_command_type_deserializes_from_snake_case() {
863        let cmd_type: SyncCommandType = serde_json::from_str("\"item_close\"").unwrap();
864        assert_eq!(cmd_type, SyncCommandType::ItemClose);
865    }
866
867    #[test]
868    fn test_all_command_types_serialize_correctly() {
869        // Item commands
870        assert_eq!(
871            serde_json::to_string(&SyncCommandType::ItemAdd).unwrap(),
872            "\"item_add\""
873        );
874        assert_eq!(
875            serde_json::to_string(&SyncCommandType::ItemUpdate).unwrap(),
876            "\"item_update\""
877        );
878        assert_eq!(
879            serde_json::to_string(&SyncCommandType::ItemMove).unwrap(),
880            "\"item_move\""
881        );
882        assert_eq!(
883            serde_json::to_string(&SyncCommandType::ItemDelete).unwrap(),
884            "\"item_delete\""
885        );
886        assert_eq!(
887            serde_json::to_string(&SyncCommandType::ItemClose).unwrap(),
888            "\"item_close\""
889        );
890        assert_eq!(
891            serde_json::to_string(&SyncCommandType::ItemUncomplete).unwrap(),
892            "\"item_uncomplete\""
893        );
894
895        // Project commands
896        assert_eq!(
897            serde_json::to_string(&SyncCommandType::ProjectAdd).unwrap(),
898            "\"project_add\""
899        );
900        assert_eq!(
901            serde_json::to_string(&SyncCommandType::ProjectUpdate).unwrap(),
902            "\"project_update\""
903        );
904        assert_eq!(
905            serde_json::to_string(&SyncCommandType::ProjectDelete).unwrap(),
906            "\"project_delete\""
907        );
908        assert_eq!(
909            serde_json::to_string(&SyncCommandType::ProjectArchive).unwrap(),
910            "\"project_archive\""
911        );
912        assert_eq!(
913            serde_json::to_string(&SyncCommandType::ProjectUnarchive).unwrap(),
914            "\"project_unarchive\""
915        );
916
917        // Section commands
918        assert_eq!(
919            serde_json::to_string(&SyncCommandType::SectionAdd).unwrap(),
920            "\"section_add\""
921        );
922        assert_eq!(
923            serde_json::to_string(&SyncCommandType::SectionDelete).unwrap(),
924            "\"section_delete\""
925        );
926        assert_eq!(
927            serde_json::to_string(&SyncCommandType::SectionArchive).unwrap(),
928            "\"section_archive\""
929        );
930        assert_eq!(
931            serde_json::to_string(&SyncCommandType::SectionUnarchive).unwrap(),
932            "\"section_unarchive\""
933        );
934
935        // Label commands
936        assert_eq!(
937            serde_json::to_string(&SyncCommandType::LabelAdd).unwrap(),
938            "\"label_add\""
939        );
940        assert_eq!(
941            serde_json::to_string(&SyncCommandType::LabelDelete).unwrap(),
942            "\"label_delete\""
943        );
944
945        // Note commands
946        assert_eq!(
947            serde_json::to_string(&SyncCommandType::NoteAdd).unwrap(),
948            "\"note_add\""
949        );
950        assert_eq!(
951            serde_json::to_string(&SyncCommandType::NoteDelete).unwrap(),
952            "\"note_delete\""
953        );
954        assert_eq!(
955            serde_json::to_string(&SyncCommandType::ProjectNoteAdd).unwrap(),
956            "\"project_note_add\""
957        );
958        assert_eq!(
959            serde_json::to_string(&SyncCommandType::ProjectNoteDelete).unwrap(),
960            "\"project_note_delete\""
961        );
962
963        // Reminder commands
964        assert_eq!(
965            serde_json::to_string(&SyncCommandType::ReminderAdd).unwrap(),
966            "\"reminder_add\""
967        );
968        assert_eq!(
969            serde_json::to_string(&SyncCommandType::ReminderDelete).unwrap(),
970            "\"reminder_delete\""
971        );
972
973        // Filter commands
974        assert_eq!(
975            serde_json::to_string(&SyncCommandType::FilterAdd).unwrap(),
976            "\"filter_add\""
977        );
978        assert_eq!(
979            serde_json::to_string(&SyncCommandType::FilterDelete).unwrap(),
980            "\"filter_delete\""
981        );
982    }
983
984    #[test]
985    fn test_sync_command_serializes_correctly() {
986        let cmd = SyncCommand::item_close("12345");
987        let json = serde_json::to_value(&cmd).unwrap();
988        assert_eq!(json["type"], "item_close");
989        assert_eq!(json["args"]["id"], "12345");
990    }
991
992    // =========================================================================
993    // Builder method tests
994    // =========================================================================
995
996    #[test]
997    fn test_item_close_builder() {
998        let cmd = SyncCommand::item_close("task-123");
999        assert_eq!(cmd.command_type, SyncCommandType::ItemClose);
1000        assert_eq!(cmd.args["id"], "task-123");
1001        assert!(cmd.temp_id.is_none());
1002    }
1003
1004    #[test]
1005    fn test_item_uncomplete_builder() {
1006        let cmd = SyncCommand::item_uncomplete("task-456");
1007        assert_eq!(cmd.command_type, SyncCommandType::ItemUncomplete);
1008        assert_eq!(cmd.args["id"], "task-456");
1009    }
1010
1011    #[test]
1012    fn test_item_delete_builder() {
1013        let cmd = SyncCommand::item_delete("task-789");
1014        assert_eq!(cmd.command_type, SyncCommandType::ItemDelete);
1015        assert_eq!(cmd.args["id"], "task-789");
1016    }
1017
1018    #[test]
1019    fn test_project_delete_builder() {
1020        let cmd = SyncCommand::project_delete("proj-123");
1021        assert_eq!(cmd.command_type, SyncCommandType::ProjectDelete);
1022        assert_eq!(cmd.args["id"], "proj-123");
1023    }
1024
1025    #[test]
1026    fn test_project_archive_builder() {
1027        let cmd = SyncCommand::project_archive("proj-456");
1028        assert_eq!(cmd.command_type, SyncCommandType::ProjectArchive);
1029        assert_eq!(cmd.args["id"], "proj-456");
1030    }
1031
1032    #[test]
1033    fn test_section_delete_builder() {
1034        let cmd = SyncCommand::section_delete("section-123");
1035        assert_eq!(cmd.command_type, SyncCommandType::SectionDelete);
1036        assert_eq!(cmd.args["id"], "section-123");
1037    }
1038
1039    #[test]
1040    fn test_label_delete_builder() {
1041        let cmd = SyncCommand::label_delete("label-123");
1042        assert_eq!(cmd.command_type, SyncCommandType::LabelDelete);
1043        assert_eq!(cmd.args["id"], "label-123");
1044    }
1045
1046    #[test]
1047    fn test_note_delete_builder() {
1048        let cmd = SyncCommand::note_delete("note-123");
1049        assert_eq!(cmd.command_type, SyncCommandType::NoteDelete);
1050        assert_eq!(cmd.args["id"], "note-123");
1051    }
1052
1053    #[test]
1054    fn test_reminder_delete_builder() {
1055        let cmd = SyncCommand::reminder_delete("reminder-123");
1056        assert_eq!(cmd.command_type, SyncCommandType::ReminderDelete);
1057        assert_eq!(cmd.args["id"], "reminder-123");
1058    }
1059
1060    #[test]
1061    fn test_filter_delete_builder() {
1062        let cmd = SyncCommand::filter_delete("filter-123");
1063        assert_eq!(cmd.command_type, SyncCommandType::FilterDelete);
1064        assert_eq!(cmd.args["id"], "filter-123");
1065    }
1066}