use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub use crate::models::{Deadline, Due, Duration, DurationUnit, LocationTrigger, ReminderType};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncResponse {
pub sync_token: String,
#[serde(default)]
pub full_sync: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_sync_date_utc: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<Item>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<Project>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Label>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sections: Vec<Section>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<Note>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub project_notes: Vec<ProjectNote>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reminders: Vec<Reminder>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub filters: Vec<Filter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<User>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub collaborators: Vec<Collaborator>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub collaborator_states: Vec<CollaboratorState>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sync_status: HashMap<String, CommandResult>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub temp_id_mapping: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub day_orders: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub live_notifications: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub live_notifications_last_read_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_settings: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_plan_limits: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stats: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub completed_info: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub locations: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CommandResult {
Ok(String),
Error(CommandError),
}
impl CommandResult {
pub fn is_ok(&self) -> bool {
matches!(self, CommandResult::Ok(s) if s == "ok")
}
pub fn error(&self) -> Option<&CommandError> {
match self {
CommandResult::Error(e) => Some(e),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommandError {
pub error_code: i32,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Item {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
pub project_id: String,
pub content: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<Due>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<Deadline>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default)]
pub child_order: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section_id: Option<String>,
#[serde(default)]
pub day_order: i32,
#[serde(default)]
pub is_collapsed: bool,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_by_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_by_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub responsible_uid: Option<String>,
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<Duration>,
}
fn default_priority() -> i32 {
1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default)]
pub child_order: i32,
#[serde(default)]
pub is_collapsed: bool,
#[serde(default)]
pub shared: bool,
#[serde(default)]
pub can_assign_tasks: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub is_archived: bool,
#[serde(default)]
pub is_favorite: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_style: Option<String>,
#[serde(default)]
pub inbox_project: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folder_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Section {
pub id: String,
pub name: String,
pub project_id: String,
#[serde(default)]
pub section_order: i32,
#[serde(default)]
pub is_collapsed: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub is_archived: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Label {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default)]
pub item_order: i32,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub is_favorite: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: String,
pub item_id: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posted_at: Option<String>,
#[serde(default)]
pub is_deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posted_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_attachment: Option<FileAttachment>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectNote {
pub id: String,
pub project_id: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posted_at: Option<String>,
#[serde(default)]
pub is_deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub posted_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_attachment: Option<FileAttachment>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FileAttachment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_size: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upload_state: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reminder {
pub id: String,
pub item_id: String,
#[serde(rename = "type")]
pub reminder_type: ReminderType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<Due>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minute_offset: Option<i32>,
#[serde(default)]
pub is_deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notify_uid: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc_lat: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc_long: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loc_trigger: Option<LocationTrigger>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radius: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Filter {
pub id: String,
pub name: String,
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default)]
pub item_order: i32,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub is_favorite: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inbox_project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_page: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_day: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_format: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_format: Option<i32>,
#[serde(default)]
pub is_premium: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Collaborator {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CollaboratorState {
pub project_id: String,
pub user_id: String,
pub state: String,
}
impl SyncResponse {
pub fn has_errors(&self) -> bool {
self.sync_status.values().any(|r| !r.is_ok())
}
pub fn errors(&self) -> Vec<(&String, &CommandError)> {
self.sync_status
.iter()
.filter_map(|(uuid, result)| result.error().map(|e| (uuid, e)))
.collect()
}
pub fn real_id(&self, temp_id: &str) -> Option<&String> {
self.temp_id_mapping.get(temp_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_response_deserialize_minimal() {
let json = r#"{
"sync_token": "abc123",
"full_sync": true
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.sync_token, "abc123");
assert!(response.full_sync);
assert!(response.items.is_empty());
assert!(response.projects.is_empty());
}
#[test]
fn test_sync_response_deserialize_with_items() {
let json = r#"{
"sync_token": "token123",
"full_sync": false,
"items": [
{
"id": "item-1",
"project_id": "proj-1",
"content": "Buy milk",
"description": "",
"priority": 1,
"checked": false,
"is_deleted": false
}
]
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.items.len(), 1);
assert_eq!(response.items[0].content, "Buy milk");
}
#[test]
fn test_sync_response_deserialize_with_projects() {
let json = r#"{
"sync_token": "token",
"full_sync": true,
"projects": [
{
"id": "proj-1",
"name": "Work",
"color": "blue",
"is_deleted": false,
"is_archived": false,
"is_favorite": true
}
]
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.projects.len(), 1);
assert_eq!(response.projects[0].name, "Work");
assert!(response.projects[0].is_favorite);
}
#[test]
fn test_sync_response_deserialize_with_sync_status() {
let json = r#"{
"sync_token": "token",
"full_sync": false,
"sync_status": {
"cmd-1": "ok",
"cmd-2": {"error_code": 15, "error": "Invalid temporary id"}
}
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert!(response.sync_status.get("cmd-1").unwrap().is_ok());
assert!(!response.sync_status.get("cmd-2").unwrap().is_ok());
let error = response.sync_status.get("cmd-2").unwrap().error().unwrap();
assert_eq!(error.error_code, 15);
assert_eq!(error.error, "Invalid temporary id");
}
#[test]
fn test_sync_response_deserialize_with_temp_id_mapping() {
let json = r#"{
"sync_token": "token",
"full_sync": false,
"temp_id_mapping": {
"temp-1": "real-id-1",
"temp-2": "real-id-2"
}
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.real_id("temp-1"), Some(&"real-id-1".to_string()));
assert_eq!(response.real_id("temp-2"), Some(&"real-id-2".to_string()));
assert_eq!(response.real_id("unknown"), None);
}
#[test]
fn test_sync_response_has_errors() {
let json = r#"{
"sync_token": "token",
"full_sync": false,
"sync_status": {
"cmd-1": "ok",
"cmd-2": {"error_code": 15, "error": "Error"}
}
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert!(response.has_errors());
let errors = response.errors();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].0, "cmd-2");
}
#[test]
fn test_sync_response_no_errors() {
let json = r#"{
"sync_token": "token",
"full_sync": false,
"sync_status": {
"cmd-1": "ok",
"cmd-2": "ok"
}
}"#;
let response: SyncResponse = serde_json::from_str(json).unwrap();
assert!(!response.has_errors());
assert!(response.errors().is_empty());
}
#[test]
fn test_item_deserialize_full() {
let json = r#"{
"id": "6X7rM8997g3RQmvh",
"user_id": "2671355",
"project_id": "6Jf8VQXxpwv56VQ7",
"content": "Buy Milk",
"description": "From the store",
"priority": 4,
"due": {
"date": "2025-01-21",
"datetime": "2025-01-21T10:00:00Z",
"string": "tomorrow at 10am",
"timezone": "America/New_York",
"is_recurring": false
},
"parent_id": null,
"child_order": 1,
"section_id": "3Ty8VQXxpwv28PK3",
"day_order": -1,
"is_collapsed": false,
"labels": ["Food", "Shopping"],
"checked": false,
"is_deleted": false,
"added_at": "2025-01-21T21:28:43.841504Z",
"duration": {"amount": 15, "unit": "minute"}
}"#;
let item: Item = serde_json::from_str(json).unwrap();
assert_eq!(item.id, "6X7rM8997g3RQmvh");
assert_eq!(item.content, "Buy Milk");
assert_eq!(item.description, "From the store");
assert_eq!(item.priority, 4);
assert!(item.due.is_some());
assert_eq!(item.labels, vec!["Food", "Shopping"]);
let due = item.due.unwrap();
assert_eq!(due.date, "2025-01-21");
assert_eq!(due.datetime, Some("2025-01-21T10:00:00Z".to_string()));
let duration = item.duration.unwrap();
assert_eq!(duration.amount, 15);
assert_eq!(duration.unit, DurationUnit::Minute);
}
#[test]
fn test_project_deserialize() {
let json = r#"{
"id": "6Jf8VQXxpwv56VQ7",
"name": "Shopping List",
"color": "lime_green",
"parent_id": null,
"child_order": 1,
"is_collapsed": false,
"shared": false,
"can_assign_tasks": false,
"is_deleted": false,
"is_archived": false,
"is_favorite": false,
"view_style": "list",
"inbox_project": true
}"#;
let project: Project = serde_json::from_str(json).unwrap();
assert_eq!(project.id, "6Jf8VQXxpwv56VQ7");
assert_eq!(project.name, "Shopping List");
assert_eq!(project.color, Some("lime_green".to_string()));
assert!(project.inbox_project);
assert!(!project.is_favorite);
}
#[test]
fn test_section_deserialize() {
let json = r#"{
"id": "6Jf8VQXxpwv56VQ7",
"name": "Groceries",
"project_id": "9Bw8VQXxpwv56ZY2",
"section_order": 1,
"is_collapsed": false,
"is_deleted": false,
"is_archived": false
}"#;
let section: Section = serde_json::from_str(json).unwrap();
assert_eq!(section.id, "6Jf8VQXxpwv56VQ7");
assert_eq!(section.name, "Groceries");
assert_eq!(section.project_id, "9Bw8VQXxpwv56ZY2");
}
#[test]
fn test_label_deserialize() {
let json = r#"{
"id": "2156154810",
"name": "Food",
"color": "lime_green",
"item_order": 0,
"is_deleted": false,
"is_favorite": false
}"#;
let label: Label = serde_json::from_str(json).unwrap();
assert_eq!(label.id, "2156154810");
assert_eq!(label.name, "Food");
assert_eq!(label.color, Some("lime_green".to_string()));
}
#[test]
fn test_filter_deserialize() {
let json = r#"{
"id": "filter-1",
"name": "Today's Tasks",
"query": "today | overdue",
"color": "red",
"item_order": 0,
"is_deleted": false,
"is_favorite": true
}"#;
let filter: Filter = serde_json::from_str(json).unwrap();
assert_eq!(filter.id, "filter-1");
assert_eq!(filter.name, "Today's Tasks");
assert_eq!(filter.query, "today | overdue");
assert!(filter.is_favorite);
}
#[test]
fn test_reminder_deserialize_relative() {
let json = r#"{
"id": "reminder-1",
"item_id": "item-1",
"type": "relative",
"minute_offset": 30,
"is_deleted": false
}"#;
let reminder: Reminder = serde_json::from_str(json).unwrap();
assert_eq!(reminder.id, "reminder-1");
assert_eq!(reminder.item_id, "item-1");
assert_eq!(reminder.reminder_type, ReminderType::Relative);
assert_eq!(reminder.minute_offset, Some(30));
}
#[test]
fn test_reminder_deserialize_absolute() {
let json = r#"{
"id": "reminder-2",
"item_id": "item-1",
"type": "absolute",
"due": {
"date": "2025-01-26",
"datetime": "2025-01-26T10:00:00Z"
},
"is_deleted": false
}"#;
let reminder: Reminder = serde_json::from_str(json).unwrap();
assert_eq!(reminder.id, "reminder-2");
assert_eq!(reminder.reminder_type, ReminderType::Absolute);
assert!(reminder.due.is_some());
}
#[test]
fn test_reminder_deserialize_location() {
let json = r#"{
"id": "reminder-3",
"item_id": "item-1",
"type": "location",
"name": "Home",
"loc_lat": "37.7749",
"loc_long": "-122.4194",
"loc_trigger": "on_enter",
"radius": 100,
"is_deleted": false
}"#;
let reminder: Reminder = serde_json::from_str(json).unwrap();
assert_eq!(reminder.id, "reminder-3");
assert_eq!(reminder.reminder_type, ReminderType::Location);
assert_eq!(reminder.name, Some("Home".to_string()));
assert_eq!(reminder.loc_lat, Some("37.7749".to_string()));
assert_eq!(reminder.loc_long, Some("-122.4194".to_string()));
assert_eq!(reminder.loc_trigger, Some(LocationTrigger::OnEnter));
assert_eq!(reminder.radius, Some(100));
}
#[test]
fn test_note_deserialize() {
let json = r#"{
"id": "note-1",
"item_id": "item-1",
"content": "Remember to check expiration dates",
"posted_at": "2025-01-21T10:00:00Z",
"is_deleted": false
}"#;
let note: Note = serde_json::from_str(json).unwrap();
assert_eq!(note.id, "note-1");
assert_eq!(note.item_id, "item-1");
assert_eq!(note.content, "Remember to check expiration dates");
}
#[test]
fn test_user_deserialize() {
let json = r#"{
"id": "user-1",
"email": "test@example.com",
"full_name": "Test User",
"timezone": "America/New_York",
"inbox_project_id": "inbox-123",
"is_premium": true
}"#;
let user: User = serde_json::from_str(json).unwrap();
assert_eq!(user.id, "user-1");
assert_eq!(user.email, Some("test@example.com".to_string()));
assert_eq!(user.full_name, Some("Test User".to_string()));
assert!(user.is_premium);
}
#[test]
fn test_command_result_ok() {
let result: CommandResult = serde_json::from_str(r#""ok""#).unwrap();
assert!(result.is_ok());
assert!(result.error().is_none());
}
#[test]
fn test_command_result_error() {
let result: CommandResult =
serde_json::from_str(r#"{"error_code": 15, "error": "Invalid temporary id"}"#).unwrap();
assert!(!result.is_ok());
let error = result.error().unwrap();
assert_eq!(error.error_code, 15);
assert_eq!(error.error, "Invalid temporary id");
}
}