Skip to main content

things_mcp/tools/
todos.rs

1//! Read tools that surface a single to-do.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::core::reader::queries::{get_tags_for_task, get_todo};
7use crate::core::types::MaybeTodo;
8use crate::state::AppState;
9
10#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
11pub struct GetTodoArgs {
12    /// The to-do's UUID (`TMTask.uuid`).
13    pub id: String,
14}
15
16pub async fn things_get_todo(
17    state: AppState,
18    args: GetTodoArgs,
19) -> anyhow::Result<MaybeTodo> {
20    let todo = get_todo(&state.pool, args.id).await?;
21    Ok(MaybeTodo { todo })
22}
23
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use crate::core::writer::operation::{AddTodoSpec, Operation};
27use crate::core::writer::outcome::WriteOutcome;
28use crate::core::writer::verify::VerifyPredicate;
29
30#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
31pub struct AddTodoArgs {
32    /// To-do title. Required, non-empty.
33    pub title: String,
34    /// Free-text notes (optional).
35    #[serde(default)]
36    pub notes: Option<String>,
37    /// `"today"`, `"tomorrow"`, `"evening"`, `"anytime"`, `"someday"`, or an
38    /// ISO date / timestamp. Optional.
39    #[serde(default)]
40    pub when: Option<String>,
41    /// ISO `YYYY-MM-DD` deadline. Optional.
42    #[serde(default)]
43    pub deadline: Option<String>,
44    /// Tag titles to attach to the new to-do. Optional.
45    #[serde(default)]
46    pub tags: Vec<String>,
47    /// Checklist item titles, in display order. Optional.
48    #[serde(default)]
49    pub checklist_items: Vec<String>,
50    /// Project or area UUID this to-do should belong to. Optional.
51    #[serde(default)]
52    pub list_id: Option<String>,
53    /// Heading UUID, if filing under a specific heading inside a project. Optional.
54    #[serde(default)]
55    pub heading_id: Option<String>,
56}
57
58pub async fn things_add_todo(
59    state: AppState,
60    args: AddTodoArgs,
61) -> anyhow::Result<WriteOutcome> {
62    if args.title.trim().is_empty() {
63        return Err(crate::core::error::ThingsError::InvalidInput {
64            field: "title".into(),
65            reason: "title must be non-empty".into(),
66        }
67        .into());
68    }
69    let since_unix = SystemTime::now()
70        .duration_since(UNIX_EPOCH)
71        .map(|d| d.as_secs_f64())
72        .unwrap_or(0.0);
73    let op = Operation::AddTodo(AddTodoSpec {
74        title: args.title.clone(),
75        notes: args.notes,
76        when: args.when,
77        deadline: args.deadline,
78        tags: args.tags,
79        checklist_items: args.checklist_items,
80        list_id: args.list_id,
81        heading_id: args.heading_id,
82    });
83    let predicate = VerifyPredicate::CreateByTitle {
84        title: args.title,
85        since_unix,
86        kind: crate::core::types::TaskKind::Todo,
87    };
88    let outcome = state.writer.fire(op, Some(predicate)).await?;
89    Ok(outcome)
90}
91
92use crate::core::writer::operation::{MoveTodoSpec, UpdateTodoSpec};
93
94#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
95pub struct UpdateTodoArgs {
96    pub id: String,
97    #[serde(default)]
98    pub title: Option<String>,
99    #[serde(default)]
100    pub notes: Option<String>,
101    #[serde(default)]
102    pub when: Option<String>,
103    #[serde(default)]
104    pub deadline: Option<String>,
105    /// `None` = leave tags unchanged. `Some(vec![])` = clear all tags.
106    #[serde(default)]
107    pub tags: Option<Vec<String>>,
108    #[serde(default)]
109    pub list_id: Option<String>,
110    #[serde(default)]
111    pub completed: Option<bool>,
112    #[serde(default)]
113    pub canceled: Option<bool>,
114}
115
116pub async fn things_update_todo(
117    state: AppState,
118    args: UpdateTodoArgs,
119) -> anyhow::Result<WriteOutcome> {
120    if args.id.trim().is_empty() {
121        return Err(crate::core::error::ThingsError::InvalidInput {
122            field: "id".into(),
123            reason: "id must be non-empty".into(),
124        }
125        .into());
126    }
127    let op = Operation::UpdateTodo(UpdateTodoSpec {
128        id: args.id.clone(),
129        title: args.title.clone(),
130        notes: args.notes.clone(),
131        when: args.when,
132        deadline: args.deadline,
133        tags: args.tags,
134        list_id: args.list_id,
135        completed: args.completed,
136        canceled: args.canceled,
137    });
138    let predicate = VerifyPredicate::UpdateById {
139        id: args.id,
140        expected_title: args.title,
141        expected_notes: args.notes,
142    };
143    let outcome = state.writer.fire(op, Some(predicate)).await?;
144    Ok(outcome)
145}
146
147#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
148pub struct StatusChangeArgs {
149    /// UUID of the to-do to mark completed or canceled.
150    pub id: String,
151}
152
153pub async fn things_complete_todo(
154    state: AppState,
155    args: StatusChangeArgs,
156) -> anyhow::Result<WriteOutcome> {
157    if args.id.trim().is_empty() {
158        return Err(crate::core::error::ThingsError::InvalidInput {
159            field: "id".into(),
160            reason: "id must be non-empty".into(),
161        }
162        .into());
163    }
164    let op = Operation::CompleteTodo { id: args.id.clone() };
165    let predicate = VerifyPredicate::StatusChange {
166        id: args.id,
167        want: crate::core::types::TaskStatus::Completed,
168    };
169    let outcome = state.writer.fire(op, Some(predicate)).await?;
170    Ok(outcome)
171}
172
173pub async fn things_cancel_todo(
174    state: AppState,
175    args: StatusChangeArgs,
176) -> anyhow::Result<WriteOutcome> {
177    if args.id.trim().is_empty() {
178        return Err(crate::core::error::ThingsError::InvalidInput {
179            field: "id".into(),
180            reason: "id must be non-empty".into(),
181        }
182        .into());
183    }
184    let op = Operation::CancelTodo { id: args.id.clone() };
185    let predicate = VerifyPredicate::StatusChange {
186        id: args.id,
187        want: crate::core::types::TaskStatus::Canceled,
188    };
189    let outcome = state.writer.fire(op, Some(predicate)).await?;
190    Ok(outcome)
191}
192
193#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
194pub struct MoveTodoArgs {
195    /// UUID of the to-do to move.
196    pub id: String,
197    /// Target project or area UUID. `None` (omitted) moves to the Inbox.
198    #[serde(default)]
199    pub list_id: Option<String>,
200}
201
202pub async fn things_move_todo(
203    state: AppState,
204    args: MoveTodoArgs,
205) -> anyhow::Result<WriteOutcome> {
206    if args.id.trim().is_empty() {
207        return Err(crate::core::error::ThingsError::InvalidInput {
208            field: "id".into(),
209            reason: "id must be non-empty".into(),
210        }
211        .into());
212    }
213    let op = Operation::MoveTodo(MoveTodoSpec {
214        id: args.id.clone(),
215        list_id: args.list_id.clone(),
216    });
217    let predicate = VerifyPredicate::MoveById {
218        id: args.id,
219        expected_list_id: args.list_id,
220    };
221    let outcome = state.writer.fire(op, Some(predicate)).await?;
222    Ok(outcome)
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
226pub struct TagAssignmentArgs {
227    /// UUID of the to-do or project to attach/remove tags on. Names are
228    /// not accepted — pass a uuid.
229    pub id: String,
230    /// Tag titles (not uuids). Non-empty.
231    pub tags: Vec<String>,
232}
233
234pub async fn things_assign_tag(
235    state: AppState,
236    args: TagAssignmentArgs,
237) -> anyhow::Result<WriteOutcome> {
238    if args.id.trim().is_empty() {
239        return Err(crate::core::error::ThingsError::InvalidInput {
240            field: "id".into(),
241            reason: "id must be non-empty".into(),
242        }
243        .into());
244    }
245    if args.tags.is_empty() {
246        return Err(crate::core::error::ThingsError::InvalidInput {
247            field: "tags".into(),
248            reason: "tags must be non-empty".into(),
249        }
250        .into());
251    }
252    if args.tags.iter().any(|t| t.trim().is_empty()) {
253        return Err(crate::core::error::ThingsError::InvalidInput {
254            field: "tags".into(),
255            reason: "tags must not contain empty or whitespace-only entries".into(),
256        }
257        .into());
258    }
259
260    // Read-modify-write: union current tags with the requested set.
261    let current = get_tags_for_task(&state.pool, args.id.clone()).await?;
262    let mut merged: Vec<String> = current.clone();
263    for t in &args.tags {
264        if !merged.iter().any(|x| x == t) {
265            merged.push(t.clone());
266        }
267    }
268
269    let op = Operation::UpdateTodo(UpdateTodoSpec {
270        id: args.id.clone(),
271        tags: Some(merged),
272        ..Default::default()
273    });
274    // Verify the first requested tag landed; if Things merges them in one
275    // write (the common case), the rest landed too.
276    let predicate = VerifyPredicate::TagOnTodoById {
277        id: args.id,
278        tag: args.tags[0].clone(),
279        present: true,
280    };
281    let outcome = state.writer.fire(op, Some(predicate)).await?;
282    Ok(outcome)
283}
284
285pub async fn things_unassign_tag(
286    state: AppState,
287    args: TagAssignmentArgs,
288) -> anyhow::Result<WriteOutcome> {
289    if args.id.trim().is_empty() {
290        return Err(crate::core::error::ThingsError::InvalidInput {
291            field: "id".into(),
292            reason: "id must be non-empty".into(),
293        }
294        .into());
295    }
296    if args.tags.is_empty() {
297        return Err(crate::core::error::ThingsError::InvalidInput {
298            field: "tags".into(),
299            reason: "tags must be non-empty".into(),
300        }
301        .into());
302    }
303    if args.tags.iter().any(|t| t.trim().is_empty()) {
304        return Err(crate::core::error::ThingsError::InvalidInput {
305            field: "tags".into(),
306            reason: "tags must not contain empty or whitespace-only entries".into(),
307        }
308        .into());
309    }
310
311    // Read-modify-write: filter out the requested tags.
312    let current = get_tags_for_task(&state.pool, args.id.clone()).await?;
313    let to_remove: std::collections::HashSet<&str> =
314        args.tags.iter().map(|s| s.as_str()).collect();
315    let new_set: Vec<String> = current
316        .into_iter()
317        .filter(|t| !to_remove.contains(t.as_str()))
318        .collect();
319
320    let op = Operation::UpdateTodo(UpdateTodoSpec {
321        id: args.id.clone(),
322        tags: Some(new_set),
323        ..Default::default()
324    });
325    let predicate = VerifyPredicate::TagOnTodoById {
326        id: args.id,
327        tag: args.tags[0].clone(),
328        present: false,
329    };
330    let outcome = state.writer.fire(op, Some(predicate)).await?;
331    Ok(outcome)
332}