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}