todoist_api/
models.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4/// Todoist Task model (API v1)
5/// Represents a task item as returned by the Unified API v1 (ItemSyncView)
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct Task {
8    pub id: String,
9    /// User ID of the task creator (API returns this as `user_id`)
10    #[serde(alias = "creator_id")]
11    pub user_id: String,
12    pub content: String,
13    pub description: String,
14    pub project_id: String,
15    pub section_id: Option<String>,
16    pub parent_id: Option<String>,
17    pub added_by_uid: Option<String>,
18    pub assigned_by_uid: Option<String>,
19    pub responsible_uid: Option<String>,
20    pub labels: Vec<String>,
21    pub deadline: Option<Deadline>,
22    pub duration: Option<Duration>,
23    /// Whether the task is completed
24    #[serde(default)]
25    pub checked: bool,
26    /// Whether the task is deleted
27    #[serde(default)]
28    pub is_deleted: bool,
29    pub added_at: String,
30    /// When the task was completed (ISO 8601)
31    pub completed_at: Option<String>,
32    /// User ID who completed the task
33    pub completed_by_uid: Option<String>,
34    pub updated_at: Option<String>,
35    pub due: Option<Due>,
36    pub priority: i32,
37    pub child_order: i32,
38    /// Deprecated: always returns 0
39    #[serde(default)]
40    pub note_count: i32,
41    pub day_order: i32,
42    pub is_collapsed: bool,
43}
44
45/// Todoist Project model (API v1)
46/// Represents a project as returned by the Unified API v1 (PersonalProjectSyncView)
47#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct Project {
49    pub id: String,
50    pub name: String,
51    pub color: String,
52    /// Whether the project is shared with other users
53    #[serde(alias = "shared")]
54    pub is_shared: bool,
55    pub is_favorite: bool,
56    /// Whether this is the inbox project
57    #[serde(alias = "is_inbox_project")]
58    pub inbox_project: bool,
59    pub view_style: String,
60    pub parent_id: Option<String>,
61    /// Child order in the project list
62    #[serde(default)]
63    pub child_order: i32,
64    /// User ID of the project creator
65    pub creator_uid: Option<String>,
66    /// When the project was created (ISO 8601)
67    pub created_at: Option<String>,
68    /// When the project was last updated (ISO 8601)
69    pub updated_at: Option<String>,
70    /// Whether the project is archived
71    #[serde(default)]
72    pub is_archived: bool,
73    /// Whether the project is deleted
74    #[serde(default)]
75    pub is_deleted: bool,
76    /// Whether the project is frozen (suspended)
77    #[serde(default)]
78    pub is_frozen: bool,
79    /// Whether the project is collapsed in the UI
80    #[serde(default)]
81    pub is_collapsed: bool,
82    /// Whether tasks can be assigned to collaborators
83    #[serde(default)]
84    pub can_assign_tasks: bool,
85    /// Default order for tasks
86    #[serde(default)]
87    pub default_order: i32,
88    /// Project description
89    #[serde(default)]
90    pub description: String,
91    /// Public sharing key
92    #[serde(default)]
93    pub public_key: String,
94    /// User's role in the project (owner, editor, viewer)
95    pub role: Option<String>,
96}
97
98/// Todoist Label model (API v1)
99/// Represents a label as returned by the Unified API v1 (LabelRestView)
100#[derive(Debug, Serialize, Deserialize, Clone)]
101pub struct Label {
102    pub id: String,
103    pub name: String,
104    pub color: String,
105    /// Order in the label list (can be null for some labels)
106    pub order: Option<i32>,
107    pub is_favorite: bool,
108}
109
110/// Todoist Section model (API v1)
111/// Represents a section as returned by the Unified API v1 (SectionSyncView)
112#[derive(Debug, Serialize, Deserialize, Clone)]
113pub struct Section {
114    pub id: String,
115    /// User ID of the section creator (API returns this as `user_id`)
116    #[serde(alias = "creator_id")]
117    pub user_id: String,
118    pub project_id: String,
119    pub added_at: String,
120    pub updated_at: Option<String>,
121    pub archived_at: Option<String>,
122    pub name: String,
123    pub section_order: i32,
124    pub is_archived: bool,
125    /// Whether the section is deleted
126    #[serde(default)]
127    pub is_deleted: bool,
128    pub is_collapsed: bool,
129}
130
131/// Todoist Comment model (API v1)
132/// Represents a comment as returned by the Unified API v1 (NoteSyncView)
133#[derive(Debug, Serialize, Deserialize, Clone)]
134pub struct Comment {
135    pub id: String,
136    #[serde(default)]
137    pub content: String,
138    pub posted_at: Option<String>,
139    pub posted_uid: Option<String>,
140    /// File attachment (API returns this as `file_attachment`)
141    #[serde(alias = "attachment")]
142    pub file_attachment: Option<Attachment>,
143    /// User IDs to notify about this comment
144    pub uids_to_notify: Option<Vec<String>>,
145    /// Whether the comment is deleted
146    #[serde(default)]
147    pub is_deleted: bool,
148    /// Reactions on the comment (emoji -> list of user IDs)
149    pub reactions: Option<serde_json::Value>,
150    /// Project ID (only present in request context, not API response)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub project_id: Option<String>,
153    /// Task ID (only present in request context, not API response)
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub task_id: Option<String>,
156}
157
158/// Todoist Attachment model
159#[derive(Debug, Serialize, Deserialize, Clone)]
160pub struct Attachment {
161    pub file_name: String,
162    pub file_type: String,
163    pub file_url: String,
164    pub resource_type: String,
165}
166
167/// Todoist User model
168#[derive(Debug, Serialize, Deserialize, Clone)]
169pub struct User {
170    pub id: String,
171    pub name: String,
172    pub email: String,
173    pub avatar_url: Option<String>,
174    pub is_premium: bool,
175    pub is_business_account: bool,
176}
177
178/// Todoist Due date model (API v1)
179/// Represents a due date as returned by the Unified API v1
180#[derive(Debug, Serialize, Deserialize, Clone)]
181pub struct Due {
182    pub string: String,
183    pub date: String,
184    pub is_recurring: bool,
185    pub datetime: Option<String>,
186    pub timezone: Option<String>,
187    /// Language of the due string
188    pub lang: Option<String>,
189}
190
191/// Todoist Deadline model (API v1)
192/// Represents a deadline as returned by the Unified API v1
193#[derive(Debug, Serialize, Deserialize, Clone)]
194pub struct Deadline {
195    pub date: String,
196    /// Language of the deadline string
197    pub lang: Option<String>,
198}
199
200/// Todoist Duration model
201#[derive(Debug, Serialize, Deserialize, Clone)]
202pub struct Duration {
203    pub amount: i32,
204    pub unit: String, // "minute", "hour", "day"
205}
206
207/// Paginated response wrapper for API v1
208/// All list endpoints in API v1 return results in this format
209#[derive(Debug, Serialize, Deserialize, Clone)]
210pub struct PaginatedResponse<T> {
211    pub results: Vec<T>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub next_cursor: Option<String>,
214}
215
216/// Task creation arguments
217#[derive(Debug, Serialize, Default)]
218pub struct CreateTaskArgs {
219    pub content: String,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub description: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub project_id: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub section_id: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub parent_id: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub order: Option<i32>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub priority: Option<i32>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub labels: Option<Vec<String>>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub due_string: Option<String>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub due_date: Option<String>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub due_datetime: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub due_lang: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub deadline_date: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub deadline_lang: Option<String>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub duration: Option<i32>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub duration_unit: Option<String>,
250}
251
252/// Task update arguments
253#[derive(Debug, Serialize, Default)]
254pub struct UpdateTaskArgs {
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub content: Option<String>,
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub description: Option<String>,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub priority: Option<i32>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub labels: Option<Vec<String>>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub due_string: Option<String>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub due_date: Option<String>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub due_datetime: Option<String>,
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub due_lang: Option<String>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub deadline_date: Option<String>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub deadline_lang: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub duration: Option<i32>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub duration_unit: Option<String>,
279}
280
281impl UpdateTaskArgs {
282    /// Check if any fields are set for updating
283    pub fn has_updates(&self) -> bool {
284        self.content.is_some()
285            || self.description.is_some()
286            || self.priority.is_some()
287            || self.labels.is_some()
288            || self.due_string.is_some()
289            || self.due_date.is_some()
290            || self.due_datetime.is_some()
291            || self.due_lang.is_some()
292            || self.deadline_date.is_some()
293            || self.deadline_lang.is_some()
294            || self.duration.is_some()
295            || self.duration_unit.is_some()
296    }
297}
298
299/// Project creation arguments
300#[derive(Debug, Serialize, Default)]
301pub struct CreateProjectArgs {
302    pub name: String,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub color: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub parent_id: Option<String>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub is_favorite: Option<bool>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub view_style: Option<String>,
311}
312
313/// Project update arguments
314#[derive(Debug, Serialize, Default)]
315pub struct UpdateProjectArgs {
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub name: Option<String>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub color: Option<String>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub is_favorite: Option<bool>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub view_style: Option<String>,
324}
325
326impl UpdateProjectArgs {
327    /// Check if any fields are set for updating
328    pub fn has_updates(&self) -> bool {
329        self.name.is_some() || self.color.is_some() || self.is_favorite.is_some() || self.view_style.is_some()
330    }
331}
332
333/// Label creation arguments
334#[derive(Debug, Serialize, Default)]
335pub struct CreateLabelArgs {
336    pub name: String,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub color: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub order: Option<i32>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub is_favorite: Option<bool>,
343}
344
345/// Label update arguments
346#[derive(Debug, Serialize, Default)]
347pub struct UpdateLabelArgs {
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub name: Option<String>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub color: Option<String>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub order: Option<i32>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub is_favorite: Option<bool>,
356}
357
358impl UpdateLabelArgs {
359    /// Check if any fields are set for updating
360    pub fn has_updates(&self) -> bool {
361        self.name.is_some() || self.color.is_some() || self.order.is_some() || self.is_favorite.is_some()
362    }
363}
364
365/// Section creation arguments
366#[derive(Debug, Serialize, Default)]
367pub struct CreateSectionArgs {
368    pub name: String,
369    pub project_id: String,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub order: Option<i32>,
372}
373
374/// Section update arguments
375#[derive(Debug, Serialize, Default)]
376pub struct UpdateSectionArgs {
377    pub name: String,
378}
379
380/// Comment creation arguments
381#[derive(Debug, Serialize, Default)]
382pub struct CreateCommentArgs {
383    pub content: String,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub task_id: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub project_id: Option<String>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub attachment: Option<Attachment>,
390}
391
392/// Comment update arguments
393#[derive(Debug, Serialize, Default)]
394pub struct UpdateCommentArgs {
395    pub content: String,
396}
397
398impl UpdateCommentArgs {
399    /// Check if any fields are set for updating
400    /// Note: UpdateCommentArgs only has required fields, so this always returns true when instantiated
401    pub fn has_updates(&self) -> bool {
402        !self.content.is_empty()
403    }
404}
405
406/// Task filter arguments
407#[derive(Debug, Serialize)]
408pub struct TaskFilterArgs {
409    pub query: String,
410    pub lang: Option<String>,
411    pub limit: Option<i32>,
412    pub cursor: Option<String>,
413}
414
415/// Project filter arguments
416#[derive(Debug, Serialize)]
417pub struct ProjectFilterArgs {
418    pub limit: Option<i32>,
419    pub cursor: Option<String>,
420}
421
422/// Label filter arguments
423#[derive(Debug, Serialize)]
424pub struct LabelFilterArgs {
425    pub limit: Option<i32>,
426    pub cursor: Option<String>,
427}
428
429/// Section filter arguments
430#[derive(Debug, Serialize)]
431pub struct SectionFilterArgs {
432    pub project_id: Option<String>,
433    pub limit: Option<i32>,
434    pub cursor: Option<String>,
435}
436
437/// Comment filter arguments
438#[derive(Debug, Serialize)]
439pub struct CommentFilterArgs {
440    pub task_id: Option<String>,
441    pub project_id: Option<String>,
442    pub limit: Option<i32>,
443    pub cursor: Option<String>,
444}
445
446/// Completed tasks filter arguments
447/// Used for querying completed tasks by completion date or due date
448#[derive(Debug, Serialize, Default)]
449pub struct CompletedTasksFilterArgs {
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub project_id: Option<String>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub section_id: Option<String>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub since: Option<String>, // ISO 8601 datetime
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub until: Option<String>, // ISO 8601 datetime
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub limit: Option<i32>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub cursor: Option<String>,
462}
463
464/// Represents different types of errors that can occur when interacting with the Todoist API
465#[derive(Debug, Clone)]
466pub enum TodoistError {
467    /// Rate limiting error (HTTP 429)
468    RateLimited { retry_after: Option<u64>, message: String },
469    /// Authentication error (HTTP 401)
470    AuthenticationError { message: String },
471    /// Authorization error (HTTP 403)
472    AuthorizationError { message: String },
473    /// Resource not found (HTTP 404)
474    NotFound {
475        resource_type: String,
476        resource_id: Option<String>,
477        message: String,
478    },
479    /// Validation error (HTTP 400)
480    ValidationError { field: Option<String>, message: String },
481    /// Server error (HTTP 5xx)
482    ServerError { status_code: u16, message: String },
483    /// Network/connection error
484    NetworkError { message: String },
485    /// JSON parsing error
486    ParseError { message: String },
487    /// Unexpected empty response (when API returns nothing)
488    EmptyResponse { endpoint: String, message: String },
489    /// Generic error for other cases
490    Generic { status_code: Option<u16>, message: String },
491}
492
493impl TodoistError {
494    /// Check if this is a rate limiting error
495    pub fn is_rate_limited(&self) -> bool {
496        matches!(self, TodoistError::RateLimited { .. })
497    }
498
499    /// Check if this is an authentication error
500    pub fn is_authentication_error(&self) -> bool {
501        matches!(self, TodoistError::AuthenticationError { .. })
502    }
503
504    /// Check if this is an authorization error
505    pub fn is_authorization_error(&self) -> bool {
506        matches!(self, TodoistError::AuthorizationError { .. })
507    }
508
509    /// Check if this is a not found error
510    pub fn is_not_found(&self) -> bool {
511        matches!(self, TodoistError::NotFound { .. })
512    }
513
514    /// Check if this is a validation error
515    pub fn is_validation_error(&self) -> bool {
516        matches!(self, TodoistError::ValidationError { .. })
517    }
518
519    /// Check if this is a server error
520    pub fn is_server_error(&self) -> bool {
521        matches!(self, TodoistError::ServerError { .. })
522    }
523
524    /// Check if this is a network error
525    pub fn is_network_error(&self) -> bool {
526        matches!(self, TodoistError::NetworkError { .. })
527    }
528
529    /// Check if this is an empty response error
530    pub fn is_empty_response(&self) -> bool {
531        matches!(self, TodoistError::EmptyResponse { .. })
532    }
533
534    /// Get the retry after value for rate limiting errors
535    pub fn retry_after(&self) -> Option<u64> {
536        match self {
537            TodoistError::RateLimited { retry_after, .. } => *retry_after,
538            _ => None,
539        }
540    }
541
542    /// Get the HTTP status code if available
543    pub fn status_code(&self) -> Option<u16> {
544        match self {
545            TodoistError::ServerError { status_code, .. } => Some(*status_code),
546            TodoistError::Generic { status_code, .. } => *status_code,
547            _ => None,
548        }
549    }
550}
551
552impl fmt::Display for TodoistError {
553    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554        match self {
555            TodoistError::RateLimited { retry_after, message } => {
556                if let Some(seconds) = retry_after {
557                    write!(f, "Rate limited: {} (retry after {} seconds)", message, seconds)
558                } else {
559                    write!(f, "Rate limited: {}", message)
560                }
561            }
562            TodoistError::AuthenticationError { message } => {
563                write!(f, "Authentication error: {}", message)
564            }
565            TodoistError::AuthorizationError { message } => {
566                write!(f, "Authorization error: {}", message)
567            }
568            TodoistError::NotFound {
569                resource_type,
570                resource_id,
571                message,
572            } => {
573                if let Some(id) = resource_id {
574                    write!(f, "{} not found (ID: {}): {}", resource_type, id, message)
575                } else {
576                    write!(f, "{} not found: {}", resource_type, message)
577                }
578            }
579            TodoistError::ValidationError { field, message } => {
580                if let Some(field_name) = field {
581                    write!(f, "Validation error for field '{}': {}", field_name, message)
582                } else {
583                    write!(f, "Validation error: {}", message)
584                }
585            }
586            TodoistError::ServerError { status_code, message } => {
587                write!(f, "Server error ({}): {}", status_code, message)
588            }
589            TodoistError::NetworkError { message } => {
590                write!(f, "Network error: {}", message)
591            }
592            TodoistError::ParseError { message } => {
593                write!(f, "Parse error: {}", message)
594            }
595            TodoistError::EmptyResponse { endpoint, message } => {
596                write!(f, "Empty response from {}: {}", endpoint, message)
597            }
598            TodoistError::Generic { status_code, message } => {
599                if let Some(code) = status_code {
600                    write!(f, "Error ({}): {}", code, message)
601                } else {
602                    write!(f, "Error: {}", message)
603                }
604            }
605        }
606    }
607}
608
609impl std::error::Error for TodoistError {}
610
611impl From<reqwest::Error> for TodoistError {
612    fn from(err: reqwest::Error) -> Self {
613        TodoistError::NetworkError {
614            message: format!("Request failed: {}", err),
615        }
616    }
617}
618
619impl From<serde_json::Error> for TodoistError {
620    fn from(err: serde_json::Error) -> Self {
621        TodoistError::ParseError {
622            message: format!("JSON error: {}", err),
623        }
624    }
625}
626
627/// Result type for Todoist API operations
628pub type TodoistResult<T> = Result<T, TodoistError>;
629
630/// Helper function to create a rate limiting error
631pub fn rate_limited_error(message: impl Into<String>, retry_after: Option<u64>) -> TodoistError {
632    TodoistError::RateLimited {
633        retry_after,
634        message: message.into(),
635    }
636}
637
638/// Helper function to create an empty response error
639pub fn empty_response_error(endpoint: impl Into<String>, message: impl Into<String>) -> TodoistError {
640    TodoistError::EmptyResponse {
641        endpoint: endpoint.into(),
642        message: message.into(),
643    }
644}
645
646/// Helper function to create a not found error
647pub fn not_found_error(
648    resource_type: impl Into<String>,
649    resource_id: Option<impl Into<String>>,
650    message: impl Into<String>,
651) -> TodoistError {
652    TodoistError::NotFound {
653        resource_type: resource_type.into(),
654        resource_id: resource_id.map(|id| id.into()),
655        message: message.into(),
656    }
657}