Skip to main content

asana_cli/models/
tag.rs

1//! Tag-oriented data structures, builders, and request payloads.
2
3use super::{user::UserReference, workspace::WorkspaceReference};
4use serde::{Deserialize, Serialize};
5use std::ops::Deref;
6use thiserror::Error;
7
8/// Compact tag reference used in listings.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
10#[serde(rename_all = "snake_case")]
11pub struct TagCompact {
12    /// Globally unique identifier.
13    pub gid: String,
14    /// Tag name.
15    pub name: String,
16    /// Resource type marker.
17    #[serde(default)]
18    pub resource_type: Option<String>,
19}
20
21impl TagCompact {
22    /// Human readable label.
23    #[must_use]
24    pub fn label(&self) -> &str {
25        &self.name
26    }
27}
28
29/// Supported tag colors in Asana.
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31#[serde(rename_all = "kebab-case")]
32pub enum TagColor {
33    /// Dark blue color.
34    DarkBlue,
35    /// Dark brown color.
36    DarkBrown,
37    /// Dark green color.
38    DarkGreen,
39    /// Dark orange color.
40    DarkOrange,
41    /// Dark pink color.
42    DarkPink,
43    /// Dark purple color.
44    DarkPurple,
45    /// Dark red color.
46    DarkRed,
47    /// Dark teal color.
48    DarkTeal,
49    /// Dark warm gray color.
50    DarkWarmGray,
51    /// Light blue color.
52    LightBlue,
53    /// Light brown color.
54    LightBrown,
55    /// Light green color.
56    LightGreen,
57    /// Light orange color.
58    LightOrange,
59    /// Light pink color.
60    LightPink,
61    /// Light purple color.
62    LightPurple,
63    /// Light red color.
64    LightRed,
65    /// Light teal color.
66    LightTeal,
67    /// Light warm gray color.
68    LightWarmGray,
69    /// Fallback for unsupported values.
70    #[serde(other)]
71    Unknown,
72}
73
74/// Full tag payload returned by the Asana API.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "snake_case")]
77pub struct Tag {
78    /// Globally unique identifier.
79    pub gid: String,
80    /// Tag name.
81    pub name: String,
82    /// Resource type marker.
83    #[serde(default)]
84    pub resource_type: Option<String>,
85    /// Tag color.
86    #[serde(default)]
87    pub color: Option<TagColor>,
88    /// Notes or description.
89    #[serde(default)]
90    pub notes: Option<String>,
91    /// Creation timestamp.
92    #[serde(default)]
93    pub created_at: Option<String>,
94    /// Followers for notifications.
95    #[serde(default)]
96    pub followers: Vec<UserReference>,
97    /// Workspace reference.
98    #[serde(default)]
99    pub workspace: Option<WorkspaceReference>,
100    /// Public permalink.
101    #[serde(default)]
102    pub permalink_url: Option<String>,
103}
104
105/// Parameters for listing tags via the API.
106#[derive(Debug, Clone, Default)]
107pub struct TagListParams {
108    /// Workspace filter (required for listing tags).
109    pub workspace: String,
110    /// Maximum number of items to fetch (client side).
111    pub limit: Option<usize>,
112}
113
114impl TagListParams {
115    /// Convert the structure into query string pairs.
116    #[must_use]
117    pub fn to_query(&self) -> Vec<(String, String)> {
118        vec![("workspace".into(), self.workspace.clone())]
119    }
120}
121
122/// Payload for creating tags.
123#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
124#[serde(rename_all = "snake_case")]
125pub struct TagCreateData {
126    /// Tag name (required).
127    pub name: String,
128    /// Workspace or organization identifier (required).
129    pub workspace: String,
130    /// Optional color.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub color: Option<TagColor>,
133    /// Optional notes.
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub notes: Option<String>,
136    /// Followers to notify.
137    #[serde(skip_serializing_if = "Vec::is_empty")]
138    pub followers: Vec<String>,
139}
140
141/// API envelope for create requests.
142#[derive(Debug, Clone, Serialize)]
143pub struct TagCreateRequest {
144    /// Wrapped data payload.
145    pub data: TagCreateData,
146}
147
148/// Builder for constructing validated tag create payloads.
149#[derive(Debug, Clone)]
150pub struct TagCreateBuilder {
151    data: TagCreateData,
152}
153
154impl TagCreateBuilder {
155    /// Start building a new tag payload with the required name and workspace.
156    #[must_use]
157    pub fn new(name: impl Into<String>, workspace: impl Into<String>) -> Self {
158        Self {
159            data: TagCreateData {
160                name: name.into(),
161                workspace: workspace.into(),
162                color: None,
163                notes: None,
164                followers: Vec::new(),
165            },
166        }
167    }
168
169    /// Override the tag name.
170    #[must_use]
171    pub fn name(mut self, name: impl Into<String>) -> Self {
172        self.data.name = name.into();
173        self
174    }
175
176    /// Set the tag color.
177    #[must_use]
178    pub fn color(mut self, color: TagColor) -> Self {
179        self.data.color = Some(color);
180        self
181    }
182
183    /// Provide notes or description.
184    #[must_use]
185    pub fn notes(mut self, notes: impl Into<String>) -> Self {
186        self.data.notes = Some(notes.into());
187        self
188    }
189
190    /// Add a follower identifier.
191    #[must_use]
192    pub fn follower(mut self, follower: impl Into<String>) -> Self {
193        let gid = follower.into();
194        if !self.data.followers.contains(&gid) {
195            self.data.followers.push(gid);
196        }
197        self
198    }
199
200    /// Finalise the builder into a request payload performing validation.
201    ///
202    /// # Errors
203    ///
204    /// Returns a validation error if mandatory fields are missing or invalid.
205    pub fn build(self) -> Result<TagCreateRequest, TagValidationError> {
206        if self.data.name.trim().is_empty() {
207            return Err(TagValidationError::MissingName);
208        }
209        if self.data.workspace.trim().is_empty() {
210            return Err(TagValidationError::MissingWorkspace);
211        }
212        Ok(TagCreateRequest { data: self.data })
213    }
214}
215
216/// Payload for updating existing tags.
217///
218/// Uses `Option<Option<T>>` for certain fields to distinguish three API states:
219/// - `None`: Don't update field (omit from JSON payload)
220/// - `Some(None)`: Clear field (send `null` in JSON to remove value)
221/// - `Some(Some(value))`: Set field to new value
222///
223/// This is required by the Asana API which treats missing fields differently from
224/// explicit `null` values. Omitting a field preserves its current value, while
225/// sending `null` clears it.
226#[allow(
227    clippy::option_option,
228    reason = "Option<Option<T>> models the API PATCH tri-state: absent leaves the field unchanged, null clears it, Some(v) sets it"
229)]
230#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
231#[serde(rename_all = "snake_case")]
232pub struct TagUpdateData {
233    /// Tag name update.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub name: Option<String>,
236    /// Color update.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub color: Option<TagColor>,
239    /// Notes update.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub notes: Option<Option<String>>,
242    /// Replace followers with the provided identifiers.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub followers: Option<Vec<String>>,
245}
246
247impl TagUpdateData {
248    /// Determine whether any fields have been set.
249    #[must_use]
250    pub const fn is_empty(&self) -> bool {
251        self.name.is_none()
252            && self.color.is_none()
253            && self.notes.is_none()
254            && self.followers.is_none()
255    }
256}
257
258/// API envelope for update requests.
259#[derive(Debug, Clone, Serialize)]
260pub struct TagUpdateRequest {
261    /// Wrapped data payload.
262    pub data: TagUpdateData,
263}
264
265/// Builder for constructing validated tag update payloads.
266#[derive(Debug, Default, Clone)]
267pub struct TagUpdateBuilder {
268    data: TagUpdateData,
269}
270
271impl TagUpdateBuilder {
272    /// Create a new empty builder.
273    #[must_use]
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Set the tag name.
279    #[must_use]
280    pub fn name(mut self, name: impl Into<String>) -> Self {
281        self.data.name = Some(name.into());
282        self
283    }
284
285    /// Set the tag color.
286    #[must_use]
287    pub fn color(mut self, color: TagColor) -> Self {
288        self.data.color = Some(color);
289        self
290    }
291
292    /// Set or update notes.
293    #[must_use]
294    pub fn notes(mut self, notes: impl Into<String>) -> Self {
295        self.data.notes = Some(Some(notes.into()));
296        self
297    }
298
299    /// Clear notes.
300    #[must_use]
301    pub fn clear_notes(mut self) -> Self {
302        self.data.notes = Some(None);
303        self
304    }
305
306    /// Replace followers with the provided identifiers.
307    #[must_use]
308    pub fn followers<I, S>(mut self, followers: I) -> Self
309    where
310        I: IntoIterator<Item = S>,
311        S: Into<String>,
312    {
313        let mut values: Vec<String> = followers.into_iter().map(Into::into).collect();
314        values.sort();
315        values.dedup();
316        self.data.followers = Some(values);
317        self
318    }
319
320    /// Finalise the builder.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if no fields were modified.
325    pub fn build(self) -> Result<TagUpdateRequest, TagValidationError> {
326        if self.data.is_empty() {
327            return Err(TagValidationError::EmptyUpdate);
328        }
329        Ok(TagUpdateRequest { data: self.data })
330    }
331}
332
333/// Errors emitted during tag payload validation.
334#[derive(Debug, Error, PartialEq, Eq)]
335pub enum TagValidationError {
336    /// Tag name was missing or blank.
337    #[error("tag name cannot be empty")]
338    MissingName,
339    /// Workspace identifier missing when creating a tag.
340    #[error("tags require a workspace identifier")]
341    MissingWorkspace,
342    /// Update payload did not contain any fields.
343    #[error("tag update payload does not include any changes")]
344    EmptyUpdate,
345}
346
347impl Deref for TagUpdateBuilder {
348    type Target = TagUpdateData;
349
350    fn deref(&self) -> &Self::Target {
351        &self.data
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn create_builder_requires_name() {
361        let builder = TagCreateBuilder::new("  ", "ws-123");
362        let result = builder.build();
363        assert_eq!(result.unwrap_err(), TagValidationError::MissingName);
364    }
365
366    #[test]
367    fn create_builder_requires_workspace() {
368        let builder = TagCreateBuilder::new("Important", "  ");
369        let result = builder.build();
370        assert_eq!(result.unwrap_err(), TagValidationError::MissingWorkspace);
371    }
372
373    #[test]
374    fn create_builder_success() {
375        let builder = TagCreateBuilder::new("Important", "ws-123")
376            .color(TagColor::DarkRed)
377            .notes("High priority items");
378        let request = builder.build().expect("builder should succeed");
379        assert_eq!(request.data.name, "Important");
380        assert_eq!(request.data.workspace, "ws-123");
381        assert_eq!(request.data.color, Some(TagColor::DarkRed));
382    }
383
384    #[test]
385    fn update_builder_requires_changes() {
386        let builder = TagUpdateBuilder::new();
387        let result = builder.build();
388        assert_eq!(result.unwrap_err(), TagValidationError::EmptyUpdate);
389    }
390
391    #[test]
392    fn update_builder_accepts_changes() {
393        let request = TagUpdateBuilder::new()
394            .name("Updated")
395            .color(TagColor::LightGreen)
396            .build()
397            .expect("builder should succeed");
398        assert_eq!(request.data.name.as_deref(), Some("Updated"));
399        assert_eq!(request.data.color, Some(TagColor::LightGreen));
400    }
401
402    #[test]
403    fn update_builder_clears_notes() {
404        let request = TagUpdateBuilder::new()
405            .clear_notes()
406            .build()
407            .expect("builder should succeed");
408        assert_eq!(request.data.notes, Some(None));
409    }
410}