Skip to main content

asana_cli/models/
story.rs

1//! Story (comment) data structures for task activity.
2
3use super::user::UserReference;
4use serde::{Deserialize, Serialize};
5use std::ops::Deref;
6use thiserror::Error;
7
8/// Story type classification.
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum StoryType {
12    /// User-created comment.
13    Comment,
14    /// System-generated activity (not supported for creation).
15    System,
16}
17
18/// Compact story reference.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21#[allow(
22    clippy::struct_field_names,
23    reason = "field names mirror the Asana API JSON keys; renaming to satisfy the lint would break serde mapping"
24)]
25pub struct StoryCompact {
26    /// Globally unique identifier.
27    pub gid: String,
28    /// Resource type marker.
29    #[serde(default)]
30    pub resource_type: Option<String>,
31    /// Story type.
32    #[serde(rename = "type")]
33    pub story_type: StoryType,
34}
35
36/// Full story payload.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39#[allow(
40    clippy::struct_field_names,
41    reason = "field names mirror the Asana API JSON keys; renaming to satisfy the lint would break serde mapping"
42)]
43pub struct Story {
44    /// Globally unique identifier.
45    pub gid: String,
46    /// Resource type marker.
47    #[serde(default)]
48    pub resource_type: Option<String>,
49    /// Story type.
50    #[serde(rename = "type")]
51    pub story_type: StoryType,
52    /// Plain text content.
53    #[serde(default)]
54    pub text: Option<String>,
55    /// HTML formatted content.
56    #[serde(default)]
57    pub html_text: Option<String>,
58    /// Whether story is pinned.
59    #[serde(default)]
60    pub is_pinned: bool,
61    /// Whether story can be edited by current user.
62    #[serde(default)]
63    pub is_editable: bool,
64    /// Whether story has been edited.
65    #[serde(default)]
66    pub is_edited: bool,
67    /// Story author.
68    #[serde(default)]
69    pub created_by: Option<UserReference>,
70    /// Creation timestamp.
71    #[serde(default)]
72    pub created_at: Option<String>,
73}
74
75/// Parameters for listing stories.
76#[derive(Debug, Clone, Default)]
77pub struct StoryListParams {
78    /// Task identifier.
79    pub task_gid: String,
80    /// Maximum number to fetch.
81    pub limit: Option<usize>,
82}
83
84/// Payload for creating stories.
85#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub struct StoryCreateData {
88    /// Plain text content (required, mutually exclusive with `html_text`).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub text: Option<String>,
91    /// HTML formatted content (mutually exclusive with text).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub html_text: Option<String>,
94    /// Whether to pin the comment.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub is_pinned: Option<bool>,
97}
98
99/// API envelope for create requests.
100#[derive(Debug, Clone, Serialize)]
101pub struct StoryCreateRequest {
102    /// Story data payload.
103    pub data: StoryCreateData,
104}
105
106/// Builder for story create payloads.
107#[derive(Debug, Clone)]
108pub struct StoryCreateBuilder {
109    data: StoryCreateData,
110}
111
112impl StoryCreateBuilder {
113    /// Create builder with plain text.
114    #[must_use]
115    pub fn new(text: impl Into<String>) -> Self {
116        Self {
117            data: StoryCreateData {
118                text: Some(text.into()),
119                html_text: None,
120                is_pinned: None,
121            },
122        }
123    }
124
125    /// Create builder with HTML text.
126    #[must_use]
127    pub fn with_html(html_text: impl Into<String>) -> Self {
128        Self {
129            data: StoryCreateData {
130                text: None,
131                html_text: Some(html_text.into()),
132                is_pinned: None,
133            },
134        }
135    }
136
137    /// Set whether comment should be pinned.
138    #[must_use]
139    pub fn pinned(mut self, pinned: bool) -> Self {
140        self.data.is_pinned = Some(pinned);
141        self
142    }
143
144    /// Build the request.
145    ///
146    /// # Errors
147    /// Returns [`StoryValidationError`] if the request has missing or conflicting text fields.
148    pub fn build(mut self) -> Result<StoryCreateRequest, StoryValidationError> {
149        if self.data.text.is_none() && self.data.html_text.is_none() {
150            return Err(StoryValidationError::MissingText);
151        }
152        if self.data.text.is_some() && self.data.html_text.is_some() {
153            return Err(StoryValidationError::BothTextFormats);
154        }
155        if let Some(ref html) = self.data.html_text {
156            let prepared = crate::rich_text::prepare_rich_text(
157                html,
158                crate::rich_text::RichTextContext::StoryText,
159            )?;
160            self.data.html_text = Some(prepared);
161        }
162        Ok(StoryCreateRequest { data: self.data })
163    }
164}
165
166/// Payload for updating stories.
167#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169pub struct StoryUpdateData {
170    /// Updated plain text.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub text: Option<String>,
173    /// Updated HTML text.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub html_text: Option<String>,
176    /// Updated pin status.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub is_pinned: Option<bool>,
179}
180
181/// API envelope for update requests.
182#[derive(Debug, Clone, Serialize)]
183pub struct StoryUpdateRequest {
184    /// Story update data payload.
185    pub data: StoryUpdateData,
186}
187
188/// Builder for story update payloads.
189#[derive(Debug, Clone, Default)]
190pub struct StoryUpdateBuilder {
191    data: StoryUpdateData,
192}
193
194impl StoryUpdateBuilder {
195    /// Create a new empty update builder.
196    #[must_use]
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Set the plain text content.
202    #[must_use]
203    pub fn text(mut self, text: impl Into<String>) -> Self {
204        self.data.text = Some(text.into());
205        self
206    }
207
208    /// Set the HTML formatted content.
209    #[must_use]
210    pub fn html_text(mut self, html: impl Into<String>) -> Self {
211        self.data.html_text = Some(html.into());
212        self
213    }
214
215    /// Set whether the comment is pinned.
216    #[must_use]
217    pub fn pinned(mut self, pinned: bool) -> Self {
218        self.data.is_pinned = Some(pinned);
219        self
220    }
221
222    /// Build the update request.
223    ///
224    /// # Errors
225    /// Returns [`StoryValidationError`] if no fields are set to update.
226    pub fn build(mut self) -> Result<StoryUpdateRequest, StoryValidationError> {
227        if self.data.text.is_none()
228            && self.data.html_text.is_none()
229            && self.data.is_pinned.is_none()
230        {
231            return Err(StoryValidationError::EmptyUpdate);
232        }
233        if let Some(ref html) = self.data.html_text {
234            let prepared = crate::rich_text::prepare_rich_text(
235                html,
236                crate::rich_text::RichTextContext::StoryText,
237            )?;
238            self.data.html_text = Some(prepared);
239        }
240        Ok(StoryUpdateRequest { data: self.data })
241    }
242}
243
244/// Validation errors for story payloads.
245#[derive(Debug, Error, PartialEq, Eq)]
246pub enum StoryValidationError {
247    /// Story must have either text or `html_text`.
248    #[error("story must have either text or html_text")]
249    MissingText,
250    /// Story cannot have both text and `html_text`.
251    #[error("story cannot have both text and html_text")]
252    BothTextFormats,
253    /// HTML rich text content is not valid XML.
254    #[error("invalid html_text: {0}")]
255    InvalidHtml(#[from] crate::rich_text::RichTextError),
256    /// Story update must change at least one field.
257    #[error("story update must change at least one field")]
258    EmptyUpdate,
259}
260
261impl Deref for StoryUpdateBuilder {
262    type Target = StoryUpdateData;
263    fn deref(&self) -> &Self::Target {
264        &self.data
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn create_builder_requires_text() {
274        let builder = StoryCreateBuilder {
275            data: StoryCreateData {
276                text: None,
277                html_text: None,
278                is_pinned: None,
279            },
280        };
281        assert_eq!(
282            builder.build().unwrap_err(),
283            StoryValidationError::MissingText
284        );
285    }
286
287    #[test]
288    fn create_builder_rejects_both_formats() {
289        let builder = StoryCreateBuilder {
290            data: StoryCreateData {
291                text: Some("plain".into()),
292                html_text: Some("<b>html</b>".into()),
293                is_pinned: None,
294            },
295        };
296        assert_eq!(
297            builder.build().unwrap_err(),
298            StoryValidationError::BothTextFormats
299        );
300    }
301
302    #[test]
303    fn create_builder_success() {
304        let request = StoryCreateBuilder::new("This is a comment")
305            .pinned(true)
306            .build()
307            .unwrap();
308        assert_eq!(request.data.text.as_deref(), Some("This is a comment"));
309        assert_eq!(request.data.is_pinned, Some(true));
310    }
311
312    #[test]
313    fn create_builder_validates_html_text() {
314        let result = StoryCreateBuilder::with_html("<body><em>bad</strong></body>").build();
315
316        assert!(matches!(
317            result.unwrap_err(),
318            StoryValidationError::InvalidHtml(_)
319        ));
320    }
321
322    #[test]
323    fn create_builder_prepares_html_text() {
324        let request = StoryCreateBuilder::with_html("comment & reply")
325            .build()
326            .expect("builder should succeed");
327
328        assert_eq!(
329            request.data.html_text.as_deref(),
330            Some("<body>comment &amp; reply</body>")
331        );
332    }
333
334    #[test]
335    fn update_builder_requires_changes() {
336        let builder = StoryUpdateBuilder::new();
337        assert_eq!(
338            builder.build().unwrap_err(),
339            StoryValidationError::EmptyUpdate
340        );
341    }
342
343    #[test]
344    fn update_builder_validates_html_text() {
345        let result = StoryUpdateBuilder::new()
346            .html_text("<body><em>bad</strong></body>")
347            .build();
348
349        assert!(matches!(
350            result.unwrap_err(),
351            StoryValidationError::InvalidHtml(_)
352        ));
353    }
354
355    #[test]
356    fn update_builder_prepares_html_text() {
357        let request = StoryUpdateBuilder::new()
358            .html_text("edit & revise")
359            .build()
360            .expect("builder should succeed");
361
362        assert_eq!(
363            request.data.html_text.as_deref(),
364            Some("<body>edit &amp; revise</body>")
365        );
366    }
367}