use std::fmt;
use std::str::FromStr;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::ThingsError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ThingsId(String);
impl ThingsId {
#[must_use]
pub fn new_v4() -> Self {
Self(Uuid::new_v4().to_string())
}
#[must_use]
pub fn new_things_native() -> Self {
let bytes = *Uuid::new_v4().as_bytes();
Self(base62_encode_22(&bytes))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
pub(crate) fn from_trusted(s: String) -> Self {
Self(s)
}
fn is_things_native(s: &str) -> bool {
let len = s.len();
(len == 21 || len == 22) && s.chars().all(|c| c.is_ascii_alphanumeric())
}
#[cfg(target_os = "macos")]
pub(crate) fn as_things_native(&self) -> Result<&str, ThingsError> {
if Self::is_things_native(&self.0) {
Ok(&self.0)
} else {
Err(ThingsError::validation(format!(
"ID {:?} is not in Things native format (21–22-char Base62) \
and cannot be referenced via AppleScript. This entity was \
likely created on Linux/CI or with --unsafe-direct-db. \
Recreate it in Things 3, or set THINGS_UNSAFE_DIRECT_DB=1 \
to mutate via direct SQLite writes.",
self.0
)))
}
}
}
fn base62_encode_22(bytes: &[u8; 16]) -> String {
const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let mut n = u128::from_be_bytes(*bytes);
let mut out = [b'0'; 22];
for slot in out.iter_mut().rev() {
*slot = ALPHABET[(n % 62) as usize];
n /= 62;
}
String::from_utf8(out.to_vec()).expect("alphabet is ASCII")
}
impl fmt::Display for ThingsId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for ThingsId {
type Err = ThingsError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if Uuid::parse_str(s).is_ok() {
return Ok(Self(s.to_string()));
}
if Self::is_things_native(s) {
return Ok(Self(s.to_string()));
}
Err(ThingsError::validation(format!(
"invalid Things 3 identifier {s:?}: expected RFC-4122 UUID \
(36 chars, hex+hyphens) or Things native ID (21–22 base62 chars)"
)))
}
}
impl From<Uuid> for ThingsId {
fn from(uuid: Uuid) -> Self {
Self(uuid.to_string())
}
}
impl AsRef<str> for ThingsId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod things_id_tests {
use super::*;
#[test]
fn new_v4_produces_hyphenated_uuid_string() {
let id = ThingsId::new_v4();
let s = id.as_str();
assert_eq!(s.len(), 36);
assert!(Uuid::parse_str(s).is_ok());
}
#[test]
fn new_things_native_produces_22_char_base62() {
let id = ThingsId::new_things_native();
let s = id.as_str();
assert_eq!(s.len(), 22);
assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
assert!(ThingsId::is_things_native(s));
}
#[test]
fn new_things_native_round_trips_through_from_str() {
let original = ThingsId::new_things_native();
let parsed: ThingsId = original.as_str().parse().unwrap();
assert_eq!(original, parsed);
}
#[test]
fn new_things_native_yields_unique_ids() {
use std::collections::HashSet;
let ids: HashSet<_> = (0..1000).map(|_| ThingsId::new_things_native()).collect();
assert_eq!(ids.len(), 1000);
}
#[cfg(target_os = "macos")]
#[test]
fn as_things_native_accepts_native_id() {
let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
assert_eq!(id.as_things_native().unwrap(), "R4t2G8Q63aGZq4epMHNeCr");
}
#[cfg(target_os = "macos")]
#[test]
fn as_things_native_accepts_21_char_native_id() {
let id: ThingsId = "19KLMeA2ULbixtvNbXsDK".parse().unwrap();
assert_eq!(id.as_things_native().unwrap(), "19KLMeA2ULbixtvNbXsDK");
}
#[cfg(target_os = "macos")]
#[test]
fn as_things_native_rejects_hyphenated_uuid() {
let id: ThingsId = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e".parse().unwrap();
let err = id.as_things_native().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not in Things native format"),
"missing format hint, got: {msg}"
);
assert!(msg.contains("Recreate"), "missing remediation, got: {msg}");
}
#[test]
fn base62_encode_22_pads_zero_input() {
let s = base62_encode_22(&[0u8; 16]);
assert_eq!(s, "0".repeat(22));
}
#[test]
fn base62_encode_22_handles_max_input() {
let s = base62_encode_22(&[0xFFu8; 16]);
assert_eq!(s.len(), 22);
assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn from_str_accepts_hyphenated_uuid() {
let s = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e";
let id: ThingsId = s.parse().unwrap();
assert_eq!(id.as_str(), s);
}
#[test]
fn from_str_accepts_22_char_native_id() {
let s = "R4t2G8Q63aGZq4epMHNeCr";
assert_eq!(s.len(), 22);
let id: ThingsId = s.parse().unwrap();
assert_eq!(id.as_str(), s);
}
#[test]
fn from_str_accepts_21_char_native_id() {
let s = "19KLMeA2ULbixtvNbXsDK";
assert_eq!(s.len(), 21);
let id: ThingsId = s.parse().unwrap();
assert_eq!(id.as_str(), s);
}
#[test]
fn from_str_rejects_short_garbage() {
let err = "abc".parse::<ThingsId>().unwrap_err();
assert!(matches!(err, ThingsError::Validation { .. }));
}
#[test]
fn from_str_rejects_long_garbage() {
let err = "ZZZZZZZZZZZZZZZZZZZZZZZ".parse::<ThingsId>().unwrap_err();
assert!(matches!(err, ThingsError::Validation { .. }));
}
#[test]
fn from_str_rejects_native_with_special_chars() {
let err = "R4t2G8Q63aGZq4epMHN-Cr".parse::<ThingsId>().unwrap_err();
assert!(matches!(err, ThingsError::Validation { .. }));
}
#[test]
fn from_str_rejects_empty() {
let err = "".parse::<ThingsId>().unwrap_err();
assert!(matches!(err, ThingsError::Validation { .. }));
}
#[test]
fn from_str_rejects_uuid_with_extra_chars() {
let err = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e-XYZ"
.parse::<ThingsId>()
.unwrap_err();
assert!(matches!(err, ThingsError::Validation { .. }));
}
#[test]
fn display_is_the_inner_string() {
let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
assert_eq!(format!("{id}"), "R4t2G8Q63aGZq4epMHNeCr");
}
#[test]
fn from_uuid_wraps_hyphenated_form() {
let uuid = Uuid::parse_str("9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e").unwrap();
let id: ThingsId = uuid.into();
assert_eq!(id.as_str(), "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e");
}
#[test]
fn serde_roundtrips_as_bare_string() {
let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"R4t2G8Q63aGZq4epMHNeCr\"");
let back: ThingsId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}
#[test]
fn equality_is_string_equality() {
let a: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
let b: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
assert_eq!(a, b);
}
#[test]
fn from_trusted_skips_validation() {
let id = ThingsId::from_trusted("anything-goes-here".to_string());
assert_eq!(id.as_str(), "anything-goes-here");
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskStatus {
#[serde(rename = "incomplete")]
Incomplete,
#[serde(rename = "completed")]
Completed,
#[serde(rename = "canceled")]
Canceled,
#[serde(rename = "trashed")]
Trashed,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskType {
#[serde(rename = "to-do")]
Todo,
#[serde(rename = "project")]
Project,
#[serde(rename = "heading")]
Heading,
#[serde(rename = "area")]
Area,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeleteChildHandling {
#[serde(rename = "error")]
Error,
#[serde(rename = "cascade")]
Cascade,
#[serde(rename = "orphan")]
Orphan,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub uuid: ThingsId,
pub title: String,
pub task_type: TaskType,
pub status: TaskStatus,
pub notes: Option<String>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub stop_date: Option<DateTime<Utc>>,
pub project_uuid: Option<ThingsId>,
pub area_uuid: Option<ThingsId>,
pub parent_uuid: Option<ThingsId>,
pub tags: Vec<String>,
pub children: Vec<Task>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub uuid: ThingsId,
pub title: String,
pub notes: Option<String>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub area_uuid: Option<ThingsId>,
pub tags: Vec<String>,
pub status: TaskStatus,
pub tasks: Vec<Task>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Area {
pub uuid: ThingsId,
pub title: String,
pub notes: Option<String>,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
pub tags: Vec<String>,
pub projects: Vec<Project>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tag {
pub uuid: ThingsId,
pub title: String,
pub shortcut: Option<String>,
pub parent_uuid: Option<ThingsId>,
pub usage_count: u32,
pub last_used: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTagRequest {
pub title: String,
pub shortcut: Option<String>,
pub parent_uuid: Option<ThingsId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateTagRequest {
pub uuid: ThingsId,
pub title: Option<String>,
pub shortcut: Option<String>,
pub parent_uuid: Option<ThingsId>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TagMatchType {
#[serde(rename = "exact")]
Exact,
#[serde(rename = "case_mismatch")]
CaseMismatch,
#[serde(rename = "similar")]
Similar,
#[serde(rename = "partial")]
PartialMatch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagMatch {
pub tag: Tag,
pub similarity_score: f32,
pub match_type: TagMatchType,
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TagCreationResult {
#[serde(rename = "created")]
Created {
uuid: ThingsId,
is_new: bool,
},
#[serde(rename = "existing")]
Existing {
tag: Tag,
is_new: bool,
},
#[serde(rename = "similar_found")]
SimilarFound {
similar_tags: Vec<TagMatch>,
requested_title: String,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TagAssignmentResult {
#[serde(rename = "assigned")]
Assigned {
tag_uuid: ThingsId,
},
#[serde(rename = "suggestions")]
Suggestions {
similar_tags: Vec<TagMatch>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagCompletion {
pub tag: Tag,
pub score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagStatistics {
pub uuid: ThingsId,
pub title: String,
pub usage_count: u32,
pub task_uuids: Vec<ThingsId>,
pub related_tags: Vec<(String, u32)>, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagPair {
pub tag1: Tag,
pub tag2: Tag,
pub similarity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTaskRequest {
pub title: String,
pub task_type: Option<TaskType>,
pub notes: Option<String>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub project_uuid: Option<ThingsId>,
pub area_uuid: Option<ThingsId>,
pub parent_uuid: Option<ThingsId>,
pub tags: Option<Vec<String>>,
pub status: Option<TaskStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateTaskRequest {
pub uuid: ThingsId,
pub title: Option<String>,
pub notes: Option<String>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub status: Option<TaskStatus>,
pub project_uuid: Option<ThingsId>,
pub area_uuid: Option<ThingsId>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TaskFilters {
pub status: Option<TaskStatus>,
pub task_type: Option<TaskType>,
pub project_uuid: Option<ThingsId>,
pub area_uuid: Option<ThingsId>,
pub tags: Option<Vec<String>>,
pub start_date_from: Option<NaiveDate>,
pub start_date_to: Option<NaiveDate>,
pub deadline_from: Option<NaiveDate>,
pub deadline_to: Option<NaiveDate>,
pub search_query: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[cfg(feature = "advanced-queries")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RankedTask {
pub task: Task,
pub score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateProjectRequest {
pub title: String,
pub notes: Option<String>,
pub area_uuid: Option<ThingsId>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateProjectRequest {
pub uuid: ThingsId,
pub title: Option<String>,
pub notes: Option<String>,
pub area_uuid: Option<ThingsId>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAreaRequest {
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateAreaRequest {
pub uuid: ThingsId,
pub title: String,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ProjectChildHandling {
#[serde(rename = "error")]
#[default]
Error,
#[serde(rename = "cascade")]
Cascade,
#[serde(rename = "orphan")]
Orphan,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkMoveRequest {
pub task_uuids: Vec<ThingsId>,
pub project_uuid: Option<ThingsId>,
pub area_uuid: Option<ThingsId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkUpdateDatesRequest {
pub task_uuids: Vec<ThingsId>,
pub start_date: Option<NaiveDate>,
pub deadline: Option<NaiveDate>,
pub clear_start_date: bool,
pub clear_deadline: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkCompleteRequest {
pub task_uuids: Vec<ThingsId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkDeleteRequest {
pub task_uuids: Vec<ThingsId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkCreateTasksRequest {
pub tasks: Vec<CreateTaskRequest>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkOperationResult {
pub success: bool,
pub processed_count: usize,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn test_task_status_serialization() {
let status = TaskStatus::Incomplete;
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, "\"incomplete\"");
let status = TaskStatus::Completed;
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, "\"completed\"");
let status = TaskStatus::Canceled;
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, "\"canceled\"");
let status = TaskStatus::Trashed;
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, "\"trashed\"");
}
#[test]
fn test_task_status_deserialization() {
let deserialized: TaskStatus = serde_json::from_str("\"incomplete\"").unwrap();
assert_eq!(deserialized, TaskStatus::Incomplete);
let deserialized: TaskStatus = serde_json::from_str("\"completed\"").unwrap();
assert_eq!(deserialized, TaskStatus::Completed);
let deserialized: TaskStatus = serde_json::from_str("\"canceled\"").unwrap();
assert_eq!(deserialized, TaskStatus::Canceled);
let deserialized: TaskStatus = serde_json::from_str("\"trashed\"").unwrap();
assert_eq!(deserialized, TaskStatus::Trashed);
}
#[test]
fn test_task_type_serialization() {
let task_type = TaskType::Todo;
let serialized = serde_json::to_string(&task_type).unwrap();
assert_eq!(serialized, "\"to-do\"");
let task_type = TaskType::Project;
let serialized = serde_json::to_string(&task_type).unwrap();
assert_eq!(serialized, "\"project\"");
let task_type = TaskType::Heading;
let serialized = serde_json::to_string(&task_type).unwrap();
assert_eq!(serialized, "\"heading\"");
let task_type = TaskType::Area;
let serialized = serde_json::to_string(&task_type).unwrap();
assert_eq!(serialized, "\"area\"");
}
#[test]
fn test_task_type_deserialization() {
let deserialized: TaskType = serde_json::from_str("\"to-do\"").unwrap();
assert_eq!(deserialized, TaskType::Todo);
let deserialized: TaskType = serde_json::from_str("\"project\"").unwrap();
assert_eq!(deserialized, TaskType::Project);
let deserialized: TaskType = serde_json::from_str("\"heading\"").unwrap();
assert_eq!(deserialized, TaskType::Heading);
let deserialized: TaskType = serde_json::from_str("\"area\"").unwrap();
assert_eq!(deserialized, TaskType::Area);
}
#[test]
fn test_task_creation() {
let uuid = ThingsId::new_v4();
let now = Utc::now();
let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let deadline = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let task = Task {
uuid: uuid.clone(),
title: "Test Task".to_string(),
task_type: TaskType::Todo,
status: TaskStatus::Incomplete,
notes: Some("Test notes".to_string()),
start_date: Some(start_date),
deadline: Some(deadline),
created: now,
modified: now,
stop_date: None,
project_uuid: None,
area_uuid: None,
parent_uuid: None,
tags: vec!["work".to_string(), "urgent".to_string()],
children: vec![],
};
assert_eq!(task.uuid, uuid);
assert_eq!(task.title, "Test Task");
assert_eq!(task.task_type, TaskType::Todo);
assert_eq!(task.status, TaskStatus::Incomplete);
assert_eq!(task.notes, Some("Test notes".to_string()));
assert_eq!(task.start_date, Some(start_date));
assert_eq!(task.deadline, Some(deadline));
assert_eq!(task.tags.len(), 2);
assert!(task.tags.contains(&"work".to_string()));
assert!(task.tags.contains(&"urgent".to_string()));
}
#[test]
fn test_task_serialization() {
let uuid = ThingsId::new_v4();
let now = Utc::now();
let task = Task {
uuid: uuid.clone(),
title: "Test Task".to_string(),
task_type: TaskType::Todo,
status: TaskStatus::Incomplete,
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
stop_date: None,
project_uuid: None,
area_uuid: None,
parent_uuid: None,
tags: vec![],
children: vec![],
};
let serialized = serde_json::to_string(&task).unwrap();
let deserialized: Task = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.uuid, task.uuid);
assert_eq!(deserialized.title, task.title);
assert_eq!(deserialized.task_type, task.task_type);
assert_eq!(deserialized.status, task.status);
}
#[test]
fn test_project_creation() {
let uuid = ThingsId::new_v4();
let area_uuid = ThingsId::new_v4();
let now = Utc::now();
let project = Project {
uuid: uuid.clone(),
title: "Test Project".to_string(),
notes: Some("Project notes".to_string()),
start_date: None,
deadline: None,
created: now,
modified: now,
area_uuid: Some(area_uuid.clone()),
tags: vec!["project".to_string()],
status: TaskStatus::Incomplete,
tasks: vec![],
};
assert_eq!(project.uuid, uuid);
assert_eq!(project.title, "Test Project");
assert_eq!(project.area_uuid, Some(area_uuid));
assert_eq!(project.status, TaskStatus::Incomplete);
assert_eq!(project.tags.len(), 1);
}
#[test]
fn test_project_serialization() {
let uuid = ThingsId::new_v4();
let now = Utc::now();
let project = Project {
uuid: uuid.clone(),
title: "Test Project".to_string(),
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
area_uuid: None,
tags: vec![],
status: TaskStatus::Incomplete,
tasks: vec![],
};
let serialized = serde_json::to_string(&project).unwrap();
let deserialized: Project = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.uuid, project.uuid);
assert_eq!(deserialized.title, project.title);
assert_eq!(deserialized.status, project.status);
}
#[test]
fn test_area_creation() {
let uuid = ThingsId::new_v4();
let now = Utc::now();
let area = Area {
uuid: uuid.clone(),
title: "Test Area".to_string(),
notes: Some("Area notes".to_string()),
created: now,
modified: now,
tags: vec!["area".to_string()],
projects: vec![],
};
assert_eq!(area.uuid, uuid);
assert_eq!(area.title, "Test Area");
assert_eq!(area.notes, Some("Area notes".to_string()));
assert_eq!(area.tags.len(), 1);
}
#[test]
fn test_area_serialization() {
let uuid = ThingsId::new_v4();
let now = Utc::now();
let area = Area {
uuid: uuid.clone(),
title: "Test Area".to_string(),
notes: None,
created: now,
modified: now,
tags: vec![],
projects: vec![],
};
let serialized = serde_json::to_string(&area).unwrap();
let deserialized: Area = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.uuid, area.uuid);
assert_eq!(deserialized.title, area.title);
}
#[test]
fn test_tag_creation() {
let uuid = ThingsId::new_v4();
let parent_uuid = ThingsId::new_v4();
let now = Utc::now();
let tag = Tag {
uuid: uuid.clone(),
title: "work".to_string(),
shortcut: Some("w".to_string()),
parent_uuid: Some(parent_uuid.clone()),
usage_count: 5,
last_used: Some(now),
};
assert_eq!(tag.uuid, uuid);
assert_eq!(tag.title, "work");
assert_eq!(tag.shortcut, Some("w".to_string()));
assert_eq!(tag.parent_uuid, Some(parent_uuid));
assert_eq!(tag.usage_count, 5);
assert_eq!(tag.last_used, Some(now));
}
#[test]
fn test_tag_serialization() {
let uuid = ThingsId::new_v4();
let tag = Tag {
uuid: uuid.clone(),
title: "test".to_string(),
shortcut: None,
parent_uuid: None,
usage_count: 0,
last_used: None,
};
let serialized = serde_json::to_string(&tag).unwrap();
let deserialized: Tag = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.uuid, tag.uuid);
assert_eq!(deserialized.title, tag.title);
assert_eq!(deserialized.usage_count, tag.usage_count);
}
#[test]
fn test_create_task_request() {
let project_uuid = ThingsId::new_v4();
let area_uuid = ThingsId::new_v4();
let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let request = CreateTaskRequest {
title: "New Task".to_string(),
task_type: None,
notes: Some("Task notes".to_string()),
start_date: Some(start_date),
deadline: None,
project_uuid: Some(project_uuid.clone()),
area_uuid: Some(area_uuid.clone()),
parent_uuid: None,
tags: Some(vec!["new".to_string()]),
status: None,
};
assert_eq!(request.title, "New Task");
assert_eq!(request.project_uuid, Some(project_uuid));
assert_eq!(request.area_uuid, Some(area_uuid));
assert_eq!(request.start_date, Some(start_date));
assert_eq!(request.tags.as_ref().unwrap().len(), 1);
}
#[test]
fn test_create_task_request_serialization() {
let request = CreateTaskRequest {
title: "Test".to_string(),
task_type: None,
notes: None,
start_date: None,
deadline: None,
project_uuid: None,
area_uuid: None,
parent_uuid: None,
tags: None,
status: None,
};
let serialized = serde_json::to_string(&request).unwrap();
let deserialized: CreateTaskRequest = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.title, request.title);
}
#[test]
fn test_update_task_request() {
let uuid = ThingsId::new_v4();
let request = UpdateTaskRequest {
uuid: uuid.clone(),
title: Some("Updated Title".to_string()),
notes: Some("Updated notes".to_string()),
start_date: None,
deadline: None,
status: Some(TaskStatus::Completed),
project_uuid: None,
area_uuid: None,
tags: Some(vec!["updated".to_string()]),
};
assert_eq!(request.uuid, uuid);
assert_eq!(request.title, Some("Updated Title".to_string()));
assert_eq!(request.status, Some(TaskStatus::Completed));
assert_eq!(request.tags, Some(vec!["updated".to_string()]));
}
#[test]
fn test_update_task_request_serialization() {
let uuid = ThingsId::new_v4();
let request = UpdateTaskRequest {
uuid: uuid.clone(),
title: None,
notes: None,
start_date: None,
deadline: None,
status: None,
project_uuid: None,
area_uuid: None,
tags: None,
};
let serialized = serde_json::to_string(&request).unwrap();
let deserialized: UpdateTaskRequest = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.uuid, request.uuid);
}
#[test]
fn test_task_filters_default() {
let filters = TaskFilters::default();
assert!(filters.status.is_none());
assert!(filters.task_type.is_none());
assert!(filters.project_uuid.is_none());
assert!(filters.area_uuid.is_none());
assert!(filters.tags.is_none());
assert!(filters.start_date_from.is_none());
assert!(filters.start_date_to.is_none());
assert!(filters.deadline_from.is_none());
assert!(filters.deadline_to.is_none());
assert!(filters.search_query.is_none());
assert!(filters.limit.is_none());
assert!(filters.offset.is_none());
}
#[test]
fn test_task_filters_creation() {
let project_uuid = ThingsId::new_v4();
let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let filters = TaskFilters {
status: Some(TaskStatus::Incomplete),
task_type: Some(TaskType::Todo),
project_uuid: Some(project_uuid.clone()),
area_uuid: None,
tags: Some(vec!["work".to_string()]),
start_date_from: Some(start_date),
start_date_to: None,
deadline_from: None,
deadline_to: None,
search_query: Some("test".to_string()),
limit: Some(10),
offset: Some(0),
};
assert_eq!(filters.status, Some(TaskStatus::Incomplete));
assert_eq!(filters.task_type, Some(TaskType::Todo));
assert_eq!(filters.project_uuid, Some(project_uuid));
assert_eq!(filters.search_query, Some("test".to_string()));
assert_eq!(filters.limit, Some(10));
assert_eq!(filters.offset, Some(0));
}
#[test]
fn test_task_filters_serialization() {
let filters = TaskFilters {
status: Some(TaskStatus::Completed),
task_type: Some(TaskType::Project),
project_uuid: None,
area_uuid: None,
tags: None,
start_date_from: None,
start_date_to: None,
deadline_from: None,
deadline_to: None,
search_query: None,
limit: None,
offset: None,
};
let serialized = serde_json::to_string(&filters).unwrap();
let deserialized: TaskFilters = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.status, filters.status);
assert_eq!(deserialized.task_type, filters.task_type);
}
#[test]
fn test_task_status_equality() {
assert_eq!(TaskStatus::Incomplete, TaskStatus::Incomplete);
assert_ne!(TaskStatus::Incomplete, TaskStatus::Completed);
assert_ne!(TaskStatus::Completed, TaskStatus::Canceled);
assert_ne!(TaskStatus::Canceled, TaskStatus::Trashed);
}
#[test]
fn test_task_type_equality() {
assert_eq!(TaskType::Todo, TaskType::Todo);
assert_ne!(TaskType::Todo, TaskType::Project);
assert_ne!(TaskType::Project, TaskType::Heading);
assert_ne!(TaskType::Heading, TaskType::Area);
}
#[test]
fn test_task_with_children() {
let parent_uuid = ThingsId::new_v4();
let child_uuid = ThingsId::new_v4();
let now = Utc::now();
let child_task = Task {
uuid: child_uuid,
title: "Child Task".to_string(),
task_type: TaskType::Todo,
status: TaskStatus::Incomplete,
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
stop_date: None,
project_uuid: None,
area_uuid: None,
parent_uuid: Some(parent_uuid.clone()),
tags: vec![],
children: vec![],
};
let parent_task = Task {
uuid: parent_uuid.clone(),
title: "Parent Task".to_string(),
task_type: TaskType::Heading,
status: TaskStatus::Incomplete,
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
stop_date: None,
project_uuid: None,
area_uuid: None,
parent_uuid: None,
tags: vec![],
children: vec![child_task],
};
assert_eq!(parent_task.children.len(), 1);
assert_eq!(parent_task.children[0].parent_uuid, Some(parent_uuid));
assert_eq!(parent_task.children[0].title, "Child Task");
}
#[test]
fn test_project_with_tasks() {
let project_uuid = ThingsId::new_v4();
let task_uuid = ThingsId::new_v4();
let now = Utc::now();
let task = Task {
uuid: task_uuid,
title: "Project Task".to_string(),
task_type: TaskType::Todo,
status: TaskStatus::Incomplete,
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
stop_date: None,
project_uuid: Some(project_uuid.clone()),
area_uuid: None,
parent_uuid: None,
tags: vec![],
children: vec![],
};
let project = Project {
uuid: project_uuid.clone(),
title: "Test Project".to_string(),
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
area_uuid: None,
tags: vec![],
status: TaskStatus::Incomplete,
tasks: vec![task],
};
assert_eq!(project.tasks.len(), 1);
assert_eq!(project.tasks[0].project_uuid, Some(project_uuid));
assert_eq!(project.tasks[0].title, "Project Task");
}
#[test]
fn test_area_with_projects() {
let area_uuid = ThingsId::new_v4();
let project_uuid = ThingsId::new_v4();
let now = Utc::now();
let project = Project {
uuid: project_uuid,
title: "Area Project".to_string(),
notes: None,
start_date: None,
deadline: None,
created: now,
modified: now,
area_uuid: Some(area_uuid.clone()),
tags: vec![],
status: TaskStatus::Incomplete,
tasks: vec![],
};
let area = Area {
uuid: area_uuid.clone(),
title: "Test Area".to_string(),
notes: None,
created: now,
modified: now,
tags: vec![],
projects: vec![project],
};
assert_eq!(area.projects.len(), 1);
assert_eq!(area.projects[0].area_uuid, Some(area_uuid));
assert_eq!(area.projects[0].title, "Area Project");
}
}