Skip to main content

asana_cli/models/
project.rs

1//! Project domain models and request payload helpers.
2
3use super::{user::UserReference, workspace::WorkspaceReference};
4use clap::ValueEnum;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use serde_with::serde_as;
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::PathBuf;
10
11/// Permission levels exposed for project members.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ValueEnum)]
13#[serde(rename_all = "snake_case")]
14pub enum MemberPermission {
15    /// Full access.
16    Member,
17    /// Read/write limited to comments.
18    Commenter,
19    /// View only.
20    Viewer,
21}
22
23/// Full project payload returned from Asana.
24#[serde_as]
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub struct Project {
28    /// Project identifier.
29    pub gid: String,
30    /// Display name.
31    pub name: String,
32    /// Resource type marker.
33    #[serde(default)]
34    pub resource_type: Option<String>,
35    /// Optional description/notes.
36    #[serde(default)]
37    pub notes: Option<String>,
38    /// Color slug when assigned.
39    #[serde(default)]
40    pub color: Option<String>,
41    /// Whether the project has been archived.
42    #[serde(default)]
43    pub archived: bool,
44    /// Visibility flag (public/private).
45    #[serde(default)]
46    pub public: Option<bool>,
47    /// ISO 8601 due date.
48    #[serde(default)]
49    pub due_on: Option<String>,
50    /// ISO 8601 start date.
51    #[serde(default)]
52    pub start_on: Option<String>,
53    /// Creation timestamp.
54    #[serde(default)]
55    pub created_at: Option<String>,
56    /// Last modification timestamp.
57    #[serde(default)]
58    pub modified_at: Option<String>,
59    /// Owning workspace.
60    #[serde(default)]
61    pub workspace: Option<WorkspaceReference>,
62    /// Owning team (Asana represents teams as workspaces with `resource_type` `team`).
63    #[serde(default)]
64    pub team: Option<WorkspaceReference>,
65    /// Project owner.
66    #[serde(default)]
67    pub owner: Option<UserReference>,
68    /// Members when requested via `opt_fields`.
69    #[serde(default)]
70    pub members: Vec<ProjectMember>,
71    /// Recent status updates when requested separately.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub statuses: Vec<ProjectStatus>,
74    /// Arbitrary custom fields.
75    #[serde(default)]
76    pub custom_fields: BTreeMap<String, serde_json::Value>,
77}
78
79impl Project {
80    /// Determine whether the project matches a set of filters.
81    #[must_use]
82    pub fn matches(&self, filters: &[ProjectFilter]) -> bool {
83        filters.iter().all(|filter| filter.matches(self))
84    }
85}
86
87/// Response payload for project members endpoints.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct ProjectMembers {
90    /// Project identifier.
91    pub project_gid: String,
92    /// Member collection.
93    pub members: Vec<ProjectMember>,
94}
95
96/// Simplified project member representation.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct ProjectMember {
99    /// Member identifier.
100    pub gid: String,
101    /// Linked user reference.
102    pub user: UserReference,
103    /// Optional role (commenter/viewer/member).
104    #[serde(default)]
105    pub role: Option<MemberPermission>,
106}
107
108/// Summary of a project status update.
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "snake_case")]
111pub struct ProjectStatus {
112    /// Status identifier.
113    pub gid: String,
114    /// Status headline.
115    #[serde(default)]
116    pub title: Option<String>,
117    /// Status color (green, yellow, red).
118    #[serde(default)]
119    pub color: Option<String>,
120    /// Rich text body when provided.
121    #[serde(default)]
122    pub text: Option<String>,
123    /// Creation timestamp.
124    #[serde(default)]
125    pub created_at: Option<String>,
126    /// Author metadata.
127    #[serde(default)]
128    pub created_by: Option<UserReference>,
129}
130
131/// Parameters accepted by the `/projects` listing endpoint.
132#[derive(Debug, Clone, Default)]
133pub struct ProjectListParams {
134    /// Optional workspace filter.
135    pub workspace: Option<String>,
136    /// Optional team filter.
137    pub team: Option<String>,
138    /// Filter archived flag.
139    pub archived: Option<bool>,
140    /// Additional fields to request.
141    pub fields: BTreeSet<String>,
142    /// Maximum number of items to fetch (client side constraint).
143    pub limit: Option<usize>,
144    /// Optional saved filter expressions.
145    pub filters: Vec<ProjectFilter>,
146    /// Sort field.
147    pub sort: Option<ProjectSort>,
148}
149
150impl ProjectListParams {
151    /// Transform parameters into query pairs for the API.
152    #[must_use]
153    pub fn to_query(&self) -> Vec<(String, String)> {
154        let mut pairs = Vec::new();
155        if let Some(workspace) = &self.workspace {
156            pairs.push(("workspace".into(), workspace.clone()));
157        }
158        if let Some(team) = &self.team {
159            pairs.push(("team".into(), team.clone()));
160        }
161        if let Some(archived) = self.archived {
162            pairs.push(("archived".into(), archived.to_string()));
163        }
164        if !self.fields.is_empty() {
165            let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
166            pairs.push(("opt_fields".into(), field_list));
167        }
168        pairs
169    }
170}
171
172/// Sort options supported by the CLI.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ProjectSort {
175    /// Sort by project name (case insensitive).
176    Name,
177    /// Sort by creation timestamp.
178    CreatedAt,
179    /// Sort by modification timestamp.
180    ModifiedAt,
181}
182
183/// Statement describing a single filter operation.
184#[derive(Debug, Clone)]
185pub enum ProjectFilter {
186    /// Field equality.
187    Equals(String, String),
188    /// Field inequality.
189    NotEquals(String, String),
190    /// Regular expression match.
191    Regex(String, Regex),
192    /// Substring match.
193    Contains(String, String),
194}
195
196impl ProjectFilter {
197    /// Evaluate filter against a project instance.
198    #[must_use]
199    pub fn matches(&self, project: &Project) -> bool {
200        match self {
201            Self::Equals(field, expected) => {
202                field_value(project, field).is_some_and(|value| value == expected.as_str())
203            }
204            Self::NotEquals(field, forbidden) => {
205                field_value(project, field).is_none_or(|value| value != forbidden.as_str())
206            }
207            Self::Regex(field, pattern) => {
208                field_value(project, field).is_some_and(|value| pattern.is_match(&value))
209            }
210            Self::Contains(field, needle) => field_value(project, field).is_some_and(|value| {
211                value
212                    .to_ascii_lowercase()
213                    .contains(&needle.to_ascii_lowercase())
214            }),
215        }
216    }
217}
218
219fn field_value(project: &Project, field: &str) -> Option<String> {
220    match field {
221        "name" => Some(project.name.clone()),
222        "gid" => Some(project.gid.clone()),
223        "notes" => project.notes.clone(),
224        "color" => project.color.clone(),
225        "archived" => Some(project.archived.to_string()),
226        "public" => project.public.map(|value| value.to_string()),
227        "due_on" => project.due_on.clone(),
228        "start_on" => project.start_on.clone(),
229        "created_at" => project.created_at.clone(),
230        "modified_at" => project.modified_at.clone(),
231        "workspace" => project
232            .workspace
233            .as_ref()
234            .map(super::workspace::WorkspaceReference::label),
235        "team" => project
236            .team
237            .as_ref()
238            .map(super::workspace::WorkspaceReference::label),
239        "owner" => project
240            .owner
241            .as_ref()
242            .map(super::user::UserReference::label),
243        "owner.name" | "owner_name" => project.owner.as_ref().and_then(|owner| owner.name.clone()),
244        "owner.email" | "owner_email" => {
245            project.owner.as_ref().and_then(|owner| owner.email.clone())
246        }
247        other => project
248            .custom_fields
249            .get(other)
250            .and_then(|value| value.as_str().map(ToString::to_string)),
251    }
252}
253
254/// Request payload for creating a project.
255#[derive(Debug, Clone, Serialize, Deserialize, Default)]
256#[serde(rename_all = "snake_case")]
257pub struct ProjectCreateData {
258    /// Name of the new project.
259    pub name: String,
260    /// Workspace or organization identifier.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub workspace: Option<String>,
263    /// Team identifier when applicable.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub team: Option<String>,
266    /// Notes/description.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub notes: Option<String>,
269    /// Color slug.
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub color: Option<String>,
272    /// Start date.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub start_on: Option<String>,
275    /// Due date.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub due_on: Option<String>,
278    /// Whether the project is public.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub public: Option<bool>,
281    /// Owner identifier.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub owner: Option<String>,
284    /// List of member identifiers.
285    #[serde(skip_serializing_if = "Vec::is_empty")]
286    pub members: Vec<String>,
287    /// Custom field assignments.
288    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
289    pub custom_fields: BTreeMap<String, serde_json::Value>,
290}
291
292/// Envelope for create requests.
293#[derive(Debug, Clone, Serialize)]
294pub struct ProjectCreateRequest {
295    /// Wrapped data payload.
296    pub data: ProjectCreateData,
297}
298
299/// Request payload for updating existing projects.
300#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
301#[serde(rename_all = "snake_case")]
302pub struct ProjectUpdateData {
303    /// New project name.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub name: Option<String>,
306    /// Notes/description.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub notes: Option<String>,
309    /// Color slug.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub color: Option<String>,
312    /// Start date.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub start_on: Option<String>,
315    /// Due date.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub due_on: Option<String>,
318    /// Archive toggle.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub archived: Option<bool>,
321    /// Privacy flag.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub public: Option<bool>,
324    /// Owner change.
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub owner: Option<String>,
327}
328
329impl ProjectUpdateData {
330    /// Determine whether any fields have been populated.
331    #[must_use]
332    pub const fn is_empty(&self) -> bool {
333        self.name.is_none()
334            && self.notes.is_none()
335            && self.color.is_none()
336            && self.start_on.is_none()
337            && self.due_on.is_none()
338            && self.archived.is_none()
339            && self.public.is_none()
340            && self.owner.is_none()
341    }
342}
343
344/// Envelope for update requests.
345#[derive(Debug, Clone, Serialize)]
346pub struct ProjectUpdateRequest {
347    /// Wrapped data payload.
348    pub data: ProjectUpdateData,
349}
350
351/// Template definition stored on disk.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub struct ProjectTemplate {
355    /// Template name for display.
356    pub name: String,
357    /// Underlying project configuration.
358    pub project: ProjectCreateData,
359    /// Optional description for humans.
360    #[serde(default)]
361    pub description: Option<String>,
362    /// Tag metadata applied during listing.
363    #[serde(default)]
364    pub tags: Vec<String>,
365    /// Source file path, populated at load time.
366    #[serde(skip)]
367    pub source: Option<PathBuf>,
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use regex::Regex;
374
375    fn sample_project() -> Project {
376        Project {
377            gid: "P1".into(),
378            name: "Demo Project".into(),
379            resource_type: None,
380            notes: Some("Demo".into()),
381            color: None,
382            archived: false,
383            public: Some(true),
384            due_on: None,
385            start_on: None,
386            created_at: None,
387            modified_at: None,
388            workspace: Some(WorkspaceReference {
389                gid: "W1".into(),
390                name: Some("Engineering".into()),
391                resource_type: None,
392            }),
393            team: None,
394            owner: Some(UserReference {
395                gid: "U1".into(),
396                name: Some("Owner".into()),
397                resource_type: None,
398                email: Some("owner@example.com".into()),
399            }),
400            members: Vec::new(),
401            statuses: Vec::new(),
402            custom_fields: BTreeMap::new(),
403        }
404    }
405
406    #[test]
407    fn equals_filter_matches_project_name() {
408        let project = sample_project();
409        let filter = ProjectFilter::Equals("name".into(), "Demo Project".into());
410        assert!(filter.matches(&project));
411    }
412
413    #[test]
414    fn regex_filter_matches_owner_email() {
415        let project = sample_project();
416        let filter = ProjectFilter::Regex(
417            "owner.email".into(),
418            Regex::new(r"(?i)owner@example\.com").unwrap(),
419        );
420        assert!(filter.matches(&project));
421    }
422
423    #[test]
424    fn update_data_is_empty_when_no_fields_set() {
425        let mut data = ProjectUpdateData::default();
426        assert!(data.is_empty());
427        data.archived = Some(true);
428        assert!(!data.is_empty());
429    }
430}