1use super::user::UserReference;
4use serde::{Deserialize, Serialize};
5use std::ops::Deref;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum StoryType {
12 Comment,
14 System,
16}
17
18#[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 pub gid: String,
28 #[serde(default)]
30 pub resource_type: Option<String>,
31 #[serde(rename = "type")]
33 pub story_type: StoryType,
34}
35
36#[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 pub gid: String,
46 #[serde(default)]
48 pub resource_type: Option<String>,
49 #[serde(rename = "type")]
51 pub story_type: StoryType,
52 #[serde(default)]
54 pub text: Option<String>,
55 #[serde(default)]
57 pub html_text: Option<String>,
58 #[serde(default)]
60 pub is_pinned: bool,
61 #[serde(default)]
63 pub is_editable: bool,
64 #[serde(default)]
66 pub is_edited: bool,
67 #[serde(default)]
69 pub created_by: Option<UserReference>,
70 #[serde(default)]
72 pub created_at: Option<String>,
73}
74
75#[derive(Debug, Clone, Default)]
77pub struct StoryListParams {
78 pub task_gid: String,
80 pub limit: Option<usize>,
82}
83
84#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
86#[serde(rename_all = "snake_case")]
87pub struct StoryCreateData {
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub text: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub html_text: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub is_pinned: Option<bool>,
97}
98
99#[derive(Debug, Clone, Serialize)]
101pub struct StoryCreateRequest {
102 pub data: StoryCreateData,
104}
105
106#[derive(Debug, Clone)]
108pub struct StoryCreateBuilder {
109 data: StoryCreateData,
110}
111
112impl StoryCreateBuilder {
113 #[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 #[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 #[must_use]
139 pub fn pinned(mut self, pinned: bool) -> Self {
140 self.data.is_pinned = Some(pinned);
141 self
142 }
143
144 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#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
168#[serde(rename_all = "snake_case")]
169pub struct StoryUpdateData {
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub text: Option<String>,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub html_text: Option<String>,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub is_pinned: Option<bool>,
179}
180
181#[derive(Debug, Clone, Serialize)]
183pub struct StoryUpdateRequest {
184 pub data: StoryUpdateData,
186}
187
188#[derive(Debug, Clone, Default)]
190pub struct StoryUpdateBuilder {
191 data: StoryUpdateData,
192}
193
194impl StoryUpdateBuilder {
195 #[must_use]
197 pub fn new() -> Self {
198 Self::default()
199 }
200
201 #[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 #[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 #[must_use]
217 pub fn pinned(mut self, pinned: bool) -> Self {
218 self.data.is_pinned = Some(pinned);
219 self
220 }
221
222 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#[derive(Debug, Error, PartialEq, Eq)]
246pub enum StoryValidationError {
247 #[error("story must have either text or html_text")]
249 MissingText,
250 #[error("story cannot have both text and html_text")]
252 BothTextFormats,
253 #[error("invalid html_text: {0}")]
255 InvalidHtml(#[from] crate::rich_text::RichTextError),
256 #[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 & 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 & revise</body>")
365 );
366 }
367}