1use super::{
4 attachment::Attachment,
5 custom_field::{CustomField, CustomFieldValue},
6 user::UserReference,
7 workspace::WorkspaceReference,
8};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::ops::Deref;
12use thiserror::Error;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub struct TaskReference {
18 pub gid: String,
20 #[serde(default)]
22 pub name: Option<String>,
23 #[serde(default)]
25 pub resource_type: Option<String>,
26}
27
28impl TaskReference {
29 #[must_use]
31 pub fn label(&self) -> String {
32 self.name.clone().unwrap_or_else(|| self.gid.clone())
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub struct TaskMembership {
40 #[serde(default)]
42 pub project: Option<TaskProjectReference>,
43 #[serde(default)]
45 pub section: Option<TaskSectionReference>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
50#[serde(rename_all = "snake_case")]
51pub struct TaskProjectReference {
52 pub gid: String,
54 #[serde(default)]
56 pub name: Option<String>,
57 #[serde(default)]
59 pub resource_type: Option<String>,
60}
61
62impl TaskProjectReference {
63 #[must_use]
65 pub fn label(&self) -> String {
66 self.name.clone().unwrap_or_else(|| self.gid.clone())
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub struct TaskSectionReference {
74 pub gid: String,
76 #[serde(default)]
78 pub name: Option<String>,
79 #[serde(default)]
81 pub resource_type: Option<String>,
82}
83
84impl TaskSectionReference {
85 #[must_use]
87 pub fn label(&self) -> String {
88 self.name.clone().unwrap_or_else(|| self.gid.clone())
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
94#[serde(rename_all = "snake_case")]
95pub struct TaskTagReference {
96 pub gid: String,
98 #[serde(default)]
100 pub name: Option<String>,
101 #[serde(default)]
103 pub resource_type: Option<String>,
104}
105
106impl TaskTagReference {
107 #[must_use]
109 pub fn label(&self) -> String {
110 self.name.clone().unwrap_or_else(|| self.gid.clone())
111 }
112}
113
114#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum TaskAssigneeStatus {
118 #[default]
120 Inbox,
121 Later,
123 Upcoming,
125 Today,
127 Waiting,
129 #[serde(other)]
131 Unknown,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "snake_case")]
137pub struct Task {
138 pub gid: String,
140 pub name: String,
142 #[serde(default)]
144 pub resource_type: Option<String>,
145 #[serde(default)]
147 pub resource_subtype: Option<String>,
148 #[serde(default)]
150 pub notes: Option<String>,
151 #[serde(default)]
153 pub html_notes: Option<String>,
154 #[serde(default)]
156 pub completed: bool,
157 #[serde(default)]
159 pub completed_at: Option<String>,
160 #[serde(default)]
162 pub completed_by: Option<UserReference>,
163 #[serde(default)]
165 pub created_at: Option<String>,
166 #[serde(default)]
168 pub modified_at: Option<String>,
169 #[serde(default)]
171 pub due_on: Option<String>,
172 #[serde(default)]
174 pub due_at: Option<String>,
175 #[serde(default)]
177 pub start_on: Option<String>,
178 #[serde(default)]
180 pub start_at: Option<String>,
181 #[serde(default)]
183 pub assignee: Option<UserReference>,
184 #[serde(default)]
186 pub assignee_status: Option<TaskAssigneeStatus>,
187 #[serde(default)]
189 pub workspace: Option<WorkspaceReference>,
190 #[serde(default)]
192 pub parent: Option<TaskReference>,
193 #[serde(default)]
195 pub memberships: Vec<TaskMembership>,
196 #[serde(default)]
198 pub projects: Vec<TaskProjectReference>,
199 #[serde(default)]
201 pub tags: Vec<TaskTagReference>,
202 #[serde(default)]
204 pub followers: Vec<UserReference>,
205 #[serde(default)]
207 pub dependencies: Vec<TaskReference>,
208 #[serde(default)]
210 pub dependents: Vec<TaskReference>,
211 #[serde(default)]
213 pub custom_fields: Vec<CustomField>,
214 #[serde(default)]
216 pub attachments: Vec<Attachment>,
217 #[serde(default)]
219 pub permalink_url: Option<String>,
220 #[serde(default)]
222 pub num_subtasks: Option<i64>,
223}
224
225impl Task {
226 #[must_use]
228 pub const fn is_open(&self) -> bool {
229 !self.completed
230 }
231}
232
233#[derive(Debug, Clone, Default)]
235pub struct TaskListParams {
236 pub workspace: Option<String>,
238 pub project: Option<String>,
240 pub section: Option<String>,
242 pub assignee: Option<String>,
244 pub completed_since: Option<String>,
246 pub modified_since: Option<String>,
248 pub due_on: Option<String>,
250 pub include_subtasks: bool,
252 pub limit: Option<usize>,
254 pub fields: BTreeSet<String>,
256 pub sort: Option<TaskSort>,
258 pub completed: Option<bool>,
260 pub due_before: Option<String>,
262 pub due_after: Option<String>,
264}
265
266impl TaskListParams {
267 #[must_use]
269 pub fn to_query(&self) -> Vec<(String, String)> {
270 let mut pairs = Vec::new();
271 if let Some(workspace) = &self.workspace {
272 pairs.push(("workspace".into(), workspace.clone()));
273 }
274 if let Some(project) = &self.project {
275 pairs.push(("project".into(), project.clone()));
276 }
277 if let Some(section) = &self.section {
278 pairs.push(("section".into(), section.clone()));
279 }
280 if let Some(assignee) = &self.assignee {
281 pairs.push(("assignee".into(), assignee.clone()));
282 }
283 if let Some(completed_since) = &self.completed_since {
284 pairs.push(("completed_since".into(), completed_since.clone()));
285 }
286 if let Some(modified_since) = &self.modified_since {
287 pairs.push(("modified_since".into(), modified_since.clone()));
288 }
289 if let Some(due_on) = &self.due_on {
290 pairs.push(("due_on".into(), due_on.clone()));
291 }
292 if !self.fields.is_empty() {
296 let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
297 pairs.push(("opt_fields".into(), field_list));
298 }
299 pairs
300 }
301
302 pub fn apply_post_filters(&self, tasks: &mut Vec<Task>) {
304 if let Some(expected) = self.completed {
305 tasks.retain(|task| task.completed == expected);
306 }
307 if let Some(due_before) = &self.due_before {
308 tasks.retain(|task| task.due_on.as_ref().is_some_and(|due| due <= due_before));
309 }
310 if let Some(due_after) = &self.due_after {
311 tasks.retain(|task| task.due_on.as_ref().is_some_and(|due| due >= due_after));
312 }
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum TaskSort {
319 Name,
321 DueOn,
323 CreatedAt,
325 ModifiedAt,
327 Assignee,
329}
330
331#[derive(Debug, Clone, Default)]
333pub struct TaskSearchParams {
334 pub workspace: String,
336 pub text: Option<String>,
338 pub resource_subtype: Option<String>,
340 pub completed: Option<bool>,
342 pub is_subtask: Option<bool>,
344 pub is_blocked: Option<bool>,
346 pub has_attachment: Option<bool>,
348 pub assignee: Option<String>,
350 pub projects: Vec<String>,
352 pub sections: Vec<String>,
354 pub tags: Vec<String>,
356 pub created_after: Option<String>,
358 pub created_before: Option<String>,
360 pub modified_after: Option<String>,
362 pub modified_before: Option<String>,
364 pub due_after: Option<String>,
366 pub due_before: Option<String>,
368 pub sort_by: Option<String>,
370 pub sort_ascending: bool,
372 pub limit: Option<usize>,
374 pub fields: BTreeSet<String>,
376}
377
378impl TaskSearchParams {
379 #[must_use]
381 pub fn to_query(&self) -> Vec<(String, String)> {
382 let mut pairs = Vec::new();
383
384 if let Some(text) = &self.text {
385 pairs.push(("text".into(), text.clone()));
386 }
387 if let Some(subtype) = &self.resource_subtype {
388 pairs.push(("resource_subtype".into(), subtype.clone()));
389 }
390 if let Some(completed) = self.completed {
391 pairs.push(("completed".into(), completed.to_string()));
392 }
393 if let Some(is_subtask) = self.is_subtask {
394 pairs.push(("is_subtask".into(), is_subtask.to_string()));
395 }
396 if let Some(is_blocked) = self.is_blocked {
397 pairs.push(("is_blocked".into(), is_blocked.to_string()));
398 }
399 if let Some(has_attachment) = self.has_attachment {
400 pairs.push(("has_attachment".into(), has_attachment.to_string()));
401 }
402 if let Some(assignee) = &self.assignee {
403 pairs.push(("assignee.any".into(), assignee.clone()));
404 }
405
406 for project in &self.projects {
407 pairs.push(("projects.any".into(), project.clone()));
408 }
409 for section in &self.sections {
410 pairs.push(("sections.any".into(), section.clone()));
411 }
412 for tag in &self.tags {
413 pairs.push(("tags.any".into(), tag.clone()));
414 }
415
416 if let Some(date) = &self.created_after {
417 pairs.push(("created_at.after".into(), date.clone()));
418 }
419 if let Some(date) = &self.created_before {
420 pairs.push(("created_at.before".into(), date.clone()));
421 }
422 if let Some(date) = &self.modified_after {
423 pairs.push(("modified_at.after".into(), date.clone()));
424 }
425 if let Some(date) = &self.modified_before {
426 pairs.push(("modified_at.before".into(), date.clone()));
427 }
428 if let Some(date) = &self.due_after {
429 pairs.push(("due_on.after".into(), date.clone()));
430 }
431 if let Some(date) = &self.due_before {
432 pairs.push(("due_on.before".into(), date.clone()));
433 }
434
435 if let Some(sort_by) = &self.sort_by {
436 pairs.push(("sort_by".into(), sort_by.clone()));
437 pairs.push(("sort_ascending".into(), self.sort_ascending.to_string()));
438 }
439
440 if !self.fields.is_empty() {
441 let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
442 pairs.push(("opt_fields".into(), field_list));
443 }
444
445 pairs
446 }
447}
448
449#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
451#[serde(rename_all = "snake_case")]
452pub struct TaskCreateData {
453 pub name: String,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub notes: Option<String>,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 pub html_notes: Option<String>,
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub workspace: Option<String>,
464 #[serde(skip_serializing_if = "Vec::is_empty")]
466 pub projects: Vec<String>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub section: Option<String>,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub parent: Option<String>,
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub assignee: Option<String>,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub due_on: Option<String>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub due_at: Option<String>,
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub start_on: Option<String>,
485 #[serde(skip_serializing_if = "Option::is_none")]
487 pub start_at: Option<String>,
488 #[serde(skip_serializing_if = "Vec::is_empty")]
490 pub tags: Vec<String>,
491 #[serde(skip_serializing_if = "Vec::is_empty")]
493 pub followers: Vec<String>,
494 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
496 pub custom_fields: BTreeMap<String, serde_json::Value>,
497}
498
499#[derive(Debug, Clone, Serialize)]
501pub struct TaskCreateRequest {
502 pub data: TaskCreateData,
504}
505
506#[derive(Debug, Clone)]
508pub struct TaskCreateBuilder {
509 data: TaskCreateData,
510}
511
512impl TaskCreateBuilder {
513 #[must_use]
515 pub fn new(name: impl Into<String>) -> Self {
516 let name = name.into();
517 Self {
518 data: TaskCreateData {
519 name,
520 notes: None,
521 html_notes: None,
522 workspace: None,
523 projects: Vec::new(),
524 section: None,
525 parent: None,
526 assignee: None,
527 due_on: None,
528 due_at: None,
529 start_on: None,
530 start_at: None,
531 tags: Vec::new(),
532 followers: Vec::new(),
533 custom_fields: BTreeMap::new(),
534 },
535 }
536 }
537
538 #[must_use]
540 pub fn name(mut self, name: impl Into<String>) -> Self {
541 self.data.name = name.into();
542 self
543 }
544
545 #[must_use]
547 pub fn notes(mut self, notes: impl Into<String>) -> Self {
548 self.data.notes = Some(notes.into());
549 self
550 }
551
552 #[must_use]
554 pub fn html_notes(mut self, notes: impl Into<String>) -> Self {
555 self.data.html_notes = Some(notes.into());
556 self
557 }
558
559 #[must_use]
561 pub fn workspace(mut self, workspace: impl Into<String>) -> Self {
562 self.data.workspace = Some(workspace.into());
563 self
564 }
565
566 #[must_use]
568 pub fn project(mut self, project: impl Into<String>) -> Self {
569 let gid = project.into();
570 if !self.data.projects.contains(&gid) {
571 self.data.projects.push(gid);
572 }
573 self
574 }
575
576 #[must_use]
578 pub fn section(mut self, section: impl Into<String>) -> Self {
579 self.data.section = Some(section.into());
580 self
581 }
582
583 #[must_use]
585 pub fn parent(mut self, parent: impl Into<String>) -> Self {
586 self.data.parent = Some(parent.into());
587 self
588 }
589
590 #[must_use]
592 pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
593 self.data.assignee = Some(assignee.into());
594 self
595 }
596
597 #[must_use]
599 pub fn due_on(mut self, due_on: impl Into<String>) -> Self {
600 self.data.due_on = Some(due_on.into());
601 self
602 }
603
604 #[must_use]
606 pub fn due_at(mut self, due_at: impl Into<String>) -> Self {
607 self.data.due_at = Some(due_at.into());
608 self
609 }
610
611 #[must_use]
613 pub fn start_on(mut self, start_on: impl Into<String>) -> Self {
614 self.data.start_on = Some(start_on.into());
615 self
616 }
617
618 #[must_use]
620 pub fn start_at(mut self, start_at: impl Into<String>) -> Self {
621 self.data.start_at = Some(start_at.into());
622 self
623 }
624
625 #[must_use]
627 pub fn tag(mut self, tag: impl Into<String>) -> Self {
628 let gid = tag.into();
629 if !self.data.tags.contains(&gid) {
630 self.data.tags.push(gid);
631 }
632 self
633 }
634
635 #[must_use]
637 pub fn follower(mut self, follower: impl Into<String>) -> Self {
638 let gid = follower.into();
639 if !self.data.followers.contains(&gid) {
640 self.data.followers.push(gid);
641 }
642 self
643 }
644
645 #[must_use]
647 pub fn custom_field(mut self, field_gid: impl Into<String>, value: CustomFieldValue) -> Self {
648 self.data
649 .custom_fields
650 .insert(field_gid.into(), value.into_value());
651 self
652 }
653
654 pub fn build(mut self) -> Result<TaskCreateRequest, TaskValidationError> {
660 if self.data.name.trim().is_empty() {
661 return Err(TaskValidationError::MissingName);
662 }
663 if self.data.workspace.is_none()
664 && self.data.projects.is_empty()
665 && self.data.parent.is_none()
666 {
667 return Err(TaskValidationError::MissingScope);
668 }
669 if let Some(ref html) = self.data.html_notes {
670 let prepared = crate::rich_text::prepare_rich_text(
671 html,
672 crate::rich_text::RichTextContext::TaskNotes,
673 )?;
674 self.data.html_notes = Some(prepared);
675 }
676 Ok(TaskCreateRequest { data: self.data })
677 }
678}
679
680#[allow(
691 clippy::option_option,
692 reason = "Option<Option<T>> models the API PATCH tri-state: absent leaves the field unchanged, null clears it, Some(v) sets it"
693)]
694#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
695#[serde(rename_all = "snake_case")]
696pub struct TaskUpdateData {
697 #[serde(skip_serializing_if = "Option::is_none")]
699 pub name: Option<String>,
700 #[serde(skip_serializing_if = "Option::is_none")]
702 pub notes: Option<Option<String>>,
703 #[serde(skip_serializing_if = "Option::is_none")]
705 pub html_notes: Option<Option<String>>,
706 #[serde(skip_serializing_if = "Option::is_none")]
708 pub completed: Option<bool>,
709 #[serde(skip_serializing_if = "Option::is_none")]
711 pub assignee: Option<Option<String>>,
712 #[serde(skip_serializing_if = "Option::is_none")]
714 pub due_on: Option<Option<String>>,
715 #[serde(skip_serializing_if = "Option::is_none")]
717 pub due_at: Option<Option<String>>,
718 #[serde(skip_serializing_if = "Option::is_none")]
720 pub start_on: Option<Option<String>>,
721 #[serde(skip_serializing_if = "Option::is_none")]
723 pub start_at: Option<Option<String>>,
724 #[serde(skip_serializing_if = "Option::is_none")]
726 pub parent: Option<Option<String>>,
727 #[serde(skip_serializing_if = "Option::is_none")]
729 pub tags: Option<Vec<String>>,
730 #[serde(skip_serializing_if = "Option::is_none")]
732 pub followers: Option<Vec<String>>,
733 #[serde(skip_serializing_if = "Option::is_none")]
735 pub projects: Option<Vec<String>>,
736 #[serde(skip_serializing_if = "Option::is_none")]
738 pub custom_fields: Option<BTreeMap<String, serde_json::Value>>,
739}
740
741impl TaskUpdateData {
742 #[must_use]
744 pub const fn is_empty(&self) -> bool {
745 self.name.is_none()
746 && self.notes.is_none()
747 && self.html_notes.is_none()
748 && self.completed.is_none()
749 && self.assignee.is_none()
750 && self.due_on.is_none()
751 && self.due_at.is_none()
752 && self.start_on.is_none()
753 && self.start_at.is_none()
754 && self.parent.is_none()
755 && self.tags.is_none()
756 && self.followers.is_none()
757 && self.projects.is_none()
758 && self.custom_fields.is_none()
759 }
760}
761
762#[derive(Debug, Clone, Serialize)]
764pub struct TaskUpdateRequest {
765 pub data: TaskUpdateData,
767}
768
769#[derive(Debug, Default, Clone)]
771pub struct TaskUpdateBuilder {
772 data: TaskUpdateData,
773}
774
775impl TaskUpdateBuilder {
776 #[must_use]
778 pub fn new() -> Self {
779 Self::default()
780 }
781
782 #[must_use]
784 pub fn name(mut self, name: impl Into<String>) -> Self {
785 self.data.name = Some(name.into());
786 self
787 }
788
789 #[must_use]
791 pub fn notes(mut self, notes: impl Into<String>) -> Self {
792 self.data.notes = Some(Some(notes.into()));
793 self
794 }
795
796 #[must_use]
798 pub fn clear_notes(mut self) -> Self {
799 self.data.notes = Some(None);
800 self
801 }
802
803 #[must_use]
805 pub fn html_notes(mut self, notes: impl Into<String>) -> Self {
806 self.data.html_notes = Some(Some(notes.into()));
807 self
808 }
809
810 #[must_use]
812 pub fn clear_html_notes(mut self) -> Self {
813 self.data.html_notes = Some(None);
814 self
815 }
816
817 #[must_use]
819 pub fn completed(mut self, completed: bool) -> Self {
820 self.data.completed = Some(completed);
821 self
822 }
823
824 #[must_use]
826 pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
827 self.data.assignee = Some(Some(assignee.into()));
828 self
829 }
830
831 #[must_use]
833 pub fn clear_assignee(mut self) -> Self {
834 self.data.assignee = Some(None);
835 self
836 }
837
838 #[must_use]
840 pub fn due_on(mut self, due_on: impl Into<String>) -> Self {
841 self.data.due_on = Some(Some(due_on.into()));
842 self
843 }
844
845 #[must_use]
847 pub fn clear_due_on(mut self) -> Self {
848 self.data.due_on = Some(None);
849 self
850 }
851
852 #[must_use]
854 pub fn due_at(mut self, due_at: impl Into<String>) -> Self {
855 self.data.due_at = Some(Some(due_at.into()));
856 self
857 }
858
859 #[must_use]
861 pub fn clear_due_at(mut self) -> Self {
862 self.data.due_at = Some(None);
863 self
864 }
865
866 #[must_use]
868 pub fn start_on(mut self, start_on: impl Into<String>) -> Self {
869 self.data.start_on = Some(Some(start_on.into()));
870 self
871 }
872
873 #[must_use]
875 pub fn clear_start_on(mut self) -> Self {
876 self.data.start_on = Some(None);
877 self
878 }
879
880 #[must_use]
882 pub fn start_at(mut self, start_at: impl Into<String>) -> Self {
883 self.data.start_at = Some(Some(start_at.into()));
884 self
885 }
886
887 #[must_use]
889 pub fn clear_start_at(mut self) -> Self {
890 self.data.start_at = Some(None);
891 self
892 }
893
894 #[must_use]
896 pub fn parent(mut self, parent: impl Into<String>) -> Self {
897 self.data.parent = Some(Some(parent.into()));
898 self
899 }
900
901 #[must_use]
903 pub fn clear_parent(mut self) -> Self {
904 self.data.parent = Some(None);
905 self
906 }
907
908 #[must_use]
910 pub fn tags<I, S>(mut self, tags: I) -> Self
911 where
912 I: IntoIterator<Item = S>,
913 S: Into<String>,
914 {
915 let mut values: Vec<String> = tags.into_iter().map(Into::into).collect();
916 values.sort();
917 values.dedup();
918 self.data.tags = Some(values);
919 self
920 }
921
922 #[must_use]
924 pub fn followers<I, S>(mut self, followers: I) -> Self
925 where
926 I: IntoIterator<Item = S>,
927 S: Into<String>,
928 {
929 let mut values: Vec<String> = followers.into_iter().map(Into::into).collect();
930 values.sort();
931 values.dedup();
932 self.data.followers = Some(values);
933 self
934 }
935
936 #[must_use]
938 pub fn projects<I, S>(mut self, projects: I) -> Self
939 where
940 I: IntoIterator<Item = S>,
941 S: Into<String>,
942 {
943 let mut values: Vec<String> = projects.into_iter().map(Into::into).collect();
944 values.sort();
945 values.dedup();
946 self.data.projects = Some(values);
947 self
948 }
949
950 #[must_use]
952 pub fn custom_field(mut self, field_gid: impl Into<String>, value: CustomFieldValue) -> Self {
953 let map = self.data.custom_fields.get_or_insert_with(BTreeMap::new);
954 map.insert(field_gid.into(), value.into_value());
955 self
956 }
957
958 pub fn build(mut self) -> Result<TaskUpdateRequest, TaskValidationError> {
964 if self.data.is_empty() {
965 return Err(TaskValidationError::EmptyUpdate);
966 }
967 if let Some(Some(ref html)) = self.data.html_notes {
968 let prepared = crate::rich_text::prepare_rich_text(
969 html,
970 crate::rich_text::RichTextContext::TaskNotes,
971 )?;
972 self.data.html_notes = Some(Some(prepared));
973 }
974 Ok(TaskUpdateRequest { data: self.data })
975 }
976}
977
978#[derive(Debug, Error, PartialEq, Eq)]
980pub enum TaskValidationError {
981 #[error("task name cannot be empty")]
983 MissingName,
984 #[error("tasks require either a workspace or at least one project")]
986 MissingScope,
987 #[error("invalid html_notes: {0}")]
989 InvalidHtml(#[from] crate::rich_text::RichTextError),
990 #[error("task update payload does not include any changes")]
992 EmptyUpdate,
993}
994
995impl Deref for TaskUpdateBuilder {
996 type Target = TaskUpdateData;
997
998 fn deref(&self) -> &Self::Target {
999 &self.data
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use serde_json::Value;
1007
1008 #[test]
1009 fn create_builder_requires_name() {
1010 let builder = TaskCreateBuilder::new(" ");
1011 let result = builder.build();
1012 assert_eq!(result.unwrap_err(), TaskValidationError::MissingName);
1013 }
1014
1015 #[test]
1016 fn create_builder_requires_scope() {
1017 let builder = TaskCreateBuilder::new("Sample task").notes("demo");
1018 let result = builder.build();
1019 assert_eq!(result.unwrap_err(), TaskValidationError::MissingScope);
1020 }
1021
1022 #[test]
1023 fn create_builder_success() {
1024 let builder = TaskCreateBuilder::new("Sample task")
1025 .workspace("123")
1026 .assignee("me");
1027 let request = builder.build().expect("builder should succeed");
1028 assert_eq!(request.data.name, "Sample task");
1029 assert_eq!(request.data.workspace.as_deref(), Some("123"));
1030 }
1031
1032 #[test]
1033 fn create_builder_accepts_parent_scope() {
1034 let request = TaskCreateBuilder::new("Child")
1035 .parent("T1")
1036 .build()
1037 .expect("builder should succeed");
1038 assert_eq!(request.data.parent.as_deref(), Some("T1"));
1039 }
1040
1041 #[test]
1042 fn create_builder_rejects_invalid_html_notes() {
1043 let result = TaskCreateBuilder::new("Test")
1044 .workspace("ws1")
1045 .html_notes("<body><em>bad</strong></body>")
1046 .build();
1047
1048 assert!(matches!(
1049 result.unwrap_err(),
1050 TaskValidationError::InvalidHtml(_)
1051 ));
1052 }
1053
1054 #[test]
1055 fn create_builder_prepares_html_notes() {
1056 let request = TaskCreateBuilder::new("Test")
1057 .workspace("ws1")
1058 .html_notes("Tom & Jerry")
1059 .build()
1060 .expect("builder should succeed");
1061
1062 assert_eq!(
1063 request.data.html_notes.as_deref(),
1064 Some("<body>Tom & Jerry</body>")
1065 );
1066 }
1067
1068 #[test]
1069 fn update_builder_requires_changes() {
1070 let builder = TaskUpdateBuilder::new();
1071 let result = builder.build();
1072 assert_eq!(result.unwrap_err(), TaskValidationError::EmptyUpdate);
1073 }
1074
1075 #[test]
1076 fn update_builder_accepts_changes() {
1077 let request = TaskUpdateBuilder::new()
1078 .name("Updated")
1079 .completed(true)
1080 .build()
1081 .expect("builder should succeed");
1082 assert_eq!(request.data.name.as_deref(), Some("Updated"));
1083 assert_eq!(request.data.completed, Some(true));
1084 }
1085
1086 #[test]
1087 fn update_builder_validates_html_notes() {
1088 let result = TaskUpdateBuilder::new()
1089 .html_notes("<body><em>bad</strong></body>")
1090 .build();
1091
1092 assert!(matches!(
1093 result.unwrap_err(),
1094 TaskValidationError::InvalidHtml(_)
1095 ));
1096 }
1097
1098 #[test]
1099 fn update_builder_prepares_html_notes() {
1100 let request = TaskUpdateBuilder::new()
1101 .html_notes("fix & update")
1102 .build()
1103 .expect("builder should succeed");
1104
1105 assert_eq!(
1106 request.data.html_notes,
1107 Some(Some("<body>fix & update</body>".to_string()))
1108 );
1109 }
1110
1111 #[test]
1112 fn update_builder_clear_html_notes_skips_validation() {
1113 let request = TaskUpdateBuilder::new()
1114 .clear_html_notes()
1115 .build()
1116 .expect("builder should succeed");
1117
1118 assert_eq!(request.data.html_notes, Some(None));
1119 }
1120
1121 #[test]
1122 fn create_builder_serializes_custom_field() {
1123 let request = TaskCreateBuilder::new("With field")
1124 .workspace("ws-1")
1125 .custom_field("cf1", CustomFieldValue::Bool(true))
1126 .build()
1127 .expect("builder should succeed");
1128 assert_eq!(
1129 request.data.custom_fields.get("cf1"),
1130 Some(&Value::Bool(true))
1131 );
1132 }
1133
1134 #[test]
1135 fn update_builder_clears_assignee() {
1136 let request = TaskUpdateBuilder::new()
1137 .clear_assignee()
1138 .build()
1139 .expect("builder should succeed");
1140 assert_eq!(request.data.assignee, Some(None));
1141 }
1142
1143 #[test]
1144 fn update_builder_sets_custom_field_value() {
1145 let request = TaskUpdateBuilder::new()
1146 .custom_field("cf1", CustomFieldValue::Number(5.0))
1147 .build()
1148 .expect("builder should succeed");
1149 let map = request
1150 .data
1151 .custom_fields
1152 .as_ref()
1153 .expect("custom fields set");
1154 assert!(
1155 map.get("cf1")
1156 .and_then(Value::as_f64)
1157 .is_some_and(|value| (value - 5.0).abs() < f64::EPSILON)
1158 );
1159 }
1160}