Skip to main content

asana_cli/models/
task.rs

1//! Task-oriented data structures, builders, and request payloads.
2
3use 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/// Lightweight reference to a task or subtask.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub struct TaskReference {
18    /// Globally unique identifier.
19    pub gid: String,
20    /// Optional display name.
21    #[serde(default)]
22    pub name: Option<String>,
23    /// Resource type marker.
24    #[serde(default)]
25    pub resource_type: Option<String>,
26}
27
28impl TaskReference {
29    /// Produce a human readable label for the reference.
30    #[must_use]
31    pub fn label(&self) -> String {
32        self.name.clone().unwrap_or_else(|| self.gid.clone())
33    }
34}
35
36/// Task membership metadata linking it to projects and sections.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub struct TaskMembership {
40    /// Parent project reference.
41    #[serde(default)]
42    pub project: Option<TaskProjectReference>,
43    /// Section reference when available.
44    #[serde(default)]
45    pub section: Option<TaskSectionReference>,
46}
47
48/// Compact project reference used within task payloads.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
50#[serde(rename_all = "snake_case")]
51pub struct TaskProjectReference {
52    /// Globally unique identifier.
53    pub gid: String,
54    /// Optional project name.
55    #[serde(default)]
56    pub name: Option<String>,
57    /// Resource type marker.
58    #[serde(default)]
59    pub resource_type: Option<String>,
60}
61
62impl TaskProjectReference {
63    /// Human readable label.
64    #[must_use]
65    pub fn label(&self) -> String {
66        self.name.clone().unwrap_or_else(|| self.gid.clone())
67    }
68}
69
70/// Compact section reference.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72#[serde(rename_all = "snake_case")]
73pub struct TaskSectionReference {
74    /// Globally unique identifier.
75    pub gid: String,
76    /// Section name.
77    #[serde(default)]
78    pub name: Option<String>,
79    /// Resource type marker.
80    #[serde(default)]
81    pub resource_type: Option<String>,
82}
83
84impl TaskSectionReference {
85    /// Human readable label.
86    #[must_use]
87    pub fn label(&self) -> String {
88        self.name.clone().unwrap_or_else(|| self.gid.clone())
89    }
90}
91
92/// Compact tag reference used within tasks.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
94#[serde(rename_all = "snake_case")]
95pub struct TaskTagReference {
96    /// Globally unique identifier.
97    pub gid: String,
98    /// Tag name.
99    #[serde(default)]
100    pub name: Option<String>,
101    /// Resource type marker.
102    #[serde(default)]
103    pub resource_type: Option<String>,
104}
105
106impl TaskTagReference {
107    /// Human readable label.
108    #[must_use]
109    pub fn label(&self) -> String {
110        self.name.clone().unwrap_or_else(|| self.gid.clone())
111    }
112}
113
114/// Status of a task relative to the assignee's prioritisation buckets.
115#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum TaskAssigneeStatus {
118    /// Task recently assigned.
119    #[default]
120    Inbox,
121    /// Deferred for later.
122    Later,
123    /// Scheduled for upcoming work.
124    Upcoming,
125    /// Due today.
126    Today,
127    /// Waiting on someone/something else.
128    Waiting,
129    /// Fallback for unsupported values.
130    #[serde(other)]
131    Unknown,
132}
133
134/// Full task payload returned by the Asana API.
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "snake_case")]
137pub struct Task {
138    /// Globally unique identifier.
139    pub gid: String,
140    /// Display name.
141    pub name: String,
142    /// Resource type marker.
143    #[serde(default)]
144    pub resource_type: Option<String>,
145    /// Resource subtype marker.
146    #[serde(default)]
147    pub resource_subtype: Option<String>,
148    /// Optional notes in plain text.
149    #[serde(default)]
150    pub notes: Option<String>,
151    /// Optional notes in HTML.
152    #[serde(default)]
153    pub html_notes: Option<String>,
154    /// Completion flag.
155    #[serde(default)]
156    pub completed: bool,
157    /// Time the task was completed.
158    #[serde(default)]
159    pub completed_at: Option<String>,
160    /// User who completed the task.
161    #[serde(default)]
162    pub completed_by: Option<UserReference>,
163    /// Creation timestamp.
164    #[serde(default)]
165    pub created_at: Option<String>,
166    /// Last modification timestamp.
167    #[serde(default)]
168    pub modified_at: Option<String>,
169    /// Due date (all day).
170    #[serde(default)]
171    pub due_on: Option<String>,
172    /// Due timestamp (specific time).
173    #[serde(default)]
174    pub due_at: Option<String>,
175    /// Start date (all day).
176    #[serde(default)]
177    pub start_on: Option<String>,
178    /// Start timestamp (specific time).
179    #[serde(default)]
180    pub start_at: Option<String>,
181    /// Assigned user.
182    #[serde(default)]
183    pub assignee: Option<UserReference>,
184    /// Assignee status bucket.
185    #[serde(default)]
186    pub assignee_status: Option<TaskAssigneeStatus>,
187    /// Workspace reference.
188    #[serde(default)]
189    pub workspace: Option<WorkspaceReference>,
190    /// Parent task reference when this is a subtask.
191    #[serde(default)]
192    pub parent: Option<TaskReference>,
193    /// Project/section memberships.
194    #[serde(default)]
195    pub memberships: Vec<TaskMembership>,
196    /// Projects referenced directly.
197    #[serde(default)]
198    pub projects: Vec<TaskProjectReference>,
199    /// Tag references.
200    #[serde(default)]
201    pub tags: Vec<TaskTagReference>,
202    /// Followers for notifications.
203    #[serde(default)]
204    pub followers: Vec<UserReference>,
205    /// Tasks this task depends on.
206    #[serde(default)]
207    pub dependencies: Vec<TaskReference>,
208    /// Tasks blocked by this task.
209    #[serde(default)]
210    pub dependents: Vec<TaskReference>,
211    /// Custom field payloads.
212    #[serde(default)]
213    pub custom_fields: Vec<CustomField>,
214    /// Attachment metadata when requested.
215    #[serde(default)]
216    pub attachments: Vec<Attachment>,
217    /// Public permalink.
218    #[serde(default)]
219    pub permalink_url: Option<String>,
220    /// Number of subtasks this task contains.
221    #[serde(default)]
222    pub num_subtasks: Option<i64>,
223}
224
225impl Task {
226    /// Determines whether the task is currently incomplete.
227    #[must_use]
228    pub const fn is_open(&self) -> bool {
229        !self.completed
230    }
231}
232
233/// Parameters for listing tasks via the API.
234#[derive(Debug, Clone, Default)]
235pub struct TaskListParams {
236    /// Workspace filter.
237    pub workspace: Option<String>,
238    /// Project filter.
239    pub project: Option<String>,
240    /// Section filter.
241    pub section: Option<String>,
242    /// Assignee filter.
243    pub assignee: Option<String>,
244    /// Completed since timestamp.
245    pub completed_since: Option<String>,
246    /// Modified since timestamp.
247    pub modified_since: Option<String>,
248    /// Exact due date filter.
249    pub due_on: Option<String>,
250    /// Include subtasks in listing.
251    pub include_subtasks: bool,
252    /// Maximum number of items to fetch (client side).
253    pub limit: Option<usize>,
254    /// Additional fields to request.
255    pub fields: BTreeSet<String>,
256    /// Sort order applied post-fetch.
257    pub sort: Option<TaskSort>,
258    /// Post-fetch completion filter.
259    pub completed: Option<bool>,
260    /// Post-fetch due date upper bound (inclusive, YYYY-MM-DD).
261    pub due_before: Option<String>,
262    /// Post-fetch due date lower bound (inclusive, YYYY-MM-DD).
263    pub due_after: Option<String>,
264}
265
266impl TaskListParams {
267    /// Convert the structure into query string pairs.
268    #[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        // Note: include_subtasks flag is used to control separate subtask fetching
293        // after the main API call. The deprecated opt_expand=subtasks parameter
294        // no longer returns complete field data and has been removed.
295        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    /// Apply local filters after API pagination.
303    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/// Supported sort orders for task listings.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum TaskSort {
319    /// Alphabetical by task name.
320    Name,
321    /// Due date ascending.
322    DueOn,
323    /// Creation timestamp ascending.
324    CreatedAt,
325    /// Modification timestamp ascending.
326    ModifiedAt,
327    /// Assignee display name.
328    Assignee,
329}
330
331/// Parameters for searching tasks.
332#[derive(Debug, Clone, Default)]
333pub struct TaskSearchParams {
334    /// Workspace to search in (required).
335    pub workspace: String,
336    /// Full-text search query.
337    pub text: Option<String>,
338    /// Resource subtype filter.
339    pub resource_subtype: Option<String>,
340    /// Completion status filter.
341    pub completed: Option<bool>,
342    /// Include subtasks.
343    pub is_subtask: Option<bool>,
344    /// Filter blocked tasks.
345    pub is_blocked: Option<bool>,
346    /// Filter tasks with attachments.
347    pub has_attachment: Option<bool>,
348    /// Assignee filter (gid or "me").
349    pub assignee: Option<String>,
350    /// Project filter (gid).
351    pub projects: Vec<String>,
352    /// Section filter (gid).
353    pub sections: Vec<String>,
354    /// Tag filter (gid).
355    pub tags: Vec<String>,
356    /// Created after date (YYYY-MM-DD).
357    pub created_after: Option<String>,
358    /// Created before date (YYYY-MM-DD).
359    pub created_before: Option<String>,
360    /// Modified after date (YYYY-MM-DD).
361    pub modified_after: Option<String>,
362    /// Modified before date (YYYY-MM-DD).
363    pub modified_before: Option<String>,
364    /// Due after date (YYYY-MM-DD).
365    pub due_after: Option<String>,
366    /// Due before date (YYYY-MM-DD).
367    pub due_before: Option<String>,
368    /// Sort field.
369    pub sort_by: Option<String>,
370    /// Sort ascending.
371    pub sort_ascending: bool,
372    /// Maximum number of items to fetch.
373    pub limit: Option<usize>,
374    /// Additional fields to request.
375    pub fields: BTreeSet<String>,
376}
377
378impl TaskSearchParams {
379    /// Convert to query parameters.
380    #[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/// Payload for creating tasks.
450#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
451#[serde(rename_all = "snake_case")]
452pub struct TaskCreateData {
453    /// Task name (required).
454    pub name: String,
455    /// Optional notes (plain text).
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub notes: Option<String>,
458    /// Optional notes in HTML format.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub html_notes: Option<String>,
461    /// Workspace or organization identifier.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub workspace: Option<String>,
464    /// Associated project identifiers.
465    #[serde(skip_serializing_if = "Vec::is_empty")]
466    pub projects: Vec<String>,
467    /// Section identifier when provided.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub section: Option<String>,
470    /// Parent task identifier for creating subtasks.
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub parent: Option<String>,
473    /// Assigned user (gid or email).
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub assignee: Option<String>,
476    /// Due date (all day).
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub due_on: Option<String>,
479    /// Due timestamp.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub due_at: Option<String>,
482    /// Start date (all day).
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub start_on: Option<String>,
485    /// Start timestamp.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub start_at: Option<String>,
488    /// Tags to apply.
489    #[serde(skip_serializing_if = "Vec::is_empty")]
490    pub tags: Vec<String>,
491    /// Followers to notify.
492    #[serde(skip_serializing_if = "Vec::is_empty")]
493    pub followers: Vec<String>,
494    /// Custom field assignments.
495    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
496    pub custom_fields: BTreeMap<String, serde_json::Value>,
497}
498
499/// API envelope for create requests.
500#[derive(Debug, Clone, Serialize)]
501pub struct TaskCreateRequest {
502    /// Wrapped data payload.
503    pub data: TaskCreateData,
504}
505
506/// Builder for constructing validated task create payloads.
507#[derive(Debug, Clone)]
508pub struct TaskCreateBuilder {
509    data: TaskCreateData,
510}
511
512impl TaskCreateBuilder {
513    /// Start building a new task payload with the required name.
514    #[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    /// Override the task name.
539    #[must_use]
540    pub fn name(mut self, name: impl Into<String>) -> Self {
541        self.data.name = name.into();
542        self
543    }
544
545    /// Provide plain text notes.
546    #[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    /// Provide HTML formatted notes.
553    #[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    /// Set the workspace gid.
560    #[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    /// Add a project gid association.
567    #[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    /// Target a specific section gid when creating within a project.
577    #[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    /// Set the parent task gid to create a subtask.
584    #[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    /// Assign the task to a user (gid or email).
591    #[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    /// Set the due date (all day).
598    #[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    /// Set the due timestamp.
605    #[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    /// Set the start date (all day).
612    #[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    /// Set the start timestamp.
619    #[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    /// Add a tag identifier.
626    #[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    /// Add a follower identifier.
636    #[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    /// Assign a custom field value.
646    #[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    /// Finalise the builder into a request payload performing validation.
655    ///
656    /// # Errors
657    ///
658    /// Returns a validation error if mandatory fields are missing or invalid.
659    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/// Payload for updating existing tasks.
681///
682/// Uses `Option<Option<T>>` for certain fields to distinguish three API states:
683/// - `None`: Don't update field (omit from JSON payload)
684/// - `Some(None)`: Clear field (send `null` in JSON to remove value)
685/// - `Some(Some(value))`: Set field to new value
686///
687/// This is required by the Asana API which treats missing fields differently from
688/// explicit `null` values. Omitting a field preserves its current value, while
689/// sending `null` clears it.
690#[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    /// Task name update.
698    #[serde(skip_serializing_if = "Option::is_none")]
699    pub name: Option<String>,
700    /// Plain text notes update.
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub notes: Option<Option<String>>,
703    /// HTML notes update.
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub html_notes: Option<Option<String>>,
706    /// Completion flag change.
707    #[serde(skip_serializing_if = "Option::is_none")]
708    pub completed: Option<bool>,
709    /// Assignee change (gid/email) or explicit null.
710    #[serde(skip_serializing_if = "Option::is_none")]
711    pub assignee: Option<Option<String>>,
712    /// Due date change (all day) or explicit null.
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub due_on: Option<Option<String>>,
715    /// Due timestamp change or explicit null.
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub due_at: Option<Option<String>>,
718    /// Start date change or explicit null.
719    #[serde(skip_serializing_if = "Option::is_none")]
720    pub start_on: Option<Option<String>>,
721    /// Start timestamp change or explicit null.
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub start_at: Option<Option<String>>,
724    /// Parent assignment or removal.
725    #[serde(skip_serializing_if = "Option::is_none")]
726    pub parent: Option<Option<String>>,
727    /// Replace tags with the provided identifiers.
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub tags: Option<Vec<String>>,
730    /// Replace followers with the provided identifiers.
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub followers: Option<Vec<String>>,
733    /// Replace project associations with the provided identifiers.
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub projects: Option<Vec<String>>,
736    /// Custom field updates.
737    #[serde(skip_serializing_if = "Option::is_none")]
738    pub custom_fields: Option<BTreeMap<String, serde_json::Value>>,
739}
740
741impl TaskUpdateData {
742    /// Determine whether any fields have been set.
743    #[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/// API envelope for update requests.
763#[derive(Debug, Clone, Serialize)]
764pub struct TaskUpdateRequest {
765    /// Wrapped data payload.
766    pub data: TaskUpdateData,
767}
768
769/// Builder for constructing validated task update payloads.
770#[derive(Debug, Default, Clone)]
771pub struct TaskUpdateBuilder {
772    data: TaskUpdateData,
773}
774
775impl TaskUpdateBuilder {
776    /// Create a new empty builder.
777    #[must_use]
778    pub fn new() -> Self {
779        Self::default()
780    }
781
782    /// Set the task name.
783    #[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    /// Replace notes with the provided plain text.
790    #[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    /// Clear the plain text notes.
797    #[must_use]
798    pub fn clear_notes(mut self) -> Self {
799        self.data.notes = Some(None);
800        self
801    }
802
803    /// Set HTML formatted notes.
804    #[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    /// Clear HTML notes.
811    #[must_use]
812    pub fn clear_html_notes(mut self) -> Self {
813        self.data.html_notes = Some(None);
814        self
815    }
816
817    /// Mark the task completed/incomplete.
818    #[must_use]
819    pub fn completed(mut self, completed: bool) -> Self {
820        self.data.completed = Some(completed);
821        self
822    }
823
824    /// Assign the task.
825    #[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    /// Remove the current assignee.
832    #[must_use]
833    pub fn clear_assignee(mut self) -> Self {
834        self.data.assignee = Some(None);
835        self
836    }
837
838    /// Set the due date (all day).
839    #[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    /// Clear the due date.
846    #[must_use]
847    pub fn clear_due_on(mut self) -> Self {
848        self.data.due_on = Some(None);
849        self
850    }
851
852    /// Set the due timestamp.
853    #[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    /// Clear the due timestamp.
860    #[must_use]
861    pub fn clear_due_at(mut self) -> Self {
862        self.data.due_at = Some(None);
863        self
864    }
865
866    /// Set the start date.
867    #[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    /// Clear the start date.
874    #[must_use]
875    pub fn clear_start_on(mut self) -> Self {
876        self.data.start_on = Some(None);
877        self
878    }
879
880    /// Set the start timestamp.
881    #[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    /// Clear the start timestamp.
888    #[must_use]
889    pub fn clear_start_at(mut self) -> Self {
890        self.data.start_at = Some(None);
891        self
892    }
893
894    /// Set the parent task.
895    #[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    /// Remove the parent task.
902    #[must_use]
903    pub fn clear_parent(mut self) -> Self {
904        self.data.parent = Some(None);
905        self
906    }
907
908    /// Replace tags with the provided identifiers.
909    #[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    /// Replace followers with the provided identifiers.
923    #[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    /// Replace project associations.
937    #[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    /// Set a custom field value.
951    #[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    /// Finalise the builder.
959    ///
960    /// # Errors
961    ///
962    /// Returns an error if no fields were modified.
963    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/// Errors emitted during task payload validation.
979#[derive(Debug, Error, PartialEq, Eq)]
980pub enum TaskValidationError {
981    /// Task name was missing or blank.
982    #[error("task name cannot be empty")]
983    MissingName,
984    /// Workspace or project context missing when creating a task.
985    #[error("tasks require either a workspace or at least one project")]
986    MissingScope,
987    /// HTML rich text content is not valid XML.
988    #[error("invalid html_notes: {0}")]
989    InvalidHtml(#[from] crate::rich_text::RichTextError),
990    /// Update payload did not contain any fields.
991    #[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 &amp; 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 &amp; 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}