1use 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 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 pub title: String,
34 #[serde(default)]
36 pub notes: Option<String>,
37 #[serde(default)]
40 pub when: Option<String>,
41 #[serde(default)]
43 pub deadline: Option<String>,
44 #[serde(default)]
46 pub tags: Vec<String>,
47 #[serde(default)]
49 pub checklist_items: Vec<String>,
50 #[serde(default)]
52 pub list_id: Option<String>,
53 #[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 #[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 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 pub id: String,
197 #[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 pub id: String,
230 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 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 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 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}