Skip to main content

asana_cli/api/
tasks.rs

1//! High level task operations built on the core API client.
2
3use crate::{
4    api::{ApiClient, ApiError},
5    api::{SingleResponse, api_delete, api_post, api_put},
6    models::{
7        Task, TaskCreateRequest, TaskListParams, TaskReference, TaskSearchParams, TaskSort,
8        TaskUpdateRequest,
9    },
10};
11use futures_util::{StreamExt, pin_mut};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeSet;
14use tracing::debug;
15
16/// Retrieve tasks according to the supplied parameters.
17///
18/// # Errors
19///
20/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
21pub async fn list_tasks(
22    client: &ApiClient,
23    mut params: TaskListParams,
24) -> Result<Vec<Task>, ApiError> {
25    ensure_default_fields(&mut params);
26
27    let query = params.to_query();
28    let max_items = params.limit;
29    let stream = client.paginate_with_limit::<Task>("/tasks", query, max_items);
30    pin_mut!(stream);
31
32    let mut tasks = Vec::new();
33    while let Some(page) = stream.next().await {
34        let mut page = page?;
35        tasks.append(&mut page);
36    }
37
38    // Fetch subtasks if requested. This makes separate API calls for each task
39    // to get subtasks with complete field data. The deprecated opt_expand=subtasks
40    // no longer returns full field information, and num_subtasks is not reliably
41    // populated by the API, so we attempt to fetch subtasks for all tasks.
42    if params.include_subtasks {
43        debug!("Fetching subtasks for {} parent tasks", tasks.len());
44        let mut all_subtasks = Vec::new();
45        for task in &tasks {
46            // Continue on error - tasks without subtasks may return empty results or errors
47            match list_subtasks(client, &task.gid, params.fields.iter().cloned().collect()).await {
48                Ok(subtasks) => {
49                    if !subtasks.is_empty() {
50                        debug!("Found {} subtasks for task {}", subtasks.len(), task.gid);
51                    }
52                    all_subtasks.extend(subtasks);
53                }
54                Err(e) => {
55                    debug!("Failed to fetch subtasks for task {}: {}", task.gid, e);
56                    // Task has no subtasks or fetch failed, skip it
57                }
58            }
59        }
60        debug!("Total subtasks fetched: {}", all_subtasks.len());
61        tasks.extend(all_subtasks);
62    }
63
64    params.apply_post_filters(&mut tasks);
65
66    if let Some(sort) = params.sort {
67        sort_tasks(&mut tasks, sort);
68    }
69
70    Ok(tasks)
71}
72
73/// Search tasks in a workspace.
74///
75/// # Errors
76///
77/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
78pub async fn search_tasks(
79    client: &ApiClient,
80    mut params: TaskSearchParams,
81) -> Result<Vec<Task>, ApiError> {
82    ensure_default_fields_for_search(&mut params);
83
84    let query = params.to_query();
85    let max_items = params.limit;
86    let endpoint = format!("/workspaces/{}/tasks/search", params.workspace);
87    let stream = client.paginate_with_limit::<Task>(&endpoint, query, max_items);
88    pin_mut!(stream);
89
90    let mut tasks = Vec::new();
91    while let Some(page) = stream.next().await {
92        let mut page = page?;
93        tasks.append(&mut page);
94    }
95
96    Ok(tasks)
97}
98
99fn ensure_default_fields_for_search(params: &mut TaskSearchParams) {
100    // Add same defaults as list_tasks
101    let defaults = [
102        "gid",
103        "name",
104        "completed",
105        "completed_at",
106        "due_on",
107        "due_at",
108        "start_on",
109        "start_at",
110        "assignee.name",
111        "assignee.gid",
112        "assignee.email",
113        "resource_type",
114        "resource_subtype",
115        "modified_at",
116        "workspace.name",
117        "workspace.gid",
118        "projects.name",
119        "projects.gid",
120        "tags.name",
121        "tags.gid",
122        "memberships.project.name",
123        "memberships.project.gid",
124        "memberships.section.name",
125        "memberships.section.gid",
126        "permalink_url",
127        "num_subtasks",
128    ];
129    for field in defaults {
130        params.fields.insert(field.to_string());
131    }
132}
133
134/// Retrieve a single task by gid.
135///
136/// # Errors
137///
138/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
139pub async fn get_task(
140    client: &ApiClient,
141    gid: &str,
142    fields: Vec<String>,
143) -> Result<Task, ApiError> {
144    let mut field_set: BTreeSet<String> = fields.into_iter().collect();
145    for field in detail_defaults() {
146        field_set.insert((*field).to_string());
147    }
148
149    let mut query = Vec::new();
150    if !field_set.is_empty() {
151        query.push((
152            "opt_fields".into(),
153            field_set.into_iter().collect::<Vec<_>>().join(","),
154        ));
155    }
156
157    let response: SingleResponse<Task> = client
158        .get_json_with_pairs(&format!("/tasks/{gid}"), query)
159        .await?;
160    Ok(response.data)
161}
162
163/// Create a task using the provided payload.
164///
165/// # Errors
166///
167/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
168pub async fn create_task(client: &ApiClient, request: TaskCreateRequest) -> Result<Task, ApiError> {
169    api_post(client, "/tasks", &request).await
170}
171
172/// Update a task using the given payload.
173///
174/// # Errors
175///
176/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
177pub async fn update_task(
178    client: &ApiClient,
179    gid: &str,
180    request: TaskUpdateRequest,
181) -> Result<Task, ApiError> {
182    api_put(client, &format!("/tasks/{gid}"), &request).await
183}
184
185/// Delete a task permanently.
186///
187/// # Errors
188///
189/// Returns an error if the API request fails or if the response is invalid.
190pub async fn delete_task(client: &ApiClient, gid: &str) -> Result<(), ApiError> {
191    api_delete(client, &format!("/tasks/{gid}")).await
192}
193
194/// List subtasks for a parent task.
195///
196/// # Errors
197///
198/// Returns an error if the API request fails or deserialization fails.
199pub async fn list_subtasks(
200    client: &ApiClient,
201    gid: &str,
202    fields: Vec<String>,
203) -> Result<Vec<Task>, ApiError> {
204    let mut field_set: BTreeSet<String> = fields.into_iter().collect();
205    ensure_subtask_fields(&mut field_set);
206
207    let mut query = Vec::new();
208    if !field_set.is_empty() {
209        let list = field_set.into_iter().collect::<Vec<_>>().join(",");
210        query.push(("opt_fields".into(), list));
211    }
212
213    let stream = client.paginate_with_limit::<Task>(&format!("/tasks/{gid}/subtasks"), query, None);
214    pin_mut!(stream);
215
216    let mut tasks = Vec::new();
217    while let Some(page) = stream.next().await {
218        let mut page = page?;
219        tasks.append(&mut page);
220    }
221    Ok(tasks)
222}
223
224/// Retrieve the tasks that this task depends on.
225///
226/// # Errors
227///
228/// Returns an error if the API request fails or deserialization fails.
229pub async fn list_dependencies(
230    client: &ApiClient,
231    gid: &str,
232) -> Result<Vec<TaskReference>, ApiError> {
233    let response: TaskReferenceResponse = client
234        .get_json_with_pairs(&format!("/tasks/{gid}/dependencies"), vec![])
235        .await?;
236    Ok(response.data)
237}
238
239/// Retrieve the tasks blocked by this task.
240///
241/// # Errors
242///
243/// Returns an error if the API request fails or deserialization fails.
244pub async fn list_dependents(
245    client: &ApiClient,
246    gid: &str,
247) -> Result<Vec<TaskReference>, ApiError> {
248    let response: TaskReferenceResponse = client
249        .get_json_with_pairs(&format!("/tasks/{gid}/dependents"), vec![])
250        .await?;
251    Ok(response.data)
252}
253
254/// Add dependencies to a task.
255///
256/// # Errors
257///
258/// Returns an error if the API request fails.
259pub async fn add_dependencies(
260    client: &ApiClient,
261    gid: &str,
262    dependencies: Vec<String>,
263) -> Result<(), ApiError> {
264    if dependencies.is_empty() {
265        return Ok(());
266    }
267
268    let payload = DependencyModifyRequest {
269        data: DependencyList { dependencies },
270    };
271    client
272        .post_void(&format!("/tasks/{gid}/addDependencies"), &payload)
273        .await
274}
275
276/// Remove dependencies from a task.
277///
278/// # Errors
279///
280/// Returns an error if the API request fails.
281pub async fn remove_dependencies(
282    client: &ApiClient,
283    gid: &str,
284    dependencies: Vec<String>,
285) -> Result<(), ApiError> {
286    if dependencies.is_empty() {
287        return Ok(());
288    }
289
290    let payload = DependencyModifyRequest {
291        data: DependencyList { dependencies },
292    };
293    client
294        .post_void(&format!("/tasks/{gid}/removeDependencies"), &payload)
295        .await
296}
297
298/// Add dependents to a task (tasks blocked by this task).
299///
300/// # Errors
301///
302/// Returns an error if the API request fails.
303pub async fn add_dependents(
304    client: &ApiClient,
305    gid: &str,
306    dependents: Vec<String>,
307) -> Result<(), ApiError> {
308    if dependents.is_empty() {
309        return Ok(());
310    }
311
312    let payload = DependentModifyRequest {
313        data: DependentList { dependents },
314    };
315    client
316        .post_void(&format!("/tasks/{gid}/addDependents"), &payload)
317        .await
318}
319
320/// Remove dependents from a task.
321///
322/// # Errors
323///
324/// Returns an error if the API request fails.
325pub async fn remove_dependents(
326    client: &ApiClient,
327    gid: &str,
328    dependents: Vec<String>,
329) -> Result<(), ApiError> {
330    if dependents.is_empty() {
331        return Ok(());
332    }
333
334    let payload = DependentModifyRequest {
335        data: DependentList { dependents },
336    };
337    client
338        .post_void(&format!("/tasks/{gid}/removeDependents"), &payload)
339        .await
340}
341
342/// Add the task to a project, optionally targeting a section.
343///
344/// # Errors
345///
346/// Returns an error if the API request fails.
347pub async fn add_project(
348    client: &ApiClient,
349    gid: &str,
350    project: String,
351    section: Option<String>,
352) -> Result<(), ApiError> {
353    let payload = ProjectModifyRequest {
354        data: ProjectModifyData { project, section },
355    };
356    client
357        .post_void(&format!("/tasks/{gid}/addProject"), &payload)
358        .await
359}
360
361/// Remove the task from a project.
362///
363/// # Errors
364///
365/// Returns an error if the API request fails.
366pub async fn remove_project(
367    client: &ApiClient,
368    gid: &str,
369    project: String,
370) -> Result<(), ApiError> {
371    let payload = ProjectModifyRequest {
372        data: ProjectModifyData {
373            project,
374            section: None,
375        },
376    };
377    client
378        .post_void(&format!("/tasks/{gid}/removeProject"), &payload)
379        .await
380}
381
382/// Add followers to a task.
383///
384/// # Errors
385///
386/// Returns an error if the API request fails.
387pub async fn add_followers(
388    client: &ApiClient,
389    gid: &str,
390    followers: Vec<String>,
391) -> Result<(), ApiError> {
392    if followers.is_empty() {
393        return Ok(());
394    }
395
396    let payload = FollowersModifyRequest {
397        data: FollowersList { followers },
398    };
399    client
400        .post_void(&format!("/tasks/{gid}/addFollowers"), &payload)
401        .await
402}
403
404/// Remove followers from a task.
405///
406/// # Errors
407///
408/// Returns an error if the API request fails.
409pub async fn remove_followers(
410    client: &ApiClient,
411    gid: &str,
412    followers: Vec<String>,
413) -> Result<(), ApiError> {
414    if followers.is_empty() {
415        return Ok(());
416    }
417
418    let payload = FollowersModifyRequest {
419        data: FollowersList { followers },
420    };
421    client
422        .post_void(&format!("/tasks/{gid}/removeFollowers"), &payload)
423        .await
424}
425
426/// Add a tag to a task.
427///
428/// # Errors
429///
430/// Returns an error if the API request fails.
431pub async fn add_tag(client: &ApiClient, gid: &str, tag: String) -> Result<(), ApiError> {
432    let payload = TagModifyRequest {
433        data: TagModifyData { tag },
434    };
435    client
436        .post_void(&format!("/tasks/{gid}/addTag"), &payload)
437        .await
438}
439
440/// Remove a tag from a task.
441///
442/// # Errors
443///
444/// Returns an error if the API request fails.
445pub async fn remove_tag(client: &ApiClient, gid: &str, tag: String) -> Result<(), ApiError> {
446    let payload = TagModifyRequest {
447        data: TagModifyData { tag },
448    };
449    client
450        .post_void(&format!("/tasks/{gid}/removeTag"), &payload)
451        .await
452}
453
454fn ensure_default_fields(params: &mut TaskListParams) {
455    let defaults = [
456        "gid",
457        "name",
458        "completed",
459        "completed_at",
460        "due_on",
461        "due_at",
462        "start_on",
463        "start_at",
464        "assignee.name",
465        "assignee.gid",
466        "assignee.email",
467        "resource_type",
468        "resource_subtype",
469        "modified_at",
470        "workspace.name",
471        "workspace.gid",
472        "projects.name",
473        "projects.gid",
474        "tags.name",
475        "tags.gid",
476        "memberships.project.name",
477        "memberships.project.gid",
478        "memberships.section.name",
479        "memberships.section.gid",
480        "permalink_url",
481        "num_subtasks",
482    ];
483    for field in defaults {
484        params.fields.insert(field.to_string());
485    }
486}
487
488const fn detail_defaults() -> &'static [&'static str] {
489    &[
490        "gid",
491        "name",
492        "completed",
493        "completed_at",
494        "due_on",
495        "due_at",
496        "start_on",
497        "start_at",
498        "notes",
499        "html_notes",
500        "assignee",
501        "assignee_status",
502        "assignee.name",
503        "assignee.email",
504        "completed_by",
505        "created_at",
506        "modified_at",
507        "workspace",
508        "workspace.name",
509        "workspace.gid",
510        "parent",
511        "projects",
512        "projects.name",
513        "projects.gid",
514        "memberships",
515        "tags",
516        "followers",
517        "followers.name",
518        "followers.email",
519        "dependencies",
520        "dependents",
521        "custom_fields",
522        "attachments",
523        "permalink_url",
524        "resource_subtype",
525        "num_subtasks",
526    ]
527}
528
529fn ensure_subtask_fields(fields: &mut BTreeSet<String>) {
530    // Always ensure parent field is included so we can identify subtasks
531    fields.insert("parent".to_string());
532    fields.insert("parent.gid".to_string());
533    fields.insert("parent.name".to_string());
534
535    if fields.len() == 3 {
536        // Only parent fields were added, add minimal defaults
537        let defaults = ["gid", "name", "completed", "assignee.name"];
538        for field in defaults {
539            fields.insert(field.to_string());
540        }
541    }
542}
543
544fn sort_tasks(tasks: &mut [Task], sort: TaskSort) {
545    match sort {
546        TaskSort::Name => tasks.sort_by(|a, b| {
547            a.name
548                .to_ascii_lowercase()
549                .cmp(&b.name.to_ascii_lowercase())
550        }),
551        TaskSort::DueOn => tasks.sort_by(|a, b| {
552            a.due_on
553                .cmp(&b.due_on)
554                .then_with(|| a.due_at.cmp(&b.due_at))
555        }),
556        TaskSort::CreatedAt => tasks.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
557        TaskSort::ModifiedAt => tasks.sort_by(|a, b| a.modified_at.cmp(&b.modified_at)),
558        TaskSort::Assignee => tasks.sort_by_key(assignee_label),
559    }
560}
561
562fn assignee_label(task: &Task) -> Option<String> {
563    task.assignee
564        .as_ref()
565        .map(crate::models::user::UserReference::label)
566}
567
568#[derive(Debug, Deserialize)]
569struct TaskReferenceResponse {
570    data: Vec<TaskReference>,
571}
572
573#[derive(Debug, Serialize)]
574struct DependencyModifyRequest {
575    data: DependencyList,
576}
577
578#[derive(Debug, Serialize)]
579struct DependencyList {
580    #[serde(skip_serializing_if = "Vec::is_empty")]
581    dependencies: Vec<String>,
582}
583
584#[derive(Debug, Serialize)]
585struct DependentModifyRequest {
586    data: DependentList,
587}
588
589#[derive(Debug, Serialize)]
590struct DependentList {
591    #[serde(skip_serializing_if = "Vec::is_empty")]
592    dependents: Vec<String>,
593}
594
595#[derive(Debug, Serialize)]
596struct ProjectModifyRequest {
597    data: ProjectModifyData,
598}
599
600#[derive(Debug, Serialize)]
601struct ProjectModifyData {
602    project: String,
603    #[serde(skip_serializing_if = "Option::is_none")]
604    section: Option<String>,
605}
606
607#[derive(Debug, Serialize)]
608struct FollowersModifyRequest {
609    data: FollowersList,
610}
611
612#[derive(Debug, Serialize)]
613struct FollowersList {
614    #[serde(skip_serializing_if = "Vec::is_empty")]
615    followers: Vec<String>,
616}
617
618#[derive(Debug, Serialize)]
619struct TagModifyRequest {
620    data: TagModifyData,
621}
622
623#[derive(Debug, Serialize)]
624struct TagModifyData {
625    tag: String,
626}