Skip to main content

asana_cli/api/
projects.rs

1//! High level project operations built on the core API client.
2
3use crate::api::{SingleResponse, api_delete, api_post, api_put};
4use crate::{
5    api::{ApiClient, ApiError},
6    models::{
7        MemberPermission, Project, ProjectCreateRequest, ProjectListParams, ProjectMember,
8        ProjectMembers, ProjectSort, ProjectStatus, ProjectUpdateRequest,
9    },
10};
11use futures_util::{StreamExt, pin_mut};
12use serde::Serialize;
13
14/// Retrieve projects according to the supplied parameters.
15///
16/// # Errors
17///
18/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
19pub async fn list_projects(
20    client: &ApiClient,
21    mut params: ProjectListParams,
22) -> Result<Vec<Project>, ApiError> {
23    ensure_default_fields(&mut params);
24
25    let query = params.to_query();
26    let max_items = params.limit;
27    let stream = client.paginate_with_limit::<Project>("/projects", query, max_items);
28    pin_mut!(stream);
29
30    let mut projects = Vec::new();
31    while let Some(page) = stream.next().await {
32        let mut page = page?;
33        projects.append(&mut page);
34    }
35
36    if !params.filters.is_empty() {
37        projects.retain(|project| project.matches(&params.filters));
38    }
39
40    if let Some(sort) = params.sort {
41        sort_projects(&mut projects, sort);
42    }
43
44    Ok(projects)
45}
46
47/// Retrieve a single project by gid.
48///
49/// # Errors
50///
51/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
52pub async fn get_project(
53    client: &ApiClient,
54    gid: &str,
55    fields: Vec<String>,
56) -> Result<Project, ApiError> {
57    let mut query = Vec::new();
58    if !fields.is_empty() {
59        query.push(("opt_fields".into(), fields.join(",")));
60    }
61
62    let response: SingleResponse<Project> = client
63        .get_json_with_pairs(&format!("/projects/{gid}"), query)
64        .await?;
65    Ok(response.data)
66}
67
68/// Create a project using the provided payload.
69///
70/// # Errors
71///
72/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
73pub async fn create_project(
74    client: &ApiClient,
75    request: ProjectCreateRequest,
76) -> Result<Project, ApiError> {
77    api_post(client, "/projects", &request).await
78}
79
80/// Update a project using the given payload.
81///
82/// # Errors
83///
84/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
85pub async fn update_project(
86    client: &ApiClient,
87    gid: &str,
88    request: ProjectUpdateRequest,
89) -> Result<Project, ApiError> {
90    api_put(client, &format!("/projects/{gid}"), &request).await
91}
92
93/// Delete a project permanently.
94///
95/// # Errors
96///
97/// Returns an error if the API request fails or if the response is invalid.
98pub async fn delete_project(client: &ApiClient, gid: &str) -> Result<(), ApiError> {
99    api_delete(client, &format!("/projects/{gid}")).await
100}
101
102/// List project members.
103///
104/// # Errors
105///
106/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
107pub async fn list_members(client: &ApiClient, gid: &str) -> Result<ProjectMembers, ApiError> {
108    let response: SingleResponse<Vec<ProjectMember>> = client
109        .get_json_with_pairs(&format!("/projects/{gid}/members"), vec![])
110        .await?;
111    Ok(ProjectMembers {
112        project_gid: gid.to_string(),
113        members: response.data,
114    })
115}
116
117/// Retrieve recent project status updates.
118///
119/// # Errors
120///
121/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
122pub async fn list_statuses(
123    client: &ApiClient,
124    gid: &str,
125    limit: Option<usize>,
126) -> Result<Vec<ProjectStatus>, ApiError> {
127    let response: SingleResponse<Vec<ProjectStatus>> = client
128        .get_json_with_pairs(&format!("/projects/{gid}/project_statuses"), vec![])
129        .await?;
130    let mut statuses = response.data;
131    if let Some(limit) = limit
132        && statuses.len() > limit
133    {
134        statuses.truncate(limit);
135    }
136    Ok(statuses)
137}
138
139/// Add members to a project.
140///
141/// # Errors
142///
143/// Returns an error if the API request fails or if the response is invalid.
144pub async fn add_members(
145    client: &ApiClient,
146    gid: &str,
147    members: Vec<String>,
148    role: Option<MemberPermission>,
149) -> Result<(), ApiError> {
150    let payload = ModifyMembersRequest {
151        data: ModifyMembersData { members, role },
152    };
153    client
154        .post_void(&format!("/projects/{gid}/addMembers"), &payload)
155        .await
156}
157
158/// Remove members from a project.
159///
160/// # Errors
161///
162/// Returns an error if the API request fails or if the response is invalid.
163pub async fn remove_members(
164    client: &ApiClient,
165    gid: &str,
166    members: Vec<String>,
167) -> Result<(), ApiError> {
168    let payload = ModifyMembersRequest {
169        data: ModifyMembersData {
170            members,
171            role: None,
172        },
173    };
174    client
175        .post_void(&format!("/projects/{gid}/removeMembers"), &payload)
176        .await
177}
178
179/// Update a specific project member's permission level.
180///
181/// # Errors
182///
183/// Returns an error if the API request fails, if deserialization fails, or if the response is invalid.
184pub async fn update_member(
185    client: &ApiClient,
186    membership_gid: &str,
187    role: MemberPermission,
188) -> Result<ProjectMember, ApiError> {
189    let payload = UpdateMemberRequest {
190        data: MemberRoleUpdate { role },
191    };
192    let response: SingleResponse<ProjectMember> = client
193        .put_json(&format!("/project_memberships/{membership_gid}"), &payload)
194        .await?;
195    Ok(response.data)
196}
197
198fn ensure_default_fields(params: &mut ProjectListParams) {
199    let defaults = [
200        "gid",
201        "name",
202        "archived",
203        "color",
204        "created_at",
205        "modified_at",
206        "workspace.name",
207        "workspace.gid",
208        "team.name",
209        "team.gid",
210        "owner.name",
211        "owner.gid",
212        "due_on",
213        "start_on",
214    ];
215    for field in defaults {
216        params.fields.insert(field.to_string());
217    }
218}
219
220fn sort_projects(projects: &mut [Project], sort: ProjectSort) {
221    match sort {
222        ProjectSort::Name => projects.sort_by(|a, b| {
223            a.name
224                .to_ascii_lowercase()
225                .cmp(&b.name.to_ascii_lowercase())
226        }),
227        ProjectSort::CreatedAt => projects.sort_by(|a, b| a.created_at.cmp(&b.created_at)),
228        ProjectSort::ModifiedAt => projects.sort_by(|a, b| a.modified_at.cmp(&b.modified_at)),
229    }
230}
231
232#[derive(Debug, Serialize)]
233struct ModifyMembersRequest {
234    data: ModifyMembersData,
235}
236
237#[derive(Debug, Serialize)]
238struct ModifyMembersData {
239    members: Vec<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    role: Option<MemberPermission>,
242}
243
244#[derive(Debug, Serialize)]
245struct UpdateMemberRequest {
246    data: MemberRoleUpdate,
247}
248
249#[derive(Debug, Serialize)]
250struct MemberRoleUpdate {
251    role: MemberPermission,
252}