Skip to main content

things3_core/
models.rs

1//! Data models for Things 3 entities
2
3use 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/// Identifier for any Things 3 entity (task, project, area, tag, heading).
13///
14/// Things 3 uses two distinct identifier formats in the wild:
15///
16/// 1. **Native Things IDs** — 21- or 22-character base62 strings the Things
17///    app itself produces (e.g. `R4t2G8Q63aGZq4epMHNeCr`). These appear on
18///    every entity created via the Things UI or via `osascript`.
19/// 2. **RFC-4122 UUIDs** — 36-character hyphenated hex strings that the
20///    `SqlxBackend` generates for entities created through rust-things3
21///    (e.g. `9d3f1e44-5c2a-4b8e-9c1f-7e2d8a4b3c5e`).
22///
23/// Both formats coexist in the same SQLite `uuid` column. This type stores
24/// whichever format the entity was created with — never lossy conversion,
25/// always round-trip-safe through `osascript`, the database, and JSON wire
26/// format.
27///
28/// `#[serde(transparent)]` means the JSON shape is unchanged from when the
29/// type was `Uuid`: a bare string field, no enum tagging.
30///
31/// # Validation boundary
32///
33/// `serde` deserialization (via `#[serde(transparent)]`) accepts **any** string
34/// and does **not** call `from_str` — it calls `from_trusted` semantics. This is
35/// intentional: request structs deserialized from the DB or from AppleScript output
36/// already contain trusted values. If you add a new code path that deserializes a
37/// request struct containing `ThingsId` fields directly from untrusted JSON (e.g. a
38/// new HTTP handler), call `ThingsId::from_str` explicitly on each ID field before
39/// acting on it rather than relying on serde to validate.
40///
41/// # Construction
42///
43/// - [`ThingsId::new_v4`] — fresh hyphenated UUID, used by `SqlxBackend`
44/// - [`ThingsId::from_str`] — strict parse, rejects anything that isn't one
45///   of the two known formats; used at MCP boundaries
46/// - [`From<Uuid>`] — infallible, wraps a UUID's hyphenated form
47#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(transparent)]
49pub struct ThingsId(String);
50
51impl ThingsId {
52    /// Generate a fresh hyphenated UUID, suitable for `SqlxBackend`-created
53    /// entities.
54    #[must_use]
55    pub fn new_v4() -> Self {
56        Self(Uuid::new_v4().to_string())
57    }
58
59    /// Generate a fresh Things-native 22-char Base62 ID. Use this for any
60    /// new entity that may be referenced via AppleScript — Things 3's
61    /// AppleScript dictionary only accepts this format in `to do id "..."`
62    /// references, so a hyphenated UUID would fail with `-1728` (#148).
63    ///
64    /// Internally derived from a v4 UUID's 16 random bytes, base62-encoded
65    /// (a 16-byte / 128-bit value fits in 22 base62 characters).
66    #[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    /// Borrow the underlying string (for SQL parameter binding, AppleScript
73    /// interpolation, logging, etc.).
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78
79    /// Consume into the owned `String`.
80    #[must_use]
81    pub fn into_string(self) -> String {
82        self.0
83    }
84
85    /// Construct without validation. Reserved for trusted sources only —
86    /// values read directly from the SQLite `uuid` column or returned by
87    /// `osascript`. Public input must go through [`FromStr`].
88    pub(crate) fn from_trusted(s: String) -> Self {
89        Self(s)
90    }
91
92    /// Returns true if `s` matches the native 21–22-char base62 Things format.
93    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    /// Borrow the underlying string if it's already in Things native format
99    /// (21–22-char Base62), or return a `Validation` error pointing the
100    /// caller at the recreate-the-entity remediation.
101    ///
102    /// AppleScript-driving call sites use this to fail fast with a useful
103    /// message instead of letting a hyphenated UUID reach `osascript`
104    /// (which returns the opaque error `-1728` "Can't get to do id ...").
105    #[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
122/// Encode 16 bytes into a fixed 22-char Base62 string (alphabet:
123/// `0-9A-Za-z`). 16 bytes = 128 bits; 22 base62 chars hold ~131 bits, so
124/// the encoding is fixed-length with leading-zero padding.
125fn base62_encode_22(bytes: &[u8; 16]) -> String {
126    // Alphabet is 0-9A-Za-z (digits, uppercase, lowercase). Arbitrary but
127    // stable — Things 3's native-ID check only requires length 21-22 and
128    // ASCII alphanumeric, not a specific ordering.
129    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    /// Strict parse. Accepts:
149    /// - Hyphenated RFC-4122 UUIDs (36 chars)
150    /// - Things native IDs (21 or 22 base62 chars)
151    ///
152    /// Anything else returns a `ThingsError::Validation` so MCP callers see a
153    /// clear error before the request hits the database.
154    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        // All-zero input → 22 leading-zero alphabet chars (i.e. all '0').
245        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        // All-0xFF input is the largest u128, which maps to "7n42DGM5Tflk9n8mt7Fhc7"
252        // (21 non-zero chars). Just verify length + alphabet, not the exact value.
253        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        // Real example pulled from a Things 3 database.
276        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        // 23 chars — wrong length for native, wrong format for UUID
291        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        // 22 chars, but contains `-` (which is fine for UUIDs but not native)
298        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        // Valid UUID prefix + extra chars
311        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        // Confirms the internal escape hatch works for DB/AS-sourced strings.
349        // Deliberately weird value to prove no validation happens.
350        let id = ThingsId::from_trusted("anything-goes-here".to_string());
351        assert_eq!(id.as_str(), "anything-goes-here");
352    }
353}
354
355/// Task status enumeration
356#[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    /// Filter-input only. Trashed tasks are surfaced by the `trashed` column predicate,
366    /// not by a status value — this variant is never returned from any read path.
367    #[serde(rename = "trashed")]
368    Trashed,
369}
370
371/// Task type enumeration
372#[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/// How to handle child tasks when deleting a parent
386#[non_exhaustive]
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
388pub enum DeleteChildHandling {
389    /// Return error if task has children (default)
390    #[serde(rename = "error")]
391    Error,
392    /// Delete parent and all children
393    #[serde(rename = "cascade")]
394    Cascade,
395    /// Delete parent only, orphan children
396    #[serde(rename = "orphan")]
397    Orphan,
398}
399
400/// Main task entity
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Task {
403    /// Unique identifier
404    pub uuid: ThingsId,
405    /// Task title
406    pub title: String,
407    /// Task type
408    pub task_type: TaskType,
409    /// Task status
410    pub status: TaskStatus,
411    /// Optional notes
412    pub notes: Option<String>,
413    /// Start date
414    pub start_date: Option<NaiveDate>,
415    /// Deadline
416    pub deadline: Option<NaiveDate>,
417    /// Creation timestamp
418    pub created: DateTime<Utc>,
419    /// Last modification timestamp
420    pub modified: DateTime<Utc>,
421    /// Completion timestamp (when status changed to completed)
422    pub stop_date: Option<DateTime<Utc>>,
423    /// Parent project UUID
424    pub project_uuid: Option<ThingsId>,
425    /// Parent area UUID
426    pub area_uuid: Option<ThingsId>,
427    /// Parent task UUID
428    pub parent_uuid: Option<ThingsId>,
429    /// Associated tags
430    pub tags: Vec<String>,
431    /// Child tasks
432    pub children: Vec<Task>,
433}
434
435/// Project entity
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct Project {
438    /// Unique identifier
439    pub uuid: ThingsId,
440    /// Project title
441    pub title: String,
442    /// Optional notes
443    pub notes: Option<String>,
444    /// Start date
445    pub start_date: Option<NaiveDate>,
446    /// Deadline
447    pub deadline: Option<NaiveDate>,
448    /// Creation timestamp
449    pub created: DateTime<Utc>,
450    /// Last modification timestamp
451    pub modified: DateTime<Utc>,
452    /// Parent area UUID
453    pub area_uuid: Option<ThingsId>,
454    /// Associated tags
455    pub tags: Vec<String>,
456    /// Project status
457    pub status: TaskStatus,
458    /// Child tasks
459    pub tasks: Vec<Task>,
460}
461
462/// Area entity
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct Area {
465    /// Unique identifier
466    pub uuid: ThingsId,
467    /// Area title
468    pub title: String,
469    /// Optional notes
470    pub notes: Option<String>,
471    /// Creation timestamp
472    pub created: DateTime<Utc>,
473    /// Last modification timestamp
474    pub modified: DateTime<Utc>,
475    /// Associated tags
476    pub tags: Vec<String>,
477    /// Child projects
478    pub projects: Vec<Project>,
479}
480
481/// Tag entity (enhanced with duplicate prevention support)
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct Tag {
484    /// Unique identifier
485    pub uuid: ThingsId,
486    /// Tag title (display form, preserves case)
487    pub title: String,
488    /// Keyboard shortcut
489    pub shortcut: Option<String>,
490    /// Parent tag UUID (for nested tags)
491    pub parent_uuid: Option<ThingsId>,
492    /// How many tasks use this tag
493    pub usage_count: u32,
494    /// Last time this tag was used
495    pub last_used: Option<DateTime<Utc>>,
496}
497
498/// Tag creation request
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct CreateTagRequest {
501    /// Tag title (required)
502    pub title: String,
503    /// Keyboard shortcut
504    pub shortcut: Option<String>,
505    /// Parent tag UUID (for nested tags)
506    pub parent_uuid: Option<ThingsId>,
507}
508
509/// Tag update request
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct UpdateTagRequest {
512    /// Tag UUID
513    pub uuid: ThingsId,
514    /// New title
515    pub title: Option<String>,
516    /// New shortcut
517    pub shortcut: Option<String>,
518    /// New parent UUID
519    pub parent_uuid: Option<ThingsId>,
520}
521
522/// Tag match type classification
523#[non_exhaustive]
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525pub enum TagMatchType {
526    /// Exact match (case-insensitive)
527    #[serde(rename = "exact")]
528    Exact,
529    /// Same text, different case
530    #[serde(rename = "case_mismatch")]
531    CaseMismatch,
532    /// Fuzzy match (high similarity via Levenshtein distance)
533    #[serde(rename = "similar")]
534    Similar,
535    /// Substring/contains match
536    #[serde(rename = "partial")]
537    PartialMatch,
538}
539
540/// Tag search result with similarity scoring
541#[derive(Debug, Clone, Serialize, Deserialize)]
542pub struct TagMatch {
543    /// The matched tag
544    pub tag: Tag,
545    /// Similarity score (0.0 to 1.0, higher is better)
546    pub similarity_score: f32,
547    /// Type of match
548    pub match_type: TagMatchType,
549}
550
551/// Result of tag creation with duplicate checking
552#[non_exhaustive]
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub enum TagCreationResult {
555    /// New tag was created
556    #[serde(rename = "created")]
557    Created {
558        /// UUID of created tag
559        uuid: ThingsId,
560        /// Always true for this variant
561        is_new: bool,
562    },
563    /// Existing exact match found
564    #[serde(rename = "existing")]
565    Existing {
566        /// The existing tag
567        tag: Tag,
568        /// Always false for this variant
569        is_new: bool,
570    },
571    /// Similar tags found (user decision needed)
572    #[serde(rename = "similar_found")]
573    SimilarFound {
574        /// Tags similar to requested title
575        similar_tags: Vec<TagMatch>,
576        /// The title user requested
577        requested_title: String,
578    },
579}
580
581/// Result of tag assignment to task
582#[non_exhaustive]
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub enum TagAssignmentResult {
585    /// Tag assigned successfully
586    #[serde(rename = "assigned")]
587    Assigned {
588        /// UUID of the tag that was assigned
589        tag_uuid: ThingsId,
590    },
591    /// Similar tags found (user decision needed)
592    #[serde(rename = "suggestions")]
593    Suggestions {
594        /// Suggested alternative tags
595        similar_tags: Vec<TagMatch>,
596    },
597}
598
599/// Tag auto-completion suggestion
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct TagCompletion {
602    /// The tag
603    pub tag: Tag,
604    /// Priority score (based on usage, recency, match quality)
605    pub score: f32,
606}
607
608/// Tag statistics for analytics
609#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct TagStatistics {
611    /// Tag UUID
612    pub uuid: ThingsId,
613    /// Tag title
614    pub title: String,
615    /// Total usage count
616    pub usage_count: u32,
617    /// Task UUIDs using this tag
618    pub task_uuids: Vec<ThingsId>,
619    /// Related tags (frequently used together)
620    pub related_tags: Vec<(String, u32)>, // (tag_title, co_occurrence_count)
621}
622
623/// Pair of similar tags (for duplicate detection)
624#[derive(Debug, Clone, Serialize, Deserialize)]
625pub struct TagPair {
626    /// First tag
627    pub tag1: Tag,
628    /// Second tag
629    pub tag2: Tag,
630    /// Similarity score between them
631    pub similarity: f32,
632}
633
634/// Task creation request
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct CreateTaskRequest {
637    /// Task title (required)
638    pub title: String,
639    /// Task type (defaults to Todo)
640    pub task_type: Option<TaskType>,
641    /// Optional notes
642    pub notes: Option<String>,
643    /// Start date
644    pub start_date: Option<NaiveDate>,
645    /// Deadline
646    pub deadline: Option<NaiveDate>,
647    /// Project UUID (validated if provided)
648    pub project_uuid: Option<ThingsId>,
649    /// Area UUID (validated if provided)
650    pub area_uuid: Option<ThingsId>,
651    /// Parent task UUID (for subtasks)
652    pub parent_uuid: Option<ThingsId>,
653    /// Tags (as string names)
654    pub tags: Option<Vec<String>>,
655    /// Initial status (defaults to Incomplete)
656    pub status: Option<TaskStatus>,
657}
658
659/// Task update request
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct UpdateTaskRequest {
662    /// Task UUID
663    pub uuid: ThingsId,
664    /// New title
665    pub title: Option<String>,
666    /// New notes
667    pub notes: Option<String>,
668    /// New start date
669    pub start_date: Option<NaiveDate>,
670    /// New deadline
671    pub deadline: Option<NaiveDate>,
672    /// New status
673    pub status: Option<TaskStatus>,
674    /// New project UUID
675    pub project_uuid: Option<ThingsId>,
676    /// New area UUID
677    pub area_uuid: Option<ThingsId>,
678    /// New tags
679    pub tags: Option<Vec<String>>,
680}
681
682/// Task filters for queries
683#[derive(Debug, Clone, Serialize, Deserialize, Default)]
684pub struct TaskFilters {
685    /// Filter by status
686    pub status: Option<TaskStatus>,
687    /// Filter by task type
688    pub task_type: Option<TaskType>,
689    /// Filter by project UUID
690    pub project_uuid: Option<ThingsId>,
691    /// Filter by area UUID
692    pub area_uuid: Option<ThingsId>,
693    /// Filter by tags (AND semantics — task must contain every listed tag).
694    pub tags: Option<Vec<String>>,
695    /// Filter by start date range
696    pub start_date_from: Option<NaiveDate>,
697    pub start_date_to: Option<NaiveDate>,
698    /// Filter by deadline range
699    pub deadline_from: Option<NaiveDate>,
700    pub deadline_to: Option<NaiveDate>,
701    /// Search query
702    pub search_query: Option<String>,
703    /// Limit results
704    pub limit: Option<usize>,
705    /// Offset for pagination
706    pub offset: Option<usize>,
707}
708
709/// A task paired with its fuzzy-match relevance score.
710///
711/// Returned by [`crate::query::TaskQueryBuilder::execute_ranked`].
712///
713/// Requires the `advanced-queries` feature flag.
714#[cfg(feature = "advanced-queries")]
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct RankedTask {
717    /// The matched task.
718    pub task: Task,
719    /// Relevance score in `[0.0, 1.0]`; higher is a better match.
720    pub score: f32,
721}
722
723/// Project creation request
724#[derive(Debug, Clone, Serialize, Deserialize)]
725pub struct CreateProjectRequest {
726    /// Project title (required)
727    pub title: String,
728    /// Optional notes
729    pub notes: Option<String>,
730    /// Area UUID (validated if provided)
731    pub area_uuid: Option<ThingsId>,
732    /// Start date
733    pub start_date: Option<NaiveDate>,
734    /// Deadline
735    pub deadline: Option<NaiveDate>,
736    /// Tags (as string names)
737    pub tags: Option<Vec<String>>,
738}
739
740/// Project update request
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct UpdateProjectRequest {
743    /// Project UUID
744    pub uuid: ThingsId,
745    /// New title
746    pub title: Option<String>,
747    /// New notes
748    pub notes: Option<String>,
749    /// New area UUID
750    pub area_uuid: Option<ThingsId>,
751    /// New start date
752    pub start_date: Option<NaiveDate>,
753    /// New deadline
754    pub deadline: Option<NaiveDate>,
755    /// New tags
756    pub tags: Option<Vec<String>>,
757}
758
759/// Area creation request
760#[derive(Debug, Clone, Serialize, Deserialize)]
761pub struct CreateAreaRequest {
762    /// Area title (required)
763    pub title: String,
764}
765
766/// Area update request
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct UpdateAreaRequest {
769    /// Area UUID
770    pub uuid: ThingsId,
771    /// New title
772    pub title: String,
773}
774
775/// How to handle child tasks when completing/deleting a project
776#[non_exhaustive]
777#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
778pub enum ProjectChildHandling {
779    /// Return error if project has child tasks (default, safest)
780    #[serde(rename = "error")]
781    #[default]
782    Error,
783    /// Complete/delete all child tasks
784    #[serde(rename = "cascade")]
785    Cascade,
786    /// Move child tasks to inbox (orphan them)
787    #[serde(rename = "orphan")]
788    Orphan,
789}
790
791// ============================================================================
792// Bulk Operation Models
793// ============================================================================
794
795/// Request to move multiple tasks to a project or area
796#[derive(Debug, Clone, Serialize, Deserialize)]
797pub struct BulkMoveRequest {
798    /// Task UUIDs to move
799    pub task_uuids: Vec<ThingsId>,
800    /// Target project UUID (optional)
801    pub project_uuid: Option<ThingsId>,
802    /// Target area UUID (optional)
803    pub area_uuid: Option<ThingsId>,
804}
805
806/// Request to update dates for multiple tasks
807#[derive(Debug, Clone, Serialize, Deserialize)]
808pub struct BulkUpdateDatesRequest {
809    /// Task UUIDs to update
810    pub task_uuids: Vec<ThingsId>,
811    /// New start date (None means don't update)
812    pub start_date: Option<NaiveDate>,
813    /// New deadline (None means don't update)
814    pub deadline: Option<NaiveDate>,
815    /// Clear start date (set to NULL)
816    pub clear_start_date: bool,
817    /// Clear deadline (set to NULL)
818    pub clear_deadline: bool,
819}
820
821/// Request to complete multiple tasks
822#[derive(Debug, Clone, Serialize, Deserialize)]
823pub struct BulkCompleteRequest {
824    /// Task UUIDs to complete
825    pub task_uuids: Vec<ThingsId>,
826}
827
828/// Request to delete multiple tasks
829#[derive(Debug, Clone, Serialize, Deserialize)]
830pub struct BulkDeleteRequest {
831    /// Task UUIDs to delete
832    pub task_uuids: Vec<ThingsId>,
833}
834
835/// Request to create multiple tasks in one call.
836///
837/// Bulk creation is atomic — if any task fails, all tasks created so far are
838/// rolled back and the caller receives an error with no partial state left.
839#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct BulkCreateTasksRequest {
841    /// Tasks to create
842    pub tasks: Vec<CreateTaskRequest>,
843}
844
845/// Result of a bulk operation
846#[derive(Debug, Clone, Serialize, Deserialize)]
847pub struct BulkOperationResult {
848    /// Whether the operation succeeded
849    pub success: bool,
850    /// Number of tasks processed
851    pub processed_count: usize,
852    /// Result message
853    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}