use super::user::UserReference;
use serde::{Deserialize, Serialize};
use std::ops::Deref;
use thiserror::Error;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StoryType {
Comment,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[allow(
clippy::struct_field_names,
reason = "field names mirror the Asana API JSON keys; renaming to satisfy the lint would break serde mapping"
)]
pub struct StoryCompact {
pub gid: String,
#[serde(default)]
pub resource_type: Option<String>,
#[serde(rename = "type")]
pub story_type: StoryType,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[allow(
clippy::struct_field_names,
reason = "field names mirror the Asana API JSON keys; renaming to satisfy the lint would break serde mapping"
)]
pub struct Story {
pub gid: String,
#[serde(default)]
pub resource_type: Option<String>,
#[serde(rename = "type")]
pub story_type: StoryType,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub html_text: Option<String>,
#[serde(default)]
pub is_pinned: bool,
#[serde(default)]
pub is_editable: bool,
#[serde(default)]
pub is_edited: bool,
#[serde(default)]
pub created_by: Option<UserReference>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct StoryListParams {
pub task_gid: String,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct StoryCreateData {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_pinned: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StoryCreateRequest {
pub data: StoryCreateData,
}
#[derive(Debug, Clone)]
pub struct StoryCreateBuilder {
data: StoryCreateData,
}
impl StoryCreateBuilder {
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
data: StoryCreateData {
text: Some(text.into()),
html_text: None,
is_pinned: None,
},
}
}
#[must_use]
pub fn with_html(html_text: impl Into<String>) -> Self {
Self {
data: StoryCreateData {
text: None,
html_text: Some(html_text.into()),
is_pinned: None,
},
}
}
#[must_use]
pub fn pinned(mut self, pinned: bool) -> Self {
self.data.is_pinned = Some(pinned);
self
}
pub fn build(mut self) -> Result<StoryCreateRequest, StoryValidationError> {
if self.data.text.is_none() && self.data.html_text.is_none() {
return Err(StoryValidationError::MissingText);
}
if self.data.text.is_some() && self.data.html_text.is_some() {
return Err(StoryValidationError::BothTextFormats);
}
if let Some(ref html) = self.data.html_text {
let prepared = crate::rich_text::prepare_rich_text(
html,
crate::rich_text::RichTextContext::StoryText,
)?;
self.data.html_text = Some(prepared);
}
Ok(StoryCreateRequest { data: self.data })
}
}
#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct StoryUpdateData {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_pinned: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StoryUpdateRequest {
pub data: StoryUpdateData,
}
#[derive(Debug, Clone, Default)]
pub struct StoryUpdateBuilder {
data: StoryUpdateData,
}
impl StoryUpdateBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn text(mut self, text: impl Into<String>) -> Self {
self.data.text = Some(text.into());
self
}
#[must_use]
pub fn html_text(mut self, html: impl Into<String>) -> Self {
self.data.html_text = Some(html.into());
self
}
#[must_use]
pub fn pinned(mut self, pinned: bool) -> Self {
self.data.is_pinned = Some(pinned);
self
}
pub fn build(mut self) -> Result<StoryUpdateRequest, StoryValidationError> {
if self.data.text.is_none()
&& self.data.html_text.is_none()
&& self.data.is_pinned.is_none()
{
return Err(StoryValidationError::EmptyUpdate);
}
if let Some(ref html) = self.data.html_text {
let prepared = crate::rich_text::prepare_rich_text(
html,
crate::rich_text::RichTextContext::StoryText,
)?;
self.data.html_text = Some(prepared);
}
Ok(StoryUpdateRequest { data: self.data })
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum StoryValidationError {
#[error("story must have either text or html_text")]
MissingText,
#[error("story cannot have both text and html_text")]
BothTextFormats,
#[error("invalid html_text: {0}")]
InvalidHtml(#[from] crate::rich_text::RichTextError),
#[error("story update must change at least one field")]
EmptyUpdate,
}
impl Deref for StoryUpdateBuilder {
type Target = StoryUpdateData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_builder_requires_text() {
let builder = StoryCreateBuilder {
data: StoryCreateData {
text: None,
html_text: None,
is_pinned: None,
},
};
assert_eq!(
builder.build().unwrap_err(),
StoryValidationError::MissingText
);
}
#[test]
fn create_builder_rejects_both_formats() {
let builder = StoryCreateBuilder {
data: StoryCreateData {
text: Some("plain".into()),
html_text: Some("<b>html</b>".into()),
is_pinned: None,
},
};
assert_eq!(
builder.build().unwrap_err(),
StoryValidationError::BothTextFormats
);
}
#[test]
fn create_builder_success() {
let request = StoryCreateBuilder::new("This is a comment")
.pinned(true)
.build()
.unwrap();
assert_eq!(request.data.text.as_deref(), Some("This is a comment"));
assert_eq!(request.data.is_pinned, Some(true));
}
#[test]
fn create_builder_validates_html_text() {
let result = StoryCreateBuilder::with_html("<body><em>bad</strong></body>").build();
assert!(matches!(
result.unwrap_err(),
StoryValidationError::InvalidHtml(_)
));
}
#[test]
fn create_builder_prepares_html_text() {
let request = StoryCreateBuilder::with_html("comment & reply")
.build()
.expect("builder should succeed");
assert_eq!(
request.data.html_text.as_deref(),
Some("<body>comment & reply</body>")
);
}
#[test]
fn update_builder_requires_changes() {
let builder = StoryUpdateBuilder::new();
assert_eq!(
builder.build().unwrap_err(),
StoryValidationError::EmptyUpdate
);
}
#[test]
fn update_builder_validates_html_text() {
let result = StoryUpdateBuilder::new()
.html_text("<body><em>bad</strong></body>")
.build();
assert!(matches!(
result.unwrap_err(),
StoryValidationError::InvalidHtml(_)
));
}
#[test]
fn update_builder_prepares_html_text() {
let request = StoryUpdateBuilder::new()
.html_text("edit & revise")
.build()
.expect("builder should succeed");
assert_eq!(
request.data.html_text.as_deref(),
Some("<body>edit & revise</body>")
);
}
}