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 #[serde(rename = "deadlineType")]
107 pub deadline_type: String,
108 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>, #[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 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, }
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 let retry_policy = ExponentialBackoff::builder()
254 .retry_bounds(
255 std::time::Duration::from_secs(10), std::time::Duration::from_secs(60), )
258 .build_with_max_retries(3);
259
260 let client = ClientBuilder::new(base_client)
262 .with(RetryTransientMiddleware::new_with_policy(retry_policy))
263 .build();
264
265 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 {
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 let response: WorkspaceListResponse = self.make_request("workspaces").await?;
420 info!("Found {} Motion workspaces", response.workspaces.len());
421
422 {
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 #[serde(rename = "autoScheduled", skip_serializing_if = "Option::is_none")]
455 auto_scheduled: Option<serde_json::Value>,
456 }
457
458 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(), };
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 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 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"), _ => 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 let jd = &mut serde_json::Deserializer::from_str(x);
698
699 let task: MotionTask = serde_path_to_error::deserialize(jd).unwrap();
701 }
702
703 #[test]
704 fn test_task_duration_serialization() {
705 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 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 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 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}