use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use super::common::{Deadline, Due, Duration};
#[cfg(test)]
use super::common::DurationUnit;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub project_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<Due>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline: Option<Deadline>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<Duration>,
#[serde(default)]
pub is_completed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default)]
pub comment_count: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creator_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignee_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigner_id: Option<String>,
}
fn default_priority() -> i32 {
1
}
impl Task {
pub fn has_due_date(&self) -> bool {
self.due.is_some()
}
pub fn is_subtask(&self) -> bool {
self.parent_id.is_some()
}
pub fn is_recurring(&self) -> bool {
self.due.as_ref().is_some_and(|d| d.is_recurring)
}
pub fn due_date(&self) -> Option<NaiveDate> {
self.due
.as_ref()
.and_then(|d| NaiveDate::parse_from_str(&d.date, "%Y-%m-%d").ok())
}
pub fn is_high_priority(&self) -> bool {
self.priority >= 3
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Datelike;
#[test]
fn test_task_deserialize_minimal() {
let json = r#"{
"id": "123",
"content": "Buy milk",
"project_id": "456"
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert_eq!(task.id, "123");
assert_eq!(task.content, "Buy milk");
assert_eq!(task.project_id, "456");
assert_eq!(task.priority, 1);
assert!(!task.is_completed);
assert!(task.labels.is_empty());
}
#[test]
fn test_task_deserialize_full() {
let json = r#"{
"id": "123",
"content": "Buy milk",
"description": "From the store",
"project_id": "456",
"section_id": "789",
"parent_id": null,
"order": 1,
"labels": ["shopping", "urgent"],
"priority": 4,
"due": {
"date": "2026-01-25",
"datetime": "2026-01-25T15:00:00Z",
"is_recurring": false,
"string": "Jan 25 at 3pm",
"timezone": "America/New_York"
},
"is_completed": false,
"url": "https://todoist.com/app/task/123",
"comment_count": 2,
"created_at": "2026-01-20T10:00:00Z",
"creator_id": "user1",
"assignee_id": "user2",
"assigner_id": "user1"
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert_eq!(task.id, "123");
assert_eq!(task.content, "Buy milk");
assert_eq!(task.description, Some("From the store".to_string()));
assert_eq!(task.section_id, Some("789".to_string()));
assert_eq!(task.priority, 4);
assert!(task.has_due_date());
assert!(task.is_high_priority());
let due = task.due.as_ref().unwrap();
assert_eq!(due.date, "2026-01-25");
assert!(due.has_time());
assert!(!due.is_recurring);
}
#[test]
fn test_task_serialize() {
let task = Task {
id: "123".to_string(),
content: "Test task".to_string(),
description: None,
project_id: "456".to_string(),
section_id: None,
parent_id: None,
order: None,
labels: vec![],
priority: 1,
due: None,
deadline: None,
duration: None,
is_completed: false,
url: None,
comment_count: 0,
created_at: None,
creator_id: None,
assignee_id: None,
assigner_id: None,
};
let json = serde_json::to_string(&task).unwrap();
assert!(json.contains("\"id\":\"123\""));
assert!(json.contains("\"content\":\"Test task\""));
assert!(!json.contains("description"));
assert!(!json.contains("section_id"));
}
#[test]
fn test_task_is_subtask() {
let mut task = Task {
id: "123".to_string(),
content: "Test".to_string(),
description: None,
project_id: "456".to_string(),
section_id: None,
parent_id: None,
order: None,
labels: vec![],
priority: 1,
due: None,
deadline: None,
duration: None,
is_completed: false,
url: None,
comment_count: 0,
created_at: None,
creator_id: None,
assignee_id: None,
assigner_id: None,
};
assert!(!task.is_subtask());
task.parent_id = Some("parent123".to_string());
assert!(task.is_subtask());
}
#[test]
fn test_task_is_recurring() {
let task_no_due = Task {
id: "123".to_string(),
content: "Test".to_string(),
description: None,
project_id: "456".to_string(),
section_id: None,
parent_id: None,
order: None,
labels: vec![],
priority: 1,
due: None,
deadline: None,
duration: None,
is_completed: false,
url: None,
comment_count: 0,
created_at: None,
creator_id: None,
assignee_id: None,
assigner_id: None,
};
assert!(!task_no_due.is_recurring());
let task_recurring = Task {
due: Some(Due {
date: "2026-01-25".to_string(),
datetime: None,
is_recurring: true,
string: Some("every day".to_string()),
timezone: None,
lang: None,
}),
..task_no_due.clone()
};
assert!(task_recurring.is_recurring());
}
#[test]
fn test_task_due_date() {
let task = Task {
id: "123".to_string(),
content: "Test".to_string(),
description: None,
project_id: "456".to_string(),
section_id: None,
parent_id: None,
order: None,
labels: vec![],
priority: 1,
due: Some(Due::from_date("2026-01-25")),
deadline: None,
duration: None,
is_completed: false,
url: None,
comment_count: 0,
created_at: None,
creator_id: None,
assignee_id: None,
assigner_id: None,
};
let due_date = task.due_date().unwrap();
assert_eq!(due_date.year(), 2026);
assert_eq!(due_date.month(), 1);
assert_eq!(due_date.day(), 25);
}
#[test]
fn test_due_from_date() {
let due = Due::from_date("2026-01-25");
assert_eq!(due.date, "2026-01-25");
assert!(!due.is_recurring);
assert!(!due.has_time());
}
#[test]
fn test_due_from_datetime() {
let due = Due::from_datetime("2026-01-25", "2026-01-25T15:00:00Z");
assert_eq!(due.date, "2026-01-25");
assert_eq!(due.datetime, Some("2026-01-25T15:00:00Z".to_string()));
assert!(due.has_time());
}
#[test]
fn test_due_as_naive_date() {
let due = Due::from_date("2026-01-25");
let date = due.as_naive_date().unwrap();
assert_eq!(date.year(), 2026);
assert_eq!(date.month(), 1);
assert_eq!(date.day(), 25);
}
#[test]
fn test_deadline_deserialize() {
let json = r#"{"date": "2026-01-30"}"#;
let deadline: Deadline = serde_json::from_str(json).unwrap();
assert_eq!(deadline.date, "2026-01-30");
}
#[test]
fn test_duration_minutes() {
let duration = Duration::minutes(30);
assert_eq!(duration.amount, 30);
assert_eq!(duration.unit, DurationUnit::Minute);
assert_eq!(duration.as_minutes(), 30);
}
#[test]
fn test_duration_days() {
let duration = Duration::days(2);
assert_eq!(duration.amount, 2);
assert_eq!(duration.unit, DurationUnit::Day);
assert_eq!(duration.as_minutes(), 2 * 24 * 60);
}
#[test]
fn test_duration_unit_serialize() {
let minute = DurationUnit::Minute;
let day = DurationUnit::Day;
assert_eq!(serde_json::to_string(&minute).unwrap(), "\"minute\"");
assert_eq!(serde_json::to_string(&day).unwrap(), "\"day\"");
}
#[test]
fn test_duration_unit_deserialize() {
let minute: DurationUnit = serde_json::from_str("\"minute\"").unwrap();
let day: DurationUnit = serde_json::from_str("\"day\"").unwrap();
assert_eq!(minute, DurationUnit::Minute);
assert_eq!(day, DurationUnit::Day);
}
#[test]
fn test_task_with_duration() {
let json = r#"{
"id": "123",
"content": "Meeting",
"project_id": "456",
"duration": {
"amount": 60,
"unit": "minute"
}
}"#;
let task: Task = serde_json::from_str(json).unwrap();
let duration = task.duration.unwrap();
assert_eq!(duration.amount, 60);
assert_eq!(duration.unit, DurationUnit::Minute);
}
#[test]
fn test_task_with_deadline() {
let json = r#"{
"id": "123",
"content": "Project deadline",
"project_id": "456",
"deadline": {
"date": "2026-02-15"
}
}"#;
let task: Task = serde_json::from_str(json).unwrap();
let deadline = task.deadline.unwrap();
assert_eq!(deadline.date, "2026-02-15");
}
#[test]
fn test_task_priority_levels() {
let task_normal = Task {
id: "1".to_string(),
content: "Normal".to_string(),
description: None,
project_id: "p".to_string(),
section_id: None,
parent_id: None,
order: None,
labels: vec![],
priority: 1,
due: None,
deadline: None,
duration: None,
is_completed: false,
url: None,
comment_count: 0,
created_at: None,
creator_id: None,
assignee_id: None,
assigner_id: None,
};
assert!(!task_normal.is_high_priority());
let task_high = Task {
priority: 3,
..task_normal.clone()
};
assert!(task_high.is_high_priority());
let task_urgent = Task {
priority: 4,
..task_normal
};
assert!(task_urgent.is_high_priority());
}
#[test]
fn test_task_labels_deserialization() {
let json = r#"{
"id": "123",
"content": "Task with labels",
"project_id": "456",
"labels": ["work", "urgent", "review"]
}"#;
let task: Task = serde_json::from_str(json).unwrap();
assert_eq!(task.labels.len(), 3);
assert!(task.labels.contains(&"work".to_string()));
assert!(task.labels.contains(&"urgent".to_string()));
assert!(task.labels.contains(&"review".to_string()));
}
}