use serde::{Deserialize, Serialize};
use crate::error::ApiError;
use crate::models::Due;
use crate::sync::Item;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct QuickAddRequest {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reminder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_reminder: Option<bool>,
}
impl QuickAddRequest {
pub fn new(text: impl Into<String>) -> Result<Self, ApiError> {
let text = text.into();
if text.trim().is_empty() {
return Err(ApiError::Validation {
field: Some("text".to_string()),
message: "task text cannot be empty".to_string(),
});
}
Ok(Self {
text,
note: None,
reminder: None,
auto_reminder: None,
})
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
pub fn with_reminder(mut self, reminder: impl Into<String>) -> Self {
self.reminder = Some(reminder.into());
self
}
pub fn with_auto_reminder(mut self, auto_reminder: bool) -> Self {
self.auto_reminder = Some(auto_reminder);
self
}
pub fn to_form_body(&self) -> String {
serde_urlencoded::to_string(self).expect("form serialization should not fail")
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct QuickAddResponse {
pub id: String,
#[serde(default)]
pub v2_id: Option<String>,
pub project_id: String,
#[serde(default)]
pub v2_project_id: Option<String>,
pub content: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default)]
pub due: Option<Due>,
#[serde(default)]
pub section_id: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub child_order: i32,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub added_by_uid: Option<String>,
#[serde(default)]
pub assigned_by_uid: Option<String>,
#[serde(default)]
pub responsible_uid: Option<String>,
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub added_at: Option<String>,
#[serde(default)]
pub resolved_project_name: Option<String>,
#[serde(default)]
pub resolved_assignee_name: Option<String>,
}
fn default_priority() -> i32 {
1
}
impl QuickAddResponse {
pub fn api_id(&self) -> &str {
self.v2_id.as_deref().unwrap_or(&self.id)
}
pub fn api_project_id(&self) -> &str {
self.v2_project_id.as_deref().unwrap_or(&self.project_id)
}
pub fn has_labels(&self) -> bool {
!self.labels.is_empty()
}
pub fn has_due_date(&self) -> bool {
self.due.is_some()
}
pub fn into_item(self) -> Item {
Item {
id: self.v2_id.unwrap_or(self.id),
user_id: self.added_by_uid.clone(),
project_id: self.v2_project_id.unwrap_or(self.project_id),
content: self.content,
description: self.description,
priority: self.priority,
due: self.due,
deadline: None,
parent_id: self.parent_id,
child_order: self.child_order,
section_id: self.section_id,
day_order: 0,
is_collapsed: false,
labels: self.labels,
added_by_uid: self.added_by_uid,
assigned_by_uid: self.assigned_by_uid,
responsible_uid: self.responsible_uid,
checked: self.checked,
is_deleted: self.is_deleted,
added_at: self.added_at,
updated_at: None,
completed_at: None,
duration: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quick_add_request_new() {
let request = QuickAddRequest::new("Buy milk tomorrow").unwrap();
assert_eq!(request.text, "Buy milk tomorrow");
assert!(request.note.is_none());
assert!(request.reminder.is_none());
assert!(request.auto_reminder.is_none());
}
#[test]
fn test_quick_add_request_new_empty_text_returns_error() {
let result = QuickAddRequest::new("");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
ApiError::Validation { field, message } => {
assert_eq!(field, Some("text".to_string()));
assert!(message.contains("empty"));
}
_ => panic!("Expected Validation error, got {:?}", err),
}
}
#[test]
fn test_quick_add_request_new_whitespace_only_returns_error() {
let result = QuickAddRequest::new(" \t\n ");
assert!(result.is_err());
let err = result.unwrap_err();
match err {
ApiError::Validation { field, message } => {
assert_eq!(field, Some("text".to_string()));
assert!(message.contains("empty"));
}
_ => panic!("Expected Validation error, got {:?}", err),
}
}
#[test]
fn test_quick_add_request_new_valid_text_with_whitespace() {
let request = QuickAddRequest::new(" Buy milk ").unwrap();
assert_eq!(request.text, " Buy milk ");
}
#[test]
fn test_quick_add_request_with_note() {
let request = QuickAddRequest::new("Call mom")
.unwrap()
.with_note("Ask about dinner plans");
assert_eq!(request.note, Some("Ask about dinner plans".to_string()));
}
#[test]
fn test_quick_add_request_with_reminder() {
let request = QuickAddRequest::new("Meeting at 3pm")
.unwrap()
.with_reminder("30 minutes before");
assert_eq!(request.reminder, Some("30 minutes before".to_string()));
}
#[test]
fn test_quick_add_request_with_auto_reminder() {
let request = QuickAddRequest::new("Meeting at 3pm")
.unwrap()
.with_auto_reminder(true);
assert_eq!(request.auto_reminder, Some(true));
}
#[test]
fn test_quick_add_request_builder_chain() {
let request = QuickAddRequest::new("Buy groceries tomorrow #Shopping @errands p2")
.unwrap()
.with_note("Don't forget the milk")
.with_reminder("1 hour before")
.with_auto_reminder(true);
assert_eq!(request.text, "Buy groceries tomorrow #Shopping @errands p2");
assert_eq!(request.note, Some("Don't forget the milk".to_string()));
assert_eq!(request.reminder, Some("1 hour before".to_string()));
assert_eq!(request.auto_reminder, Some(true));
}
#[test]
fn test_quick_add_request_to_form_body_minimal() {
let request = QuickAddRequest::new("Test task").unwrap();
let body = request.to_form_body();
let decoded: std::collections::HashMap<String, String> =
serde_urlencoded::from_str(&body).unwrap();
assert_eq!(decoded.get("text").unwrap(), "Test task");
}
#[test]
fn test_quick_add_request_to_form_body_full() {
let request = QuickAddRequest::new("Test task")
.unwrap()
.with_note("A note")
.with_reminder("tomorrow")
.with_auto_reminder(true);
let body = request.to_form_body();
let decoded: std::collections::HashMap<String, String> =
serde_urlencoded::from_str(&body).unwrap();
assert_eq!(decoded.get("text").unwrap(), "Test task");
assert_eq!(decoded.get("note").unwrap(), "A note");
assert_eq!(decoded.get("reminder").unwrap(), "tomorrow");
assert_eq!(decoded.get("auto_reminder").unwrap(), "true");
}
#[test]
fn test_quick_add_request_to_form_body_with_special_chars() {
let request = QuickAddRequest::new("Buy milk #Shopping @errands").unwrap();
let body = request.to_form_body();
let decoded: std::collections::HashMap<String, String> =
serde_urlencoded::from_str(&body).unwrap();
assert_eq!(decoded.get("text").unwrap(), "Buy milk #Shopping @errands");
}
#[test]
fn test_quick_add_response_deserialize_minimal() {
let json = r#"{
"id": "task-123",
"project_id": "proj-456",
"content": "Buy milk"
}"#;
let response: QuickAddResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.id, "task-123");
assert_eq!(response.project_id, "proj-456");
assert_eq!(response.content, "Buy milk");
assert_eq!(response.priority, 1);
assert!(!response.checked);
assert!(response.labels.is_empty());
assert!(!response.has_labels());
assert!(!response.has_due_date());
}
#[test]
fn test_quick_add_response_deserialize_full() {
let json = r#"{
"id": "task-123",
"project_id": "proj-456",
"content": "Buy groceries",
"description": "For the party",
"priority": 3,
"due": {
"date": "2026-01-26",
"datetime": "2026-01-26T15:00:00Z",
"string": "tomorrow at 3pm",
"is_recurring": false
},
"section_id": "section-789",
"labels": ["shopping", "urgent"],
"child_order": 1,
"checked": false,
"is_deleted": false,
"added_at": "2026-01-25T10:00:00Z",
"resolved_project_name": "Shopping List"
}"#;
let response: QuickAddResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.id, "task-123");
assert_eq!(response.content, "Buy groceries");
assert_eq!(response.description, "For the party");
assert_eq!(response.priority, 3);
assert!(response.has_due_date());
let due = response.due.as_ref().unwrap();
assert_eq!(due.date, "2026-01-26");
assert_eq!(due.datetime, Some("2026-01-26T15:00:00Z".to_string()));
assert_eq!(response.section_id, Some("section-789".to_string()));
assert!(response.has_labels());
assert_eq!(response.labels.len(), 2);
assert!(response.labels.contains(&"shopping".to_string()));
assert!(response.labels.contains(&"urgent".to_string()));
assert_eq!(
response.resolved_project_name,
Some("Shopping List".to_string())
);
}
#[test]
fn test_quick_add_response_into_item() {
let response = QuickAddResponse {
id: "task-123".to_string(),
v2_id: Some("v2-task-123".to_string()),
project_id: "proj-456".to_string(),
v2_project_id: Some("v2-proj-456".to_string()),
content: "Test task".to_string(),
description: "Description".to_string(),
priority: 2,
due: Some(Due {
date: "2026-01-26".to_string(),
datetime: None,
string: Some("tomorrow".to_string()),
timezone: None,
is_recurring: false,
lang: None,
}),
section_id: Some("section-1".to_string()),
parent_id: None,
child_order: 1,
labels: vec!["work".to_string()],
added_by_uid: Some("user-1".to_string()),
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: Some("2026-01-25T10:00:00Z".to_string()),
resolved_project_name: None,
resolved_assignee_name: None,
};
let item = response.into_item();
assert_eq!(item.id, "v2-task-123");
assert_eq!(item.project_id, "v2-proj-456");
assert_eq!(item.content, "Test task");
assert_eq!(item.priority, 2);
assert!(item.due.is_some());
assert_eq!(item.labels, vec!["work".to_string()]);
}
#[test]
fn test_quick_add_response_api_id() {
let response_with_v2 = QuickAddResponse {
id: "old-id".to_string(),
v2_id: Some("v2-id".to_string()),
project_id: "proj".to_string(),
v2_project_id: Some("v2-proj".to_string()),
content: "test".to_string(),
description: String::new(),
priority: 1,
due: None,
section_id: None,
parent_id: None,
child_order: 0,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
resolved_project_name: None,
resolved_assignee_name: None,
};
assert_eq!(response_with_v2.api_id(), "v2-id");
assert_eq!(response_with_v2.api_project_id(), "v2-proj");
let response_without_v2 = QuickAddResponse {
id: "old-id".to_string(),
v2_id: None,
project_id: "proj".to_string(),
v2_project_id: None,
content: "test".to_string(),
description: String::new(),
priority: 1,
due: None,
section_id: None,
parent_id: None,
child_order: 0,
labels: vec![],
added_by_uid: None,
assigned_by_uid: None,
responsible_uid: None,
checked: false,
is_deleted: false,
added_at: None,
resolved_project_name: None,
resolved_assignee_name: None,
};
assert_eq!(response_without_v2.api_id(), "old-id");
assert_eq!(response_without_v2.api_project_id(), "proj");
}
}