use super::{
attachment::Attachment,
custom_field::{CustomField, CustomFieldValue},
user::UserReference,
workspace::WorkspaceReference,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Deref;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TaskReference {
pub gid: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub resource_type: Option<String>,
}
impl TaskReference {
#[must_use]
pub fn label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.gid.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TaskMembership {
#[serde(default)]
pub project: Option<TaskProjectReference>,
#[serde(default)]
pub section: Option<TaskSectionReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
#[serde(rename_all = "snake_case")]
pub struct TaskProjectReference {
pub gid: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub resource_type: Option<String>,
}
impl TaskProjectReference {
#[must_use]
pub fn label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.gid.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TaskSectionReference {
pub gid: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub resource_type: Option<String>,
}
impl TaskSectionReference {
#[must_use]
pub fn label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.gid.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
#[serde(rename_all = "snake_case")]
pub struct TaskTagReference {
pub gid: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub resource_type: Option<String>,
}
impl TaskTagReference {
#[must_use]
pub fn label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.gid.clone())
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskAssigneeStatus {
#[default]
Inbox,
Later,
Upcoming,
Today,
Waiting,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct Task {
pub gid: String,
pub name: String,
#[serde(default)]
pub resource_type: Option<String>,
#[serde(default)]
pub resource_subtype: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub html_notes: Option<String>,
#[serde(default)]
pub completed: bool,
#[serde(default)]
pub completed_at: Option<String>,
#[serde(default)]
pub completed_by: Option<UserReference>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub modified_at: Option<String>,
#[serde(default)]
pub due_on: Option<String>,
#[serde(default)]
pub due_at: Option<String>,
#[serde(default)]
pub start_on: Option<String>,
#[serde(default)]
pub start_at: Option<String>,
#[serde(default)]
pub assignee: Option<UserReference>,
#[serde(default)]
pub assignee_status: Option<TaskAssigneeStatus>,
#[serde(default)]
pub workspace: Option<WorkspaceReference>,
#[serde(default)]
pub parent: Option<TaskReference>,
#[serde(default)]
pub memberships: Vec<TaskMembership>,
#[serde(default)]
pub projects: Vec<TaskProjectReference>,
#[serde(default)]
pub tags: Vec<TaskTagReference>,
#[serde(default)]
pub followers: Vec<UserReference>,
#[serde(default)]
pub dependencies: Vec<TaskReference>,
#[serde(default)]
pub dependents: Vec<TaskReference>,
#[serde(default)]
pub custom_fields: Vec<CustomField>,
#[serde(default)]
pub attachments: Vec<Attachment>,
#[serde(default)]
pub permalink_url: Option<String>,
#[serde(default)]
pub num_subtasks: Option<i64>,
}
impl Task {
#[must_use]
pub const fn is_open(&self) -> bool {
!self.completed
}
}
#[derive(Debug, Clone, Default)]
pub struct TaskListParams {
pub workspace: Option<String>,
pub project: Option<String>,
pub section: Option<String>,
pub assignee: Option<String>,
pub completed_since: Option<String>,
pub modified_since: Option<String>,
pub due_on: Option<String>,
pub include_subtasks: bool,
pub limit: Option<usize>,
pub fields: BTreeSet<String>,
pub sort: Option<TaskSort>,
pub completed: Option<bool>,
pub due_before: Option<String>,
pub due_after: Option<String>,
}
impl TaskListParams {
#[must_use]
pub fn to_query(&self) -> Vec<(String, String)> {
let mut pairs = Vec::new();
if let Some(workspace) = &self.workspace {
pairs.push(("workspace".into(), workspace.clone()));
}
if let Some(project) = &self.project {
pairs.push(("project".into(), project.clone()));
}
if let Some(section) = &self.section {
pairs.push(("section".into(), section.clone()));
}
if let Some(assignee) = &self.assignee {
pairs.push(("assignee".into(), assignee.clone()));
}
if let Some(completed_since) = &self.completed_since {
pairs.push(("completed_since".into(), completed_since.clone()));
}
if let Some(modified_since) = &self.modified_since {
pairs.push(("modified_since".into(), modified_since.clone()));
}
if let Some(due_on) = &self.due_on {
pairs.push(("due_on".into(), due_on.clone()));
}
if !self.fields.is_empty() {
let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
pairs.push(("opt_fields".into(), field_list));
}
pairs
}
pub fn apply_post_filters(&self, tasks: &mut Vec<Task>) {
if let Some(expected) = self.completed {
tasks.retain(|task| task.completed == expected);
}
if let Some(due_before) = &self.due_before {
tasks.retain(|task| task.due_on.as_ref().is_some_and(|due| due <= due_before));
}
if let Some(due_after) = &self.due_after {
tasks.retain(|task| task.due_on.as_ref().is_some_and(|due| due >= due_after));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskSort {
Name,
DueOn,
CreatedAt,
ModifiedAt,
Assignee,
}
#[derive(Debug, Clone, Default)]
pub struct TaskSearchParams {
pub workspace: String,
pub text: Option<String>,
pub resource_subtype: Option<String>,
pub completed: Option<bool>,
pub is_subtask: Option<bool>,
pub is_blocked: Option<bool>,
pub has_attachment: Option<bool>,
pub assignee: Option<String>,
pub projects: Vec<String>,
pub sections: Vec<String>,
pub tags: Vec<String>,
pub created_after: Option<String>,
pub created_before: Option<String>,
pub modified_after: Option<String>,
pub modified_before: Option<String>,
pub due_after: Option<String>,
pub due_before: Option<String>,
pub sort_by: Option<String>,
pub sort_ascending: bool,
pub limit: Option<usize>,
pub fields: BTreeSet<String>,
}
impl TaskSearchParams {
#[must_use]
pub fn to_query(&self) -> Vec<(String, String)> {
let mut pairs = Vec::new();
if let Some(text) = &self.text {
pairs.push(("text".into(), text.clone()));
}
if let Some(subtype) = &self.resource_subtype {
pairs.push(("resource_subtype".into(), subtype.clone()));
}
if let Some(completed) = self.completed {
pairs.push(("completed".into(), completed.to_string()));
}
if let Some(is_subtask) = self.is_subtask {
pairs.push(("is_subtask".into(), is_subtask.to_string()));
}
if let Some(is_blocked) = self.is_blocked {
pairs.push(("is_blocked".into(), is_blocked.to_string()));
}
if let Some(has_attachment) = self.has_attachment {
pairs.push(("has_attachment".into(), has_attachment.to_string()));
}
if let Some(assignee) = &self.assignee {
pairs.push(("assignee.any".into(), assignee.clone()));
}
for project in &self.projects {
pairs.push(("projects.any".into(), project.clone()));
}
for section in &self.sections {
pairs.push(("sections.any".into(), section.clone()));
}
for tag in &self.tags {
pairs.push(("tags.any".into(), tag.clone()));
}
if let Some(date) = &self.created_after {
pairs.push(("created_at.after".into(), date.clone()));
}
if let Some(date) = &self.created_before {
pairs.push(("created_at.before".into(), date.clone()));
}
if let Some(date) = &self.modified_after {
pairs.push(("modified_at.after".into(), date.clone()));
}
if let Some(date) = &self.modified_before {
pairs.push(("modified_at.before".into(), date.clone()));
}
if let Some(date) = &self.due_after {
pairs.push(("due_on.after".into(), date.clone()));
}
if let Some(date) = &self.due_before {
pairs.push(("due_on.before".into(), date.clone()));
}
if let Some(sort_by) = &self.sort_by {
pairs.push(("sort_by".into(), sort_by.clone()));
pairs.push(("sort_ascending".into(), self.sort_ascending.to_string()));
}
if !self.fields.is_empty() {
let field_list = self.fields.iter().cloned().collect::<Vec<_>>().join(",");
pairs.push(("opt_fields".into(), field_list));
}
pairs
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TaskCreateData {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_on: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_at: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub followers: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub custom_fields: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskCreateRequest {
pub data: TaskCreateData,
}
#[derive(Debug, Clone)]
pub struct TaskCreateBuilder {
data: TaskCreateData,
}
impl TaskCreateBuilder {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self {
data: TaskCreateData {
name,
notes: None,
html_notes: None,
workspace: None,
projects: Vec::new(),
section: None,
parent: None,
assignee: None,
due_on: None,
due_at: None,
start_on: None,
start_at: None,
tags: Vec::new(),
followers: Vec::new(),
custom_fields: BTreeMap::new(),
},
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.data.name = name.into();
self
}
#[must_use]
pub fn notes(mut self, notes: impl Into<String>) -> Self {
self.data.notes = Some(notes.into());
self
}
#[must_use]
pub fn html_notes(mut self, notes: impl Into<String>) -> Self {
self.data.html_notes = Some(notes.into());
self
}
#[must_use]
pub fn workspace(mut self, workspace: impl Into<String>) -> Self {
self.data.workspace = Some(workspace.into());
self
}
#[must_use]
pub fn project(mut self, project: impl Into<String>) -> Self {
let gid = project.into();
if !self.data.projects.contains(&gid) {
self.data.projects.push(gid);
}
self
}
#[must_use]
pub fn section(mut self, section: impl Into<String>) -> Self {
self.data.section = Some(section.into());
self
}
#[must_use]
pub fn parent(mut self, parent: impl Into<String>) -> Self {
self.data.parent = Some(parent.into());
self
}
#[must_use]
pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
self.data.assignee = Some(assignee.into());
self
}
#[must_use]
pub fn due_on(mut self, due_on: impl Into<String>) -> Self {
self.data.due_on = Some(due_on.into());
self
}
#[must_use]
pub fn due_at(mut self, due_at: impl Into<String>) -> Self {
self.data.due_at = Some(due_at.into());
self
}
#[must_use]
pub fn start_on(mut self, start_on: impl Into<String>) -> Self {
self.data.start_on = Some(start_on.into());
self
}
#[must_use]
pub fn start_at(mut self, start_at: impl Into<String>) -> Self {
self.data.start_at = Some(start_at.into());
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<String>) -> Self {
let gid = tag.into();
if !self.data.tags.contains(&gid) {
self.data.tags.push(gid);
}
self
}
#[must_use]
pub fn follower(mut self, follower: impl Into<String>) -> Self {
let gid = follower.into();
if !self.data.followers.contains(&gid) {
self.data.followers.push(gid);
}
self
}
#[must_use]
pub fn custom_field(mut self, field_gid: impl Into<String>, value: CustomFieldValue) -> Self {
self.data
.custom_fields
.insert(field_gid.into(), value.into_value());
self
}
pub fn build(mut self) -> Result<TaskCreateRequest, TaskValidationError> {
if self.data.name.trim().is_empty() {
return Err(TaskValidationError::MissingName);
}
if self.data.workspace.is_none()
&& self.data.projects.is_empty()
&& self.data.parent.is_none()
{
return Err(TaskValidationError::MissingScope);
}
if let Some(ref html) = self.data.html_notes {
let prepared = crate::rich_text::prepare_rich_text(
html,
crate::rich_text::RichTextContext::TaskNotes,
)?;
self.data.html_notes = Some(prepared);
}
Ok(TaskCreateRequest { data: self.data })
}
}
#[allow(
clippy::option_option,
reason = "Option<Option<T>> models the API PATCH tri-state: absent leaves the field unchanged, null clears it, Some(v) sets it"
)]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TaskUpdateData {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_notes: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_on: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due_at: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_on: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_at: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub followers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub projects: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_fields: Option<BTreeMap<String, serde_json::Value>>,
}
impl TaskUpdateData {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.name.is_none()
&& self.notes.is_none()
&& self.html_notes.is_none()
&& self.completed.is_none()
&& self.assignee.is_none()
&& self.due_on.is_none()
&& self.due_at.is_none()
&& self.start_on.is_none()
&& self.start_at.is_none()
&& self.parent.is_none()
&& self.tags.is_none()
&& self.followers.is_none()
&& self.projects.is_none()
&& self.custom_fields.is_none()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskUpdateRequest {
pub data: TaskUpdateData,
}
#[derive(Debug, Default, Clone)]
pub struct TaskUpdateBuilder {
data: TaskUpdateData,
}
impl TaskUpdateBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.data.name = Some(name.into());
self
}
#[must_use]
pub fn notes(mut self, notes: impl Into<String>) -> Self {
self.data.notes = Some(Some(notes.into()));
self
}
#[must_use]
pub fn clear_notes(mut self) -> Self {
self.data.notes = Some(None);
self
}
#[must_use]
pub fn html_notes(mut self, notes: impl Into<String>) -> Self {
self.data.html_notes = Some(Some(notes.into()));
self
}
#[must_use]
pub fn clear_html_notes(mut self) -> Self {
self.data.html_notes = Some(None);
self
}
#[must_use]
pub fn completed(mut self, completed: bool) -> Self {
self.data.completed = Some(completed);
self
}
#[must_use]
pub fn assignee(mut self, assignee: impl Into<String>) -> Self {
self.data.assignee = Some(Some(assignee.into()));
self
}
#[must_use]
pub fn clear_assignee(mut self) -> Self {
self.data.assignee = Some(None);
self
}
#[must_use]
pub fn due_on(mut self, due_on: impl Into<String>) -> Self {
self.data.due_on = Some(Some(due_on.into()));
self
}
#[must_use]
pub fn clear_due_on(mut self) -> Self {
self.data.due_on = Some(None);
self
}
#[must_use]
pub fn due_at(mut self, due_at: impl Into<String>) -> Self {
self.data.due_at = Some(Some(due_at.into()));
self
}
#[must_use]
pub fn clear_due_at(mut self) -> Self {
self.data.due_at = Some(None);
self
}
#[must_use]
pub fn start_on(mut self, start_on: impl Into<String>) -> Self {
self.data.start_on = Some(Some(start_on.into()));
self
}
#[must_use]
pub fn clear_start_on(mut self) -> Self {
self.data.start_on = Some(None);
self
}
#[must_use]
pub fn start_at(mut self, start_at: impl Into<String>) -> Self {
self.data.start_at = Some(Some(start_at.into()));
self
}
#[must_use]
pub fn clear_start_at(mut self) -> Self {
self.data.start_at = Some(None);
self
}
#[must_use]
pub fn parent(mut self, parent: impl Into<String>) -> Self {
self.data.parent = Some(Some(parent.into()));
self
}
#[must_use]
pub fn clear_parent(mut self) -> Self {
self.data.parent = Some(None);
self
}
#[must_use]
pub fn tags<I, S>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut values: Vec<String> = tags.into_iter().map(Into::into).collect();
values.sort();
values.dedup();
self.data.tags = Some(values);
self
}
#[must_use]
pub fn followers<I, S>(mut self, followers: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut values: Vec<String> = followers.into_iter().map(Into::into).collect();
values.sort();
values.dedup();
self.data.followers = Some(values);
self
}
#[must_use]
pub fn projects<I, S>(mut self, projects: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut values: Vec<String> = projects.into_iter().map(Into::into).collect();
values.sort();
values.dedup();
self.data.projects = Some(values);
self
}
#[must_use]
pub fn custom_field(mut self, field_gid: impl Into<String>, value: CustomFieldValue) -> Self {
let map = self.data.custom_fields.get_or_insert_with(BTreeMap::new);
map.insert(field_gid.into(), value.into_value());
self
}
pub fn build(mut self) -> Result<TaskUpdateRequest, TaskValidationError> {
if self.data.is_empty() {
return Err(TaskValidationError::EmptyUpdate);
}
if let Some(Some(ref html)) = self.data.html_notes {
let prepared = crate::rich_text::prepare_rich_text(
html,
crate::rich_text::RichTextContext::TaskNotes,
)?;
self.data.html_notes = Some(Some(prepared));
}
Ok(TaskUpdateRequest { data: self.data })
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TaskValidationError {
#[error("task name cannot be empty")]
MissingName,
#[error("tasks require either a workspace or at least one project")]
MissingScope,
#[error("invalid html_notes: {0}")]
InvalidHtml(#[from] crate::rich_text::RichTextError),
#[error("task update payload does not include any changes")]
EmptyUpdate,
}
impl Deref for TaskUpdateBuilder {
type Target = TaskUpdateData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn create_builder_requires_name() {
let builder = TaskCreateBuilder::new(" ");
let result = builder.build();
assert_eq!(result.unwrap_err(), TaskValidationError::MissingName);
}
#[test]
fn create_builder_requires_scope() {
let builder = TaskCreateBuilder::new("Sample task").notes("demo");
let result = builder.build();
assert_eq!(result.unwrap_err(), TaskValidationError::MissingScope);
}
#[test]
fn create_builder_success() {
let builder = TaskCreateBuilder::new("Sample task")
.workspace("123")
.assignee("me");
let request = builder.build().expect("builder should succeed");
assert_eq!(request.data.name, "Sample task");
assert_eq!(request.data.workspace.as_deref(), Some("123"));
}
#[test]
fn create_builder_accepts_parent_scope() {
let request = TaskCreateBuilder::new("Child")
.parent("T1")
.build()
.expect("builder should succeed");
assert_eq!(request.data.parent.as_deref(), Some("T1"));
}
#[test]
fn create_builder_rejects_invalid_html_notes() {
let result = TaskCreateBuilder::new("Test")
.workspace("ws1")
.html_notes("<body><em>bad</strong></body>")
.build();
assert!(matches!(
result.unwrap_err(),
TaskValidationError::InvalidHtml(_)
));
}
#[test]
fn create_builder_prepares_html_notes() {
let request = TaskCreateBuilder::new("Test")
.workspace("ws1")
.html_notes("Tom & Jerry")
.build()
.expect("builder should succeed");
assert_eq!(
request.data.html_notes.as_deref(),
Some("<body>Tom & Jerry</body>")
);
}
#[test]
fn update_builder_requires_changes() {
let builder = TaskUpdateBuilder::new();
let result = builder.build();
assert_eq!(result.unwrap_err(), TaskValidationError::EmptyUpdate);
}
#[test]
fn update_builder_accepts_changes() {
let request = TaskUpdateBuilder::new()
.name("Updated")
.completed(true)
.build()
.expect("builder should succeed");
assert_eq!(request.data.name.as_deref(), Some("Updated"));
assert_eq!(request.data.completed, Some(true));
}
#[test]
fn update_builder_validates_html_notes() {
let result = TaskUpdateBuilder::new()
.html_notes("<body><em>bad</strong></body>")
.build();
assert!(matches!(
result.unwrap_err(),
TaskValidationError::InvalidHtml(_)
));
}
#[test]
fn update_builder_prepares_html_notes() {
let request = TaskUpdateBuilder::new()
.html_notes("fix & update")
.build()
.expect("builder should succeed");
assert_eq!(
request.data.html_notes,
Some(Some("<body>fix & update</body>".to_string()))
);
}
#[test]
fn update_builder_clear_html_notes_skips_validation() {
let request = TaskUpdateBuilder::new()
.clear_html_notes()
.build()
.expect("builder should succeed");
assert_eq!(request.data.html_notes, Some(None));
}
#[test]
fn create_builder_serializes_custom_field() {
let request = TaskCreateBuilder::new("With field")
.workspace("ws-1")
.custom_field("cf1", CustomFieldValue::Bool(true))
.build()
.expect("builder should succeed");
assert_eq!(
request.data.custom_fields.get("cf1"),
Some(&Value::Bool(true))
);
}
#[test]
fn update_builder_clears_assignee() {
let request = TaskUpdateBuilder::new()
.clear_assignee()
.build()
.expect("builder should succeed");
assert_eq!(request.data.assignee, Some(None));
}
#[test]
fn update_builder_sets_custom_field_value() {
let request = TaskUpdateBuilder::new()
.custom_field("cf1", CustomFieldValue::Number(5.0))
.build()
.expect("builder should succeed");
let map = request
.data
.custom_fields
.as_ref()
.expect("custom fields set");
assert!(
map.get("cf1")
.and_then(Value::as_f64)
.is_some_and(|value| (value - 5.0).abs() < f64::EPSILON)
);
}
}