Skip to main content

linear_motion/clients/
motion.rs

1use crate::{Error, Result};
2use chrono::{DateTime, Utc};
3use governor::{Quota, RateLimiter};
4use reqwest::header::{HeaderMap, HeaderValue};
5use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
6use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
7use serde::{Deserialize, Serialize};
8use std::num::NonZeroU32;
9use std::sync::{Arc, Mutex};
10use tracing::{debug, error, info};
11
12#[derive(Debug, Clone)]
13pub enum TaskDuration {
14    None,
15    Reminder,
16    Minutes(u32),
17}
18
19impl Serialize for TaskDuration {
20    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
21    where
22        S: serde::Serializer,
23    {
24        match self {
25            TaskDuration::None => serializer.serialize_str("NONE"),
26            TaskDuration::Reminder => serializer.serialize_str("REMINDER"),
27            TaskDuration::Minutes(m) => serializer.serialize_u32(*m),
28        }
29    }
30}
31
32impl<'de> Deserialize<'de> for TaskDuration {
33    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
34    where
35        D: serde::Deserializer<'de>,
36    {
37        use serde::de::Error;
38        let value = serde_json::Value::deserialize(deserializer)?;
39
40        match value {
41            serde_json::Value::String(s) => match s.as_str() {
42                "NONE" => Ok(TaskDuration::None),
43                "REMINDER" => Ok(TaskDuration::Reminder),
44                _ => Err(D::Error::custom(format!("Invalid duration string: {}", s))),
45            },
46            serde_json::Value::Number(n) => {
47                if let Some(minutes) = n.as_u64() {
48                    Ok(TaskDuration::Minutes(minutes as u32))
49                } else {
50                    Err(D::Error::custom("Invalid duration number"))
51                }
52            }
53            _ => Err(D::Error::custom("Duration must be a string or number")),
54        }
55    }
56}
57
58impl TaskDuration {
59    pub fn from_minutes(minutes: u32) -> Self {
60        if minutes == 0 {
61            TaskDuration::None
62        } else {
63            TaskDuration::Minutes(minutes)
64        }
65    }
66}
67
68impl From<u32> for TaskDuration {
69    fn from(minutes: u32) -> Self {
70        TaskDuration::from_minutes(minutes)
71    }
72}
73
74#[derive(Debug)]
75pub struct MotionClient {
76    client: ClientWithMiddleware,
77    api_key: String,
78    base_url: String,
79    rate_limiter: RateLimiter<
80        governor::state::direct::NotKeyed,
81        governor::state::InMemoryState,
82        governor::clock::DefaultClock,
83    >,
84    cached_workspaces: Arc<Mutex<Option<Vec<MotionWorkspace>>>>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct Status {
89    pub name: String,
90    #[serde(rename = "isDefaultStatus")]
91    pub is_default_status: bool,
92    #[serde(rename = "isResolvedStatus")]
93    pub is_resolved_status: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct Label {
98    pub name: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct AutoScheduled {
103    #[serde(rename = "startDate")]
104    pub start_date: Option<DateTime<Utc>>,
105    /// HARD SOFT NONE
106    #[serde(rename = "deadlineType")]
107    pub deadline_type: String,
108    /// Work Hours
109    pub schedule: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct MotionTask {
114    pub id: Option<String>,
115    pub name: String,
116    pub description: Option<String>,
117    pub assignee_id: Option<String>,
118    pub project: Option<MotionProject>,
119    pub priority: Option<String>, // "ASAP", "HIGH", "MEDIUM", "LOW"
120    #[serde(rename = "dueDate")]
121    pub due_date: Option<DateTime<Utc>>,
122    pub duration: Option<TaskDuration>,
123    pub status: Option<Status>,
124    pub completed: Option<bool>,
125    pub labels: Option<Vec<Label>>,
126    #[serde(rename = "createdTime")]
127    pub created_time: Option<DateTime<Utc>>,
128    #[serde(rename = "updatedTime")]
129    pub updated_time: Option<DateTime<Utc>>,
130    // Additional fields from the API response that we don't currently use
131    pub creator: Option<MotionUser>,
132    pub workspace: Option<MotionWorkspace>,
133    pub assignees: Option<Vec<MotionUser>>,
134    #[serde(rename = "autoScheduled")]
135    pub auto_scheduled: Option<AutoScheduled>,
136    pub chunks: Option<Vec<TaskChunk>>,
137    #[serde(rename = "parentRecurringTaskId")]
138    pub parent_recurring_task_id: Option<String>,
139    #[serde(rename = "deadlineType")]
140    pub deadline_type: Option<String>,
141    #[serde(rename = "startOn")]
142    pub start_on: Option<String>,
143    #[serde(rename = "scheduledStart")]
144    pub scheduled_start: Option<DateTime<Utc>>,
145    #[serde(rename = "scheduledEnd")]
146    pub scheduled_end: Option<DateTime<Utc>>,
147    #[serde(rename = "schedulingIssue")]
148    pub scheduling_issue: Option<bool>,
149    #[serde(rename = "lastInteractedTime")]
150    pub last_interacted_time: Option<DateTime<Utc>>,
151    #[serde(rename = "completedTime")]
152    pub completed_time: Option<DateTime<Utc>>,
153    #[serde(rename = "customFieldValues")]
154    pub custom_field_values: Option<serde_json::Value>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct MotionWorkspace {
159    pub id: String,
160    pub name: String,
161    #[serde(rename = "teamId")]
162    pub team_id: Option<String>,
163    #[serde(rename = "type")]
164    pub workspace_type: String, // "team" or "individual"
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct MotionUser {
169    pub id: String,
170    pub name: String,
171    pub email: String,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct MotionProject {
176    pub id: String,
177    pub name: String,
178    pub description: Option<String>,
179    #[serde(rename = "workspaceId")]
180    pub workspace_id: String,
181    #[serde(rename = "createdTime")]
182    pub created_time: Option<DateTime<Utc>>,
183    #[serde(rename = "updatedTime")]
184    pub updated_time: Option<DateTime<Utc>>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct TaskChunk {
189    pub id: String,
190    pub duration: u32,
191    #[serde(rename = "scheduledStart")]
192    pub scheduled_start: Option<DateTime<Utc>>,
193    #[serde(rename = "scheduledEnd")]
194    pub scheduled_end: Option<DateTime<Utc>>,
195    #[serde(rename = "completedTime")]
196    pub completed_time: Option<DateTime<Utc>>,
197    #[serde(rename = "isFixed")]
198    pub is_fixed: bool,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct MotionLabel {
203    pub id: String,
204    pub name: String,
205    #[serde(rename = "colorHex")]
206    pub color_hex: Option<String>,
207}
208
209#[derive(Debug, Deserialize)]
210struct MotionResponse<T> {
211    #[serde(flatten)]
212    data: Option<T>,
213    meta: Option<MotionMeta>,
214}
215
216#[derive(Debug, Deserialize)]
217struct MotionMeta {
218    #[serde(rename = "nextCursor")]
219    next_cursor: Option<String>,
220    #[serde(rename = "pageSize")]
221    page_size: Option<i32>,
222}
223
224#[derive(Debug, Deserialize)]
225struct TaskListResponse {
226    tasks: Vec<MotionTask>,
227    meta: MotionMeta,
228}
229
230#[derive(Debug, Deserialize)]
231struct WorkspaceListResponse {
232    workspaces: Vec<MotionWorkspace>,
233    meta: Option<MotionMeta>,
234}
235
236#[derive(Debug, Deserialize)]
237struct LabelListResponse {
238    labels: Vec<MotionLabel>,
239    meta: Option<MotionMeta>,
240}
241
242impl MotionClient {
243    pub fn new(api_key: String) -> Result<Self> {
244        let mut headers = HeaderMap::new();
245        headers.insert("X-API-Key", HeaderValue::from_str(&api_key)?);
246
247        let base_client = reqwest::Client::builder()
248            .default_headers(headers)
249            .build()?;
250
251        // Configure exponential backoff retry policy
252        // Start with 10 seconds as requested, with exponential backoff
253        let retry_policy = ExponentialBackoff::builder()
254            .retry_bounds(
255                std::time::Duration::from_secs(10), // Start with 10 seconds as requested
256                std::time::Duration::from_secs(60), // Cap at 60 seconds
257            )
258            .build_with_max_retries(3);
259
260        // Create client with retry middleware specifically for 429 errors
261        let client = ClientBuilder::new(base_client)
262            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
263            .build();
264
265        // Motion rate limit: 12 requests per minute for individuals, 120 for teams
266        // We'll use exactly 12 requests per minute for individuals
267        let quota = Quota::per_minute(NonZeroU32::new(12).unwrap());
268        let rate_limiter = RateLimiter::direct(quota);
269
270        Ok(Self {
271            client,
272            api_key,
273            base_url: "https://api.usemotion.com/v1".to_string(),
274            rate_limiter,
275            cached_workspaces: Arc::new(Mutex::new(None)),
276        })
277    }
278
279    async fn rate_limit(&self) -> Result<()> {
280        self.rate_limiter.until_ready().await;
281        Ok(())
282    }
283
284    async fn make_request<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T> {
285        self.rate_limit().await?;
286
287        let url = format!("{}/{}", self.base_url, endpoint.trim_start_matches('/'));
288        debug!("Making Motion API request: GET {}", url);
289
290        let response = self
291            .client
292            .get(&url)
293            .send()
294            .await
295            .map_err(|e| Error::MotionApi {
296                message: format!("Request failed: {}", e),
297            })?;
298
299        if !response.status().is_success() {
300            let status = response.status();
301            let text = response.text().await?;
302            error!("Motion API error: {} - {}", status, text);
303            return Err(Error::MotionApi {
304                message: format!("HTTP {}: {}", status, text),
305            });
306        }
307
308        let text = response.text().await?;
309        debug!("Motion API response: {}", text);
310
311        let response_data: T = {
312            let deserializer = &mut serde_json::Deserializer::from_str(&text);
313            serde_path_to_error::deserialize(deserializer)?
314        };
315        Ok(response_data)
316    }
317
318    async fn make_post_request<T: for<'de> Deserialize<'de>, B: Serialize + std::fmt::Debug>(
319        &self,
320        endpoint: &str,
321        body: &B,
322    ) -> Result<T> {
323        self.rate_limit().await?;
324
325        let url = format!("{}/{}", self.base_url, endpoint.trim_start_matches('/'));
326        debug!(
327            "Making Motion API request: POST {} {:?}",
328            url,
329            serde_json::to_string(&body)
330        );
331
332        let response = self
333            .client
334            .post(&url)
335            .json(body)
336            .send()
337            .await
338            .map_err(|e| Error::MotionApi {
339                message: format!("Request failed: {}", e),
340            })?;
341
342        if !response.status().is_success() {
343            let status = response.status();
344            let text = response.text().await?;
345            error!("Motion API error: {} - {}", status, text);
346            return Err(Error::MotionApi {
347                message: format!("HTTP {}: {}", status, text),
348            });
349        }
350
351        let text = response.text().await?;
352        debug!("Motion API response: {}", text);
353
354        let response_data: T = {
355            let deserializer = &mut serde_json::Deserializer::from_str(&text);
356            match serde_path_to_error::deserialize(deserializer) {
357                Ok(data) => data,
358                Err(e) => {
359                    error!("Failed to parse Motion POST response: {}", e);
360                    error!("Response body was: {}", text);
361                    return Err(Error::Json(e.into_inner()));
362                }
363            }
364        };
365
366        Ok(response_data)
367    }
368
369    async fn make_patch_request<T: for<'de> Deserialize<'de>, B: Serialize>(
370        &self,
371        endpoint: &str,
372        body: &B,
373    ) -> Result<T> {
374        self.rate_limit().await?;
375
376        let url = format!("{}/{}", self.base_url, endpoint.trim_start_matches('/'));
377        debug!("Making Motion API request: PATCH {}", url);
378
379        let response = self
380            .client
381            .patch(&url)
382            .json(body)
383            .send()
384            .await
385            .map_err(|e| Error::MotionApi {
386                message: format!("Request failed: {}", e),
387            })?;
388
389        if !response.status().is_success() {
390            let status = response.status();
391            let text = response.text().await?;
392            error!("Motion API error: {} - {}", status, text);
393            return Err(Error::MotionApi {
394                message: format!("HTTP {}: {}", status, text),
395            });
396        }
397
398        let response_data: T = response.json().await?;
399        Ok(response_data)
400    }
401
402    pub async fn get_current_user(&self) -> Result<MotionUser> {
403        let user: MotionUser = self.make_request("users/me").await?;
404        debug!("connected to Motion as: {} ({})", user.name, user.email);
405        Ok(user)
406    }
407
408    pub async fn list_workspaces(&self) -> Result<Vec<MotionWorkspace>> {
409        // Check cache first
410        {
411            let cache = self.cached_workspaces.lock().unwrap();
412            if let Some(ref workspaces) = *cache {
413                debug!("Using cached Motion workspaces ({})", workspaces.len());
414                return Ok(workspaces.clone());
415            }
416        }
417
418        // Cache miss - fetch from API
419        let response: WorkspaceListResponse = self.make_request("workspaces").await?;
420        info!("Found {} Motion workspaces", response.workspaces.len());
421
422        // Store in cache
423        {
424            let mut cache = self.cached_workspaces.lock().unwrap();
425            *cache = Some(response.workspaces.clone());
426        }
427
428        Ok(response.workspaces)
429    }
430
431    pub async fn create_task(&self, task: &MotionTask) -> Result<MotionTask> {
432        debug!("Creating Motion task: {}", task.name);
433
434        #[derive(Serialize, Debug)]
435        struct CreateTaskRequest {
436            name: String,
437            #[serde(rename = "workspaceId")]
438            workspace_id: String,
439            #[serde(skip_serializing_if = "Option::is_none")]
440            description: Option<String>,
441            #[serde(rename = "assigneeId", skip_serializing_if = "Option::is_none")]
442            assignee_id: Option<String>,
443            #[serde(rename = "projectId", skip_serializing_if = "Option::is_none")]
444            project_id: Option<String>,
445            #[serde(skip_serializing_if = "Option::is_none")]
446            priority: Option<String>,
447            #[serde(rename = "dueDate", skip_serializing_if = "Option::is_none")]
448            due_date: Option<DateTime<Utc>>,
449            #[serde(skip_serializing_if = "Option::is_none")]
450            duration: Option<TaskDuration>,
451            #[serde(skip_serializing_if = "Option::is_none")]
452            labels: Option<Vec<String>>,
453            // Auto-scheduling fields (sent as individual fields, not nested object)
454            #[serde(rename = "autoScheduled", skip_serializing_if = "Option::is_none")]
455            auto_scheduled: Option<serde_json::Value>,
456        }
457
458        // Convert auto_scheduled to the correct format for Motion API
459        let auto_scheduled_value = task.auto_scheduled.as_ref().map(|auto_sched| {
460            serde_json::json!({
461                "startDate": auto_sched.start_date,
462                "deadlineType": auto_sched.deadline_type,
463                "schedule": auto_sched.schedule
464            })
465        });
466
467        let request = CreateTaskRequest {
468            name: task.name.clone(),
469            workspace_id: task
470                .workspace
471                .as_ref()
472                .map(|w| w.id.to_owned())
473                .expect("workspace"),
474            description: task.description.clone(),
475            assignee_id: task.assignee_id.clone(),
476            project_id: task.project.as_ref().map(|p| p.id.clone()),
477            priority: task.priority.clone(),
478            due_date: task.due_date,
479            duration: task.duration.clone(),
480            labels: task
481                .labels
482                .clone()
483                .map(|l| l.into_iter().map(|l| l.name).collect()),
484            auto_scheduled: auto_scheduled_value,
485        };
486
487        let created_task: MotionTask = self.make_post_request("tasks", &request).await?;
488        info!(
489            "Created Motion task: {} (ID: {})",
490            created_task.name,
491            created_task.id.as_ref().unwrap_or(&"unknown".to_string())
492        );
493        Ok(created_task)
494    }
495
496    pub async fn update_task(&self, task_id: &str, task: &MotionTask) -> Result<MotionTask> {
497        debug!("Updating Motion task: {}", task_id);
498
499        #[derive(Serialize)]
500        struct UpdateTaskRequest {
501            #[serde(skip_serializing_if = "Option::is_none")]
502            name: Option<String>,
503            #[serde(skip_serializing_if = "Option::is_none")]
504            description: Option<String>,
505            #[serde(skip_serializing_if = "Option::is_none")]
506            priority: Option<String>,
507            #[serde(rename = "dueDate", skip_serializing_if = "Option::is_none")]
508            due_date: Option<DateTime<Utc>>,
509            #[serde(skip_serializing_if = "Option::is_none")]
510            duration: Option<TaskDuration>,
511            #[serde(skip_serializing_if = "Option::is_none")]
512            status: Option<String>,
513            #[serde(skip_serializing_if = "Option::is_none")]
514            labels: Option<Vec<String>>,
515        }
516
517        let request = UpdateTaskRequest {
518            name: Some(task.name.clone()),
519            description: task.description.clone(),
520            priority: task.priority.clone(),
521            due_date: task.due_date,
522            duration: task.duration.clone(),
523            status: task.status.as_ref().map(|s| s.name.clone()),
524            labels: task
525                .labels
526                .clone()
527                .map(|l| l.into_iter().map(|l| l.name).collect()),
528        };
529
530        let endpoint = format!("tasks/{}", task_id);
531        let updated_task: MotionTask = self.make_patch_request(&endpoint, &request).await?;
532        debug!("Updated Motion task: {}", task_id);
533        Ok(updated_task)
534    }
535
536    pub async fn get_task(&self, task_id: &str) -> Result<MotionTask> {
537        let endpoint = format!("tasks/{}", task_id);
538        let task: MotionTask = self.make_request(&endpoint).await?;
539        Ok(task)
540    }
541
542    pub async fn list_tasks(&self, workspace_id: &str) -> Result<Vec<MotionTask>> {
543        let endpoint = format!("tasks?workspaceId={}", workspace_id);
544        let response: TaskListResponse = self.make_request(&endpoint).await?;
545        Ok(response
546            .tasks
547            .into_iter()
548            .filter(|t| !t.completed.unwrap_or(false))
549            .collect())
550    }
551
552    pub async fn list_completed_tasks(&self, workspace_id: &str) -> Result<Vec<MotionTask>> {
553        let endpoint = format!("tasks?workspaceId={}&includeAllStatuses=true", workspace_id);
554        let response: TaskListResponse = self.make_request(&endpoint).await?;
555
556        let completed_tasks: Vec<MotionTask> = response
557            .tasks
558            .into_iter()
559            .filter(|task| task.completed.unwrap_or(false))
560            .collect();
561
562        debug!(
563            "Found {} completed tasks in workspace {}",
564            completed_tasks.len(),
565            workspace_id
566        );
567        Ok(completed_tasks)
568    }
569
570    pub async fn mark_task_completed(&self, task_id: &str) -> Result<MotionTask> {
571        debug!("Marking Motion task as completed: {}", task_id);
572
573        #[derive(Serialize)]
574        struct CompleteTaskRequest {
575            status: String,
576        }
577
578        let request = CompleteTaskRequest {
579            status: "Completed".to_string(), // Motion's default completed status name
580        };
581
582        let endpoint = format!("tasks/{}", task_id);
583        let updated_task: MotionTask = self.make_patch_request(&endpoint, &request).await?;
584        info!("Marked Motion task as completed: {}", task_id);
585        Ok(updated_task)
586    }
587
588    pub async fn delete_task(&self, task_id: &str) -> Result<()> {
589        debug!("Deleting Motion task: {}", task_id);
590        
591        self.rate_limit().await?;
592
593        let url = format!("{}/tasks/{}", self.base_url, task_id);
594        let response = self
595            .client
596            .delete(&url)
597            .send()
598            .await
599            .map_err(|e| Error::MotionApi {
600                message: format!("Request failed: {}", e),
601            })?;
602
603        if !response.status().is_success() {
604            let status = response.status();
605            let text = response.text().await?;
606            error!("Motion API error: {} - {}", status, text);
607            return Err(Error::MotionApi {
608                message: format!("HTTP {}: {}", status, text),
609            });
610        }
611
612        info!("Successfully deleted Motion task: {}", task_id);
613        Ok(())
614    }
615
616    pub async fn list_labels(&self, workspace_id: &str) -> Result<Vec<MotionLabel>> {
617        let endpoint = format!("labels?workspaceId={}", workspace_id);
618        let response: LabelListResponse = self.make_request(&endpoint).await?;
619        debug!(
620            "Found {} labels in workspace {}",
621            response.labels.len(),
622            workspace_id
623        );
624        Ok(response.labels)
625    }
626
627    pub async fn create_label(
628        &self,
629        workspace_id: &str,
630        name: &str,
631        color_hex: Option<&str>,
632    ) -> Result<MotionLabel> {
633        debug!(
634            "Creating Motion label: {} in workspace {}",
635            name, workspace_id
636        );
637
638        #[derive(Serialize, Debug)]
639        struct CreateLabelRequest {
640            name: String,
641            #[serde(rename = "workspaceId")]
642            workspace_id: String,
643            #[serde(rename = "colorHex", skip_serializing_if = "Option::is_none")]
644            color_hex: Option<String>,
645        }
646
647        let request = CreateLabelRequest {
648            name: name.to_string(),
649            workspace_id: workspace_id.to_string(),
650            color_hex: color_hex.map(|c| c.to_string()),
651        };
652
653        let label: MotionLabel = self.make_post_request("labels", &request).await?;
654        debug!("Created Motion label: {} (ID: {})", label.name, label.id);
655        Ok(label)
656    }
657
658    pub async fn ensure_label_exists(
659        &self,
660        workspace_id: &str,
661        label_name: &str,
662    ) -> Result<MotionLabel> {
663        // First, check if the label already exists
664        let labels = self.list_labels(workspace_id).await?;
665
666        if let Some(existing_label) = labels.iter().find(|l| l.name == label_name) {
667            debug!(
668                "Label '{}' already exists with ID: {}",
669                label_name, existing_label.id
670            );
671            return Ok(existing_label.clone());
672        }
673
674        // If it doesn't exist, create it
675        debug!(
676            "Label '{}' does not exist in workspace {}, creating it",
677            label_name, workspace_id
678        );
679        let color = match label_name {
680            "linear-sync" => Some("#4F46E5"), // Indigo color for sync labels
681            _ => None,
682        };
683
684        self.create_label(workspace_id, label_name, color).await
685    }
686}
687
688#[cfg(test)]
689mod test {
690    use super::*;
691
692    #[test]
693    fn test_deserialize() {
694        let x = r#"{"id":"tk_2gkqzra9Lwy5VsMJmoEL1S","name":"[CAR-126] Reach out to dentist","description":"","duration":30,"dueDate":null,"deadlineType":"SOFT","parentRecurringTaskId":null,"completed":false,"completedTime":null,"updatedTime":"2025-09-07T20:48:54.036Z","creator":{"id":"vDP3xJXG3kSNEq2kBsU1yAoCc872","name":"Alexander Lyon","email":"alyon.ipride@gmail.com"},"workspace":{"id":"S01pxiftfAAQzD3JjeA3T","name":"My Tasks (Private)","teamId":null,"statuses":[{"name":"Backlog","isDefaultStatus":false,"isResolvedStatus":false},{"name":"Canceled","isDefaultStatus":false,"isResolvedStatus":false},{"name":"Completed","isDefaultStatus":false,"isResolvedStatus":true},{"name":"Not Started","isDefaultStatus":false,"isResolvedStatus":false},{"name":"Todo","isDefaultStatus":true,"isResolvedStatus":false}],"labels":[{"name":"linear-sync"}],"type":"INDIVIDUAL"},"project":null,"status":{"name":"Todo","isDefaultStatus":true,"isResolvedStatus":false},"priority":"LOW","labels":[{"name":"linear-sync"}],"assignees":[{"id":"vDP3xJXG3kSNEq2kBsU1yAoCc872","name":"Alexander Lyon","email":"alyon.ipride@gmail.com"}],"scheduledStart":null,"createdTime":"2025-09-07T20:48:54.036Z","scheduledEnd":null,"schedulingIssue":false,"lastInteractedTime":"2025-09-07T20:48:54.055Z","customFieldValues":{},"chunks":[]}"#;
695
696        // Some Deserializer.
697        let jd = &mut serde_json::Deserializer::from_str(x);
698
699        // deserialize with serde_json
700        let task: MotionTask = serde_path_to_error::deserialize(jd).unwrap();
701    }
702
703    #[test]
704    fn test_task_duration_serialization() {
705        // Test that TaskDuration serializes to the correct format for Motion API
706        let duration_none = TaskDuration::None;
707        let duration_reminder = TaskDuration::Reminder;
708        let duration_30_min = TaskDuration::Minutes(30);
709        let duration_120_min = TaskDuration::Minutes(120);
710
711        // Serialize to JSON
712        let json_none = serde_json::to_string(&duration_none).unwrap();
713        let json_reminder = serde_json::to_string(&duration_reminder).unwrap();
714        let json_30 = serde_json::to_string(&duration_30_min).unwrap();
715        let json_120 = serde_json::to_string(&duration_120_min).unwrap();
716
717        // Check correct formats
718        assert_eq!(json_none, "\"NONE\"");
719        assert_eq!(json_reminder, "\"REMINDER\"");
720        assert_eq!(json_30, "30");
721        assert_eq!(json_120, "120");
722
723        // Test round-trip deserialization
724        let parsed_none: TaskDuration = serde_json::from_str(&json_none).unwrap();
725        let parsed_reminder: TaskDuration = serde_json::from_str(&json_reminder).unwrap();
726        let parsed_30: TaskDuration = serde_json::from_str(&json_30).unwrap();
727        let parsed_120: TaskDuration = serde_json::from_str(&json_120).unwrap();
728
729        match parsed_none {
730            TaskDuration::None => (),
731            _ => panic!("Expected TaskDuration::None"),
732        }
733
734        match parsed_reminder {
735            TaskDuration::Reminder => (),
736            _ => panic!("Expected TaskDuration::Reminder"),
737        }
738
739        match parsed_30 {
740            TaskDuration::Minutes(30) => (),
741            _ => panic!("Expected TaskDuration::Minutes(30)"),
742        }
743
744        match parsed_120 {
745            TaskDuration::Minutes(120) => (),
746            _ => panic!("Expected TaskDuration::Minutes(120)"),
747        }
748    }
749}