1use std::fmt;
4use std::str::FromStr;
5
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use crate::error::ThingsError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(transparent)]
49pub struct ThingsId(String);
50
51impl ThingsId {
52 #[must_use]
55 pub fn new_v4() -> Self {
56 Self(Uuid::new_v4().to_string())
57 }
58
59 #[must_use]
67 pub fn new_things_native() -> Self {
68 let bytes = *Uuid::new_v4().as_bytes();
69 Self(base62_encode_22(&bytes))
70 }
71
72 #[must_use]
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78
79 #[must_use]
81 pub fn into_string(self) -> String {
82 self.0
83 }
84
85 pub(crate) fn from_trusted(s: String) -> Self {
89 Self(s)
90 }
91
92 fn is_things_native(s: &str) -> bool {
94 let len = s.len();
95 (len == 21 || len == 22) && s.chars().all(|c| c.is_ascii_alphanumeric())
96 }
97
98 #[cfg(target_os = "macos")]
106 pub(crate) fn as_things_native(&self) -> Result<&str, ThingsError> {
107 if Self::is_things_native(&self.0) {
108 Ok(&self.0)
109 } else {
110 Err(ThingsError::validation(format!(
111 "ID {:?} is not in Things native format (21–22-char Base62) \
112 and cannot be referenced via AppleScript. This entity was \
113 likely created on Linux/CI or with --unsafe-direct-db. \
114 Recreate it in Things 3, or set THINGS_UNSAFE_DIRECT_DB=1 \
115 to mutate via direct SQLite writes.",
116 self.0
117 )))
118 }
119 }
120}
121
122fn base62_encode_22(bytes: &[u8; 16]) -> String {
126 const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
130 let mut n = u128::from_be_bytes(*bytes);
131 let mut out = [b'0'; 22];
132 for slot in out.iter_mut().rev() {
133 *slot = ALPHABET[(n % 62) as usize];
134 n /= 62;
135 }
136 String::from_utf8(out.to_vec()).expect("alphabet is ASCII")
137}
138
139impl fmt::Display for ThingsId {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(&self.0)
142 }
143}
144
145impl FromStr for ThingsId {
146 type Err = ThingsError;
147
148 fn from_str(s: &str) -> Result<Self, Self::Err> {
155 if Uuid::parse_str(s).is_ok() {
156 return Ok(Self(s.to_string()));
157 }
158 if Self::is_things_native(s) {
159 return Ok(Self(s.to_string()));
160 }
161 Err(ThingsError::validation(format!(
162 "invalid Things 3 identifier {s:?}: expected RFC-4122 UUID \
163 (36 chars, hex+hyphens) or Things native ID (21–22 base62 chars)"
164 )))
165 }
166}
167
168impl From<Uuid> for ThingsId {
169 fn from(uuid: Uuid) -> Self {
170 Self(uuid.to_string())
171 }
172}
173
174impl AsRef<str> for ThingsId {
175 fn as_ref(&self) -> &str {
176 &self.0
177 }
178}
179
180#[cfg(test)]
181mod things_id_tests {
182 use super::*;
183
184 #[test]
185 fn new_v4_produces_hyphenated_uuid_string() {
186 let id = ThingsId::new_v4();
187 let s = id.as_str();
188 assert_eq!(s.len(), 36);
189 assert!(Uuid::parse_str(s).is_ok());
190 }
191
192 #[test]
193 fn new_things_native_produces_22_char_base62() {
194 let id = ThingsId::new_things_native();
195 let s = id.as_str();
196 assert_eq!(s.len(), 22);
197 assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
198 assert!(ThingsId::is_things_native(s));
199 }
200
201 #[test]
202 fn new_things_native_round_trips_through_from_str() {
203 let original = ThingsId::new_things_native();
204 let parsed: ThingsId = original.as_str().parse().unwrap();
205 assert_eq!(original, parsed);
206 }
207
208 #[test]
209 fn new_things_native_yields_unique_ids() {
210 use std::collections::HashSet;
211 let ids: HashSet<_> = (0..1000).map(|_| ThingsId::new_things_native()).collect();
212 assert_eq!(ids.len(), 1000);
213 }
214
215 #[cfg(target_os = "macos")]
216 #[test]
217 fn as_things_native_accepts_native_id() {
218 let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
219 assert_eq!(id.as_things_native().unwrap(), "R4t2G8Q63aGZq4epMHNeCr");
220 }
221
222 #[cfg(target_os = "macos")]
223 #[test]
224 fn as_things_native_accepts_21_char_native_id() {
225 let id: ThingsId = "19KLMeA2ULbixtvNbXsDK".parse().unwrap();
226 assert_eq!(id.as_things_native().unwrap(), "19KLMeA2ULbixtvNbXsDK");
227 }
228
229 #[cfg(target_os = "macos")]
230 #[test]
231 fn as_things_native_rejects_hyphenated_uuid() {
232 let id: ThingsId = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e".parse().unwrap();
233 let err = id.as_things_native().unwrap_err();
234 let msg = err.to_string();
235 assert!(
236 msg.contains("not in Things native format"),
237 "missing format hint, got: {msg}"
238 );
239 assert!(msg.contains("Recreate"), "missing remediation, got: {msg}");
240 }
241
242 #[test]
243 fn base62_encode_22_pads_zero_input() {
244 let s = base62_encode_22(&[0u8; 16]);
246 assert_eq!(s, "0".repeat(22));
247 }
248
249 #[test]
250 fn base62_encode_22_handles_max_input() {
251 let s = base62_encode_22(&[0xFFu8; 16]);
254 assert_eq!(s.len(), 22);
255 assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
256 }
257
258 #[test]
259 fn from_str_accepts_hyphenated_uuid() {
260 let s = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e";
261 let id: ThingsId = s.parse().unwrap();
262 assert_eq!(id.as_str(), s);
263 }
264
265 #[test]
266 fn from_str_accepts_22_char_native_id() {
267 let s = "R4t2G8Q63aGZq4epMHNeCr";
268 assert_eq!(s.len(), 22);
269 let id: ThingsId = s.parse().unwrap();
270 assert_eq!(id.as_str(), s);
271 }
272
273 #[test]
274 fn from_str_accepts_21_char_native_id() {
275 let s = "19KLMeA2ULbixtvNbXsDK";
277 assert_eq!(s.len(), 21);
278 let id: ThingsId = s.parse().unwrap();
279 assert_eq!(id.as_str(), s);
280 }
281
282 #[test]
283 fn from_str_rejects_short_garbage() {
284 let err = "abc".parse::<ThingsId>().unwrap_err();
285 assert!(matches!(err, ThingsError::Validation { .. }));
286 }
287
288 #[test]
289 fn from_str_rejects_long_garbage() {
290 let err = "ZZZZZZZZZZZZZZZZZZZZZZZ".parse::<ThingsId>().unwrap_err();
292 assert!(matches!(err, ThingsError::Validation { .. }));
293 }
294
295 #[test]
296 fn from_str_rejects_native_with_special_chars() {
297 let err = "R4t2G8Q63aGZq4epMHN-Cr".parse::<ThingsId>().unwrap_err();
299 assert!(matches!(err, ThingsError::Validation { .. }));
300 }
301
302 #[test]
303 fn from_str_rejects_empty() {
304 let err = "".parse::<ThingsId>().unwrap_err();
305 assert!(matches!(err, ThingsError::Validation { .. }));
306 }
307
308 #[test]
309 fn from_str_rejects_uuid_with_extra_chars() {
310 let err = "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e-XYZ"
312 .parse::<ThingsId>()
313 .unwrap_err();
314 assert!(matches!(err, ThingsError::Validation { .. }));
315 }
316
317 #[test]
318 fn display_is_the_inner_string() {
319 let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
320 assert_eq!(format!("{id}"), "R4t2G8Q63aGZq4epMHNeCr");
321 }
322
323 #[test]
324 fn from_uuid_wraps_hyphenated_form() {
325 let uuid = Uuid::parse_str("9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e").unwrap();
326 let id: ThingsId = uuid.into();
327 assert_eq!(id.as_str(), "9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e");
328 }
329
330 #[test]
331 fn serde_roundtrips_as_bare_string() {
332 let id: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
333 let json = serde_json::to_string(&id).unwrap();
334 assert_eq!(json, "\"R4t2G8Q63aGZq4epMHNeCr\"");
335 let back: ThingsId = serde_json::from_str(&json).unwrap();
336 assert_eq!(back, id);
337 }
338
339 #[test]
340 fn equality_is_string_equality() {
341 let a: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
342 let b: ThingsId = "R4t2G8Q63aGZq4epMHNeCr".parse().unwrap();
343 assert_eq!(a, b);
344 }
345
346 #[test]
347 fn from_trusted_skips_validation() {
348 let id = ThingsId::from_trusted("anything-goes-here".to_string());
351 assert_eq!(id.as_str(), "anything-goes-here");
352 }
353}
354
355#[non_exhaustive]
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358pub enum TaskStatus {
359 #[serde(rename = "incomplete")]
360 Incomplete,
361 #[serde(rename = "completed")]
362 Completed,
363 #[serde(rename = "canceled")]
364 Canceled,
365 #[serde(rename = "trashed")]
368 Trashed,
369}
370
371#[non_exhaustive]
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
374pub enum TaskType {
375 #[serde(rename = "to-do")]
376 Todo,
377 #[serde(rename = "project")]
378 Project,
379 #[serde(rename = "heading")]
380 Heading,
381 #[serde(rename = "area")]
382 Area,
383}
384
385#[non_exhaustive]
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388pub enum DeleteChildHandling {
389 #[serde(rename = "error")]
391 Error,
392 #[serde(rename = "cascade")]
394 Cascade,
395 #[serde(rename = "orphan")]
397 Orphan,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Task {
403 pub uuid: ThingsId,
405 pub title: String,
407 pub task_type: TaskType,
409 pub status: TaskStatus,
411 pub notes: Option<String>,
413 pub start_date: Option<NaiveDate>,
415 pub deadline: Option<NaiveDate>,
417 pub created: DateTime<Utc>,
419 pub modified: DateTime<Utc>,
421 pub stop_date: Option<DateTime<Utc>>,
423 pub project_uuid: Option<ThingsId>,
425 pub area_uuid: Option<ThingsId>,
427 pub parent_uuid: Option<ThingsId>,
429 pub tags: Vec<String>,
431 pub children: Vec<Task>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct Project {
438 pub uuid: ThingsId,
440 pub title: String,
442 pub notes: Option<String>,
444 pub start_date: Option<NaiveDate>,
446 pub deadline: Option<NaiveDate>,
448 pub created: DateTime<Utc>,
450 pub modified: DateTime<Utc>,
452 pub area_uuid: Option<ThingsId>,
454 pub tags: Vec<String>,
456 pub status: TaskStatus,
458 pub tasks: Vec<Task>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct Area {
465 pub uuid: ThingsId,
467 pub title: String,
469 pub notes: Option<String>,
471 pub created: DateTime<Utc>,
473 pub modified: DateTime<Utc>,
475 pub tags: Vec<String>,
477 pub projects: Vec<Project>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct Tag {
484 pub uuid: ThingsId,
486 pub title: String,
488 pub shortcut: Option<String>,
490 pub parent_uuid: Option<ThingsId>,
492 pub usage_count: u32,
494 pub last_used: Option<DateTime<Utc>>,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct CreateTagRequest {
501 pub title: String,
503 pub shortcut: Option<String>,
505 pub parent_uuid: Option<ThingsId>,
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct UpdateTagRequest {
512 pub uuid: ThingsId,
514 pub title: Option<String>,
516 pub shortcut: Option<String>,
518 pub parent_uuid: Option<ThingsId>,
520}
521
522#[non_exhaustive]
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525pub enum TagMatchType {
526 #[serde(rename = "exact")]
528 Exact,
529 #[serde(rename = "case_mismatch")]
531 CaseMismatch,
532 #[serde(rename = "similar")]
534 Similar,
535 #[serde(rename = "partial")]
537 PartialMatch,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct TagMatch {
543 pub tag: Tag,
545 pub similarity_score: f32,
547 pub match_type: TagMatchType,
549}
550
551#[non_exhaustive]
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub enum TagCreationResult {
555 #[serde(rename = "created")]
557 Created {
558 uuid: ThingsId,
560 is_new: bool,
562 },
563 #[serde(rename = "existing")]
565 Existing {
566 tag: Tag,
568 is_new: bool,
570 },
571 #[serde(rename = "similar_found")]
573 SimilarFound {
574 similar_tags: Vec<TagMatch>,
576 requested_title: String,
578 },
579}
580
581#[non_exhaustive]
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub enum TagAssignmentResult {
585 #[serde(rename = "assigned")]
587 Assigned {
588 tag_uuid: ThingsId,
590 },
591 #[serde(rename = "suggestions")]
593 Suggestions {
594 similar_tags: Vec<TagMatch>,
596 },
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct TagCompletion {
602 pub tag: Tag,
604 pub score: f32,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct TagStatistics {
611 pub uuid: ThingsId,
613 pub title: String,
615 pub usage_count: u32,
617 pub task_uuids: Vec<ThingsId>,
619 pub related_tags: Vec<(String, u32)>, }
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct TagPair {
626 pub tag1: Tag,
628 pub tag2: Tag,
630 pub similarity: f32,
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct CreateTaskRequest {
637 pub title: String,
639 pub task_type: Option<TaskType>,
641 pub notes: Option<String>,
643 pub start_date: Option<NaiveDate>,
645 pub deadline: Option<NaiveDate>,
647 pub project_uuid: Option<ThingsId>,
649 pub area_uuid: Option<ThingsId>,
651 pub parent_uuid: Option<ThingsId>,
653 pub tags: Option<Vec<String>>,
655 pub status: Option<TaskStatus>,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct UpdateTaskRequest {
662 pub uuid: ThingsId,
664 pub title: Option<String>,
666 pub notes: Option<String>,
668 pub start_date: Option<NaiveDate>,
670 pub deadline: Option<NaiveDate>,
672 pub status: Option<TaskStatus>,
674 pub project_uuid: Option<ThingsId>,
676 pub area_uuid: Option<ThingsId>,
678 pub tags: Option<Vec<String>>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize, Default)]
684pub struct TaskFilters {
685 pub status: Option<TaskStatus>,
687 pub task_type: Option<TaskType>,
689 pub project_uuid: Option<ThingsId>,
691 pub area_uuid: Option<ThingsId>,
693 pub tags: Option<Vec<String>>,
695 pub start_date_from: Option<NaiveDate>,
697 pub start_date_to: Option<NaiveDate>,
698 pub deadline_from: Option<NaiveDate>,
700 pub deadline_to: Option<NaiveDate>,
701 pub search_query: Option<String>,
703 pub limit: Option<usize>,
705 pub offset: Option<usize>,
707}
708
709#[cfg(feature = "advanced-queries")]
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct RankedTask {
717 pub task: Task,
719 pub score: f32,
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize)]
725pub struct CreateProjectRequest {
726 pub title: String,
728 pub notes: Option<String>,
730 pub area_uuid: Option<ThingsId>,
732 pub start_date: Option<NaiveDate>,
734 pub deadline: Option<NaiveDate>,
736 pub tags: Option<Vec<String>>,
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct UpdateProjectRequest {
743 pub uuid: ThingsId,
745 pub title: Option<String>,
747 pub notes: Option<String>,
749 pub area_uuid: Option<ThingsId>,
751 pub start_date: Option<NaiveDate>,
753 pub deadline: Option<NaiveDate>,
755 pub tags: Option<Vec<String>>,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct CreateAreaRequest {
762 pub title: String,
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct UpdateAreaRequest {
769 pub uuid: ThingsId,
771 pub title: String,
773}
774
775#[non_exhaustive]
777#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
778pub enum ProjectChildHandling {
779 #[serde(rename = "error")]
781 #[default]
782 Error,
783 #[serde(rename = "cascade")]
785 Cascade,
786 #[serde(rename = "orphan")]
788 Orphan,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct BulkMoveRequest {
798 pub task_uuids: Vec<ThingsId>,
800 pub project_uuid: Option<ThingsId>,
802 pub area_uuid: Option<ThingsId>,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
808pub struct BulkUpdateDatesRequest {
809 pub task_uuids: Vec<ThingsId>,
811 pub start_date: Option<NaiveDate>,
813 pub deadline: Option<NaiveDate>,
815 pub clear_start_date: bool,
817 pub clear_deadline: bool,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct BulkCompleteRequest {
824 pub task_uuids: Vec<ThingsId>,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct BulkDeleteRequest {
831 pub task_uuids: Vec<ThingsId>,
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct BulkCreateTasksRequest {
841 pub tasks: Vec<CreateTaskRequest>,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct BulkOperationResult {
848 pub success: bool,
850 pub processed_count: usize,
852 pub message: String,
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use chrono::NaiveDate;
860
861 #[test]
862 fn test_task_status_serialization() {
863 let status = TaskStatus::Incomplete;
864 let serialized = serde_json::to_string(&status).unwrap();
865 assert_eq!(serialized, "\"incomplete\"");
866
867 let status = TaskStatus::Completed;
868 let serialized = serde_json::to_string(&status).unwrap();
869 assert_eq!(serialized, "\"completed\"");
870
871 let status = TaskStatus::Canceled;
872 let serialized = serde_json::to_string(&status).unwrap();
873 assert_eq!(serialized, "\"canceled\"");
874
875 let status = TaskStatus::Trashed;
876 let serialized = serde_json::to_string(&status).unwrap();
877 assert_eq!(serialized, "\"trashed\"");
878 }
879
880 #[test]
881 fn test_task_status_deserialization() {
882 let deserialized: TaskStatus = serde_json::from_str("\"incomplete\"").unwrap();
883 assert_eq!(deserialized, TaskStatus::Incomplete);
884
885 let deserialized: TaskStatus = serde_json::from_str("\"completed\"").unwrap();
886 assert_eq!(deserialized, TaskStatus::Completed);
887
888 let deserialized: TaskStatus = serde_json::from_str("\"canceled\"").unwrap();
889 assert_eq!(deserialized, TaskStatus::Canceled);
890
891 let deserialized: TaskStatus = serde_json::from_str("\"trashed\"").unwrap();
892 assert_eq!(deserialized, TaskStatus::Trashed);
893 }
894
895 #[test]
896 fn test_task_type_serialization() {
897 let task_type = TaskType::Todo;
898 let serialized = serde_json::to_string(&task_type).unwrap();
899 assert_eq!(serialized, "\"to-do\"");
900
901 let task_type = TaskType::Project;
902 let serialized = serde_json::to_string(&task_type).unwrap();
903 assert_eq!(serialized, "\"project\"");
904
905 let task_type = TaskType::Heading;
906 let serialized = serde_json::to_string(&task_type).unwrap();
907 assert_eq!(serialized, "\"heading\"");
908
909 let task_type = TaskType::Area;
910 let serialized = serde_json::to_string(&task_type).unwrap();
911 assert_eq!(serialized, "\"area\"");
912 }
913
914 #[test]
915 fn test_task_type_deserialization() {
916 let deserialized: TaskType = serde_json::from_str("\"to-do\"").unwrap();
917 assert_eq!(deserialized, TaskType::Todo);
918
919 let deserialized: TaskType = serde_json::from_str("\"project\"").unwrap();
920 assert_eq!(deserialized, TaskType::Project);
921
922 let deserialized: TaskType = serde_json::from_str("\"heading\"").unwrap();
923 assert_eq!(deserialized, TaskType::Heading);
924
925 let deserialized: TaskType = serde_json::from_str("\"area\"").unwrap();
926 assert_eq!(deserialized, TaskType::Area);
927 }
928
929 #[test]
930 fn test_task_creation() {
931 let uuid = ThingsId::new_v4();
932 let now = Utc::now();
933 let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
934 let deadline = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
935
936 let task = Task {
937 uuid: uuid.clone(),
938 title: "Test Task".to_string(),
939 task_type: TaskType::Todo,
940 status: TaskStatus::Incomplete,
941 notes: Some("Test notes".to_string()),
942 start_date: Some(start_date),
943 deadline: Some(deadline),
944 created: now,
945 modified: now,
946 stop_date: None,
947 project_uuid: None,
948 area_uuid: None,
949 parent_uuid: None,
950 tags: vec!["work".to_string(), "urgent".to_string()],
951 children: vec![],
952 };
953
954 assert_eq!(task.uuid, uuid);
955 assert_eq!(task.title, "Test Task");
956 assert_eq!(task.task_type, TaskType::Todo);
957 assert_eq!(task.status, TaskStatus::Incomplete);
958 assert_eq!(task.notes, Some("Test notes".to_string()));
959 assert_eq!(task.start_date, Some(start_date));
960 assert_eq!(task.deadline, Some(deadline));
961 assert_eq!(task.tags.len(), 2);
962 assert!(task.tags.contains(&"work".to_string()));
963 assert!(task.tags.contains(&"urgent".to_string()));
964 }
965
966 #[test]
967 fn test_task_serialization() {
968 let uuid = ThingsId::new_v4();
969 let now = Utc::now();
970
971 let task = Task {
972 uuid: uuid.clone(),
973 title: "Test Task".to_string(),
974 task_type: TaskType::Todo,
975 status: TaskStatus::Incomplete,
976 notes: None,
977 start_date: None,
978 deadline: None,
979 created: now,
980 modified: now,
981 stop_date: None,
982 project_uuid: None,
983 area_uuid: None,
984 parent_uuid: None,
985 tags: vec![],
986 children: vec![],
987 };
988
989 let serialized = serde_json::to_string(&task).unwrap();
990 let deserialized: Task = serde_json::from_str(&serialized).unwrap();
991
992 assert_eq!(deserialized.uuid, task.uuid);
993 assert_eq!(deserialized.title, task.title);
994 assert_eq!(deserialized.task_type, task.task_type);
995 assert_eq!(deserialized.status, task.status);
996 }
997
998 #[test]
999 fn test_project_creation() {
1000 let uuid = ThingsId::new_v4();
1001 let area_uuid = ThingsId::new_v4();
1002 let now = Utc::now();
1003
1004 let project = Project {
1005 uuid: uuid.clone(),
1006 title: "Test Project".to_string(),
1007 notes: Some("Project notes".to_string()),
1008 start_date: None,
1009 deadline: None,
1010 created: now,
1011 modified: now,
1012 area_uuid: Some(area_uuid.clone()),
1013 tags: vec!["project".to_string()],
1014 status: TaskStatus::Incomplete,
1015 tasks: vec![],
1016 };
1017
1018 assert_eq!(project.uuid, uuid);
1019 assert_eq!(project.title, "Test Project");
1020 assert_eq!(project.area_uuid, Some(area_uuid));
1021 assert_eq!(project.status, TaskStatus::Incomplete);
1022 assert_eq!(project.tags.len(), 1);
1023 }
1024
1025 #[test]
1026 fn test_project_serialization() {
1027 let uuid = ThingsId::new_v4();
1028 let now = Utc::now();
1029
1030 let project = Project {
1031 uuid: uuid.clone(),
1032 title: "Test Project".to_string(),
1033 notes: None,
1034 start_date: None,
1035 deadline: None,
1036 created: now,
1037 modified: now,
1038 area_uuid: None,
1039 tags: vec![],
1040 status: TaskStatus::Incomplete,
1041 tasks: vec![],
1042 };
1043
1044 let serialized = serde_json::to_string(&project).unwrap();
1045 let deserialized: Project = serde_json::from_str(&serialized).unwrap();
1046
1047 assert_eq!(deserialized.uuid, project.uuid);
1048 assert_eq!(deserialized.title, project.title);
1049 assert_eq!(deserialized.status, project.status);
1050 }
1051
1052 #[test]
1053 fn test_area_creation() {
1054 let uuid = ThingsId::new_v4();
1055 let now = Utc::now();
1056
1057 let area = Area {
1058 uuid: uuid.clone(),
1059 title: "Test Area".to_string(),
1060 notes: Some("Area notes".to_string()),
1061 created: now,
1062 modified: now,
1063 tags: vec!["area".to_string()],
1064 projects: vec![],
1065 };
1066
1067 assert_eq!(area.uuid, uuid);
1068 assert_eq!(area.title, "Test Area");
1069 assert_eq!(area.notes, Some("Area notes".to_string()));
1070 assert_eq!(area.tags.len(), 1);
1071 }
1072
1073 #[test]
1074 fn test_area_serialization() {
1075 let uuid = ThingsId::new_v4();
1076 let now = Utc::now();
1077
1078 let area = Area {
1079 uuid: uuid.clone(),
1080 title: "Test Area".to_string(),
1081 notes: None,
1082 created: now,
1083 modified: now,
1084 tags: vec![],
1085 projects: vec![],
1086 };
1087
1088 let serialized = serde_json::to_string(&area).unwrap();
1089 let deserialized: Area = serde_json::from_str(&serialized).unwrap();
1090
1091 assert_eq!(deserialized.uuid, area.uuid);
1092 assert_eq!(deserialized.title, area.title);
1093 }
1094
1095 #[test]
1096 fn test_tag_creation() {
1097 let uuid = ThingsId::new_v4();
1098 let parent_uuid = ThingsId::new_v4();
1099 let now = Utc::now();
1100
1101 let tag = Tag {
1102 uuid: uuid.clone(),
1103 title: "work".to_string(),
1104 shortcut: Some("w".to_string()),
1105 parent_uuid: Some(parent_uuid.clone()),
1106 usage_count: 5,
1107 last_used: Some(now),
1108 };
1109
1110 assert_eq!(tag.uuid, uuid);
1111 assert_eq!(tag.title, "work");
1112 assert_eq!(tag.shortcut, Some("w".to_string()));
1113 assert_eq!(tag.parent_uuid, Some(parent_uuid));
1114 assert_eq!(tag.usage_count, 5);
1115 assert_eq!(tag.last_used, Some(now));
1116 }
1117
1118 #[test]
1119 fn test_tag_serialization() {
1120 let uuid = ThingsId::new_v4();
1121
1122 let tag = Tag {
1123 uuid: uuid.clone(),
1124 title: "test".to_string(),
1125 shortcut: None,
1126 parent_uuid: None,
1127 usage_count: 0,
1128 last_used: None,
1129 };
1130
1131 let serialized = serde_json::to_string(&tag).unwrap();
1132 let deserialized: Tag = serde_json::from_str(&serialized).unwrap();
1133
1134 assert_eq!(deserialized.uuid, tag.uuid);
1135 assert_eq!(deserialized.title, tag.title);
1136 assert_eq!(deserialized.usage_count, tag.usage_count);
1137 }
1138
1139 #[test]
1140 fn test_create_task_request() {
1141 let project_uuid = ThingsId::new_v4();
1142 let area_uuid = ThingsId::new_v4();
1143 let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1144
1145 let request = CreateTaskRequest {
1146 title: "New Task".to_string(),
1147 task_type: None,
1148 notes: Some("Task notes".to_string()),
1149 start_date: Some(start_date),
1150 deadline: None,
1151 project_uuid: Some(project_uuid.clone()),
1152 area_uuid: Some(area_uuid.clone()),
1153 parent_uuid: None,
1154 tags: Some(vec!["new".to_string()]),
1155 status: None,
1156 };
1157
1158 assert_eq!(request.title, "New Task");
1159 assert_eq!(request.project_uuid, Some(project_uuid));
1160 assert_eq!(request.area_uuid, Some(area_uuid));
1161 assert_eq!(request.start_date, Some(start_date));
1162 assert_eq!(request.tags.as_ref().unwrap().len(), 1);
1163 }
1164
1165 #[test]
1166 fn test_create_task_request_serialization() {
1167 let request = CreateTaskRequest {
1168 title: "Test".to_string(),
1169 task_type: None,
1170 notes: None,
1171 start_date: None,
1172 deadline: None,
1173 project_uuid: None,
1174 area_uuid: None,
1175 parent_uuid: None,
1176 tags: None,
1177 status: None,
1178 };
1179
1180 let serialized = serde_json::to_string(&request).unwrap();
1181 let deserialized: CreateTaskRequest = serde_json::from_str(&serialized).unwrap();
1182
1183 assert_eq!(deserialized.title, request.title);
1184 }
1185
1186 #[test]
1187 fn test_update_task_request() {
1188 let uuid = ThingsId::new_v4();
1189
1190 let request = UpdateTaskRequest {
1191 uuid: uuid.clone(),
1192 title: Some("Updated Title".to_string()),
1193 notes: Some("Updated notes".to_string()),
1194 start_date: None,
1195 deadline: None,
1196 status: Some(TaskStatus::Completed),
1197 project_uuid: None,
1198 area_uuid: None,
1199 tags: Some(vec!["updated".to_string()]),
1200 };
1201
1202 assert_eq!(request.uuid, uuid);
1203 assert_eq!(request.title, Some("Updated Title".to_string()));
1204 assert_eq!(request.status, Some(TaskStatus::Completed));
1205 assert_eq!(request.tags, Some(vec!["updated".to_string()]));
1206 }
1207
1208 #[test]
1209 fn test_update_task_request_serialization() {
1210 let uuid = ThingsId::new_v4();
1211
1212 let request = UpdateTaskRequest {
1213 uuid: uuid.clone(),
1214 title: None,
1215 notes: None,
1216 start_date: None,
1217 deadline: None,
1218 status: None,
1219 project_uuid: None,
1220 area_uuid: None,
1221 tags: None,
1222 };
1223
1224 let serialized = serde_json::to_string(&request).unwrap();
1225 let deserialized: UpdateTaskRequest = serde_json::from_str(&serialized).unwrap();
1226
1227 assert_eq!(deserialized.uuid, request.uuid);
1228 }
1229
1230 #[test]
1231 fn test_task_filters_default() {
1232 let filters = TaskFilters::default();
1233
1234 assert!(filters.status.is_none());
1235 assert!(filters.task_type.is_none());
1236 assert!(filters.project_uuid.is_none());
1237 assert!(filters.area_uuid.is_none());
1238 assert!(filters.tags.is_none());
1239 assert!(filters.start_date_from.is_none());
1240 assert!(filters.start_date_to.is_none());
1241 assert!(filters.deadline_from.is_none());
1242 assert!(filters.deadline_to.is_none());
1243 assert!(filters.search_query.is_none());
1244 assert!(filters.limit.is_none());
1245 assert!(filters.offset.is_none());
1246 }
1247
1248 #[test]
1249 fn test_task_filters_creation() {
1250 let project_uuid = ThingsId::new_v4();
1251 let start_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1252
1253 let filters = TaskFilters {
1254 status: Some(TaskStatus::Incomplete),
1255 task_type: Some(TaskType::Todo),
1256 project_uuid: Some(project_uuid.clone()),
1257 area_uuid: None,
1258 tags: Some(vec!["work".to_string()]),
1259 start_date_from: Some(start_date),
1260 start_date_to: None,
1261 deadline_from: None,
1262 deadline_to: None,
1263 search_query: Some("test".to_string()),
1264 limit: Some(10),
1265 offset: Some(0),
1266 };
1267
1268 assert_eq!(filters.status, Some(TaskStatus::Incomplete));
1269 assert_eq!(filters.task_type, Some(TaskType::Todo));
1270 assert_eq!(filters.project_uuid, Some(project_uuid));
1271 assert_eq!(filters.search_query, Some("test".to_string()));
1272 assert_eq!(filters.limit, Some(10));
1273 assert_eq!(filters.offset, Some(0));
1274 }
1275
1276 #[test]
1277 fn test_task_filters_serialization() {
1278 let filters = TaskFilters {
1279 status: Some(TaskStatus::Completed),
1280 task_type: Some(TaskType::Project),
1281 project_uuid: None,
1282 area_uuid: None,
1283 tags: None,
1284 start_date_from: None,
1285 start_date_to: None,
1286 deadline_from: None,
1287 deadline_to: None,
1288 search_query: None,
1289 limit: None,
1290 offset: None,
1291 };
1292
1293 let serialized = serde_json::to_string(&filters).unwrap();
1294 let deserialized: TaskFilters = serde_json::from_str(&serialized).unwrap();
1295
1296 assert_eq!(deserialized.status, filters.status);
1297 assert_eq!(deserialized.task_type, filters.task_type);
1298 }
1299
1300 #[test]
1301 fn test_task_status_equality() {
1302 assert_eq!(TaskStatus::Incomplete, TaskStatus::Incomplete);
1303 assert_ne!(TaskStatus::Incomplete, TaskStatus::Completed);
1304 assert_ne!(TaskStatus::Completed, TaskStatus::Canceled);
1305 assert_ne!(TaskStatus::Canceled, TaskStatus::Trashed);
1306 }
1307
1308 #[test]
1309 fn test_task_type_equality() {
1310 assert_eq!(TaskType::Todo, TaskType::Todo);
1311 assert_ne!(TaskType::Todo, TaskType::Project);
1312 assert_ne!(TaskType::Project, TaskType::Heading);
1313 assert_ne!(TaskType::Heading, TaskType::Area);
1314 }
1315
1316 #[test]
1317 fn test_task_with_children() {
1318 let parent_uuid = ThingsId::new_v4();
1319 let child_uuid = ThingsId::new_v4();
1320 let now = Utc::now();
1321
1322 let child_task = Task {
1323 uuid: child_uuid,
1324 title: "Child Task".to_string(),
1325 task_type: TaskType::Todo,
1326 status: TaskStatus::Incomplete,
1327 notes: None,
1328 start_date: None,
1329 deadline: None,
1330 created: now,
1331 modified: now,
1332 stop_date: None,
1333 project_uuid: None,
1334 area_uuid: None,
1335 parent_uuid: Some(parent_uuid.clone()),
1336 tags: vec![],
1337 children: vec![],
1338 };
1339
1340 let parent_task = Task {
1341 uuid: parent_uuid.clone(),
1342 title: "Parent Task".to_string(),
1343 task_type: TaskType::Heading,
1344 status: TaskStatus::Incomplete,
1345 notes: None,
1346 start_date: None,
1347 deadline: None,
1348 created: now,
1349 modified: now,
1350 stop_date: None,
1351 project_uuid: None,
1352 area_uuid: None,
1353 parent_uuid: None,
1354 tags: vec![],
1355 children: vec![child_task],
1356 };
1357
1358 assert_eq!(parent_task.children.len(), 1);
1359 assert_eq!(parent_task.children[0].parent_uuid, Some(parent_uuid));
1360 assert_eq!(parent_task.children[0].title, "Child Task");
1361 }
1362
1363 #[test]
1364 fn test_project_with_tasks() {
1365 let project_uuid = ThingsId::new_v4();
1366 let task_uuid = ThingsId::new_v4();
1367 let now = Utc::now();
1368
1369 let task = Task {
1370 uuid: task_uuid,
1371 title: "Project Task".to_string(),
1372 task_type: TaskType::Todo,
1373 status: TaskStatus::Incomplete,
1374 notes: None,
1375 start_date: None,
1376 deadline: None,
1377 created: now,
1378 modified: now,
1379 stop_date: None,
1380 project_uuid: Some(project_uuid.clone()),
1381 area_uuid: None,
1382 parent_uuid: None,
1383 tags: vec![],
1384 children: vec![],
1385 };
1386
1387 let project = Project {
1388 uuid: project_uuid.clone(),
1389 title: "Test Project".to_string(),
1390 notes: None,
1391 start_date: None,
1392 deadline: None,
1393 created: now,
1394 modified: now,
1395 area_uuid: None,
1396 tags: vec![],
1397 status: TaskStatus::Incomplete,
1398 tasks: vec![task],
1399 };
1400
1401 assert_eq!(project.tasks.len(), 1);
1402 assert_eq!(project.tasks[0].project_uuid, Some(project_uuid));
1403 assert_eq!(project.tasks[0].title, "Project Task");
1404 }
1405
1406 #[test]
1407 fn test_area_with_projects() {
1408 let area_uuid = ThingsId::new_v4();
1409 let project_uuid = ThingsId::new_v4();
1410 let now = Utc::now();
1411
1412 let project = Project {
1413 uuid: project_uuid,
1414 title: "Area Project".to_string(),
1415 notes: None,
1416 start_date: None,
1417 deadline: None,
1418 created: now,
1419 modified: now,
1420 area_uuid: Some(area_uuid.clone()),
1421 tags: vec![],
1422 status: TaskStatus::Incomplete,
1423 tasks: vec![],
1424 };
1425
1426 let area = Area {
1427 uuid: area_uuid.clone(),
1428 title: "Test Area".to_string(),
1429 notes: None,
1430 created: now,
1431 modified: now,
1432 tags: vec![],
1433 projects: vec![project],
1434 };
1435
1436 assert_eq!(area.projects.len(), 1);
1437 assert_eq!(area.projects[0].area_uuid, Some(area_uuid));
1438 assert_eq!(area.projects[0].title, "Area Project");
1439 }
1440}