1use crate::db::row_mapping::row_to_todo;
2use crate::db::Database;
3use crate::models::{Todo, TodoStatus};
4use crate::utils::{Result, TrackError};
5use chrono::Utc;
6use rusqlite::params;
7use std::str::FromStr;
8
9pub struct TodoService<'a> {
10 db: &'a Database,
11}
12
13impl<'a> TodoService<'a> {
14 pub fn new(db: &'a Database) -> Self {
15 Self { db }
16 }
17
18 pub fn add_todo(
19 &self,
20 task_id: i64,
21 content: &str,
22 options: impl Into<crate::models::TodoAddOptions>,
23 ) -> Result<Todo> {
24 let options = options.into();
25 if content.trim().is_empty() {
26 return Err(TrackError::EmptyTodoContent);
27 }
28
29 let now = Utc::now().to_rfc3339();
30 let content = content.to_string();
31 let status = TodoStatus::Pending.as_str().to_string();
32
33 self.db.with_transaction(|| {
35 let conn = self.db.get_connection();
36
37 let next_index: i64 = conn.query_row(
39 "SELECT COALESCE(MAX(task_index), 0) + 1 FROM todos WHERE task_id = ?1",
40 params![task_id],
41 |row| row.get(0),
42 )?;
43
44 conn.execute(
45 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, requires_workspace, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
46 params![
47 task_id,
48 next_index,
49 content,
50 status,
51 options.worktree_requested,
52 options.requires_workspace,
53 now
54 ],
55 )?;
56
57 let todo_id = conn.last_insert_rowid();
58 self.db.increment_rev("todos")?;
59 self.get_todo(todo_id)
60 })
61 }
62
63 pub fn get_todo(&self, todo_id: i64) -> Result<Todo> {
64 let conn = self.db.get_connection();
65 let mut stmt = conn.prepare(
66 "SELECT id, task_id, task_index, content, status, worktree_requested, requires_workspace, created_at, completed_at FROM todos WHERE id = ?1"
67 )?;
68
69 let todo = stmt
70 .query_row(params![todo_id], row_to_todo)
71 .map_err(|_| TrackError::TodoNotFound(todo_id))?;
72
73 Ok(todo)
74 }
75
76 pub fn list_todos(&self, task_id: i64) -> Result<Vec<Todo>> {
77 let conn = self.db.get_connection();
78 let mut stmt = conn.prepare(
79 "SELECT id, task_id, task_index, content, status, worktree_requested, requires_workspace, created_at, completed_at FROM todos WHERE task_id = ?1 ORDER BY task_index ASC"
80 )?;
81
82 let todos = stmt
83 .query_map(params![task_id], row_to_todo)?
84 .collect::<std::result::Result<Vec<_>, _>>()?;
85
86 Ok(todos)
87 }
88
89 pub fn get_todo_by_index(&self, task_id: i64, task_index: i64) -> Result<Todo> {
90 let conn = self.db.get_connection();
91 let mut stmt = conn.prepare(
92 "SELECT id, task_id, task_index, content, status, worktree_requested, requires_workspace, created_at, completed_at FROM todos WHERE task_id = ?1 AND task_index = ?2"
93 )?;
94
95 let todo = stmt
96 .query_row(params![task_id, task_index], row_to_todo)
97 .map_err(|_| TrackError::TodoIndexNotFound(task_index))?;
98
99 Ok(todo)
100 }
101
102 pub fn update_status(&self, todo_id: i64, status: &str) -> Result<()> {
104 let new_status = TodoStatus::from_str(status)
105 .map_err(|_| TrackError::InvalidStatus(status.to_string()))?;
106 self.transition_status(todo_id, new_status)
107 }
108
109 pub fn mark_done(&self, todo_id: i64) -> Result<()> {
111 self.transition_status(todo_id, TodoStatus::Done)
112 }
113
114 pub fn transition_status(&self, todo_id: i64, new_status: TodoStatus) -> Result<()> {
116 let todo = self.get_todo(todo_id)?;
117 if TodoStatus::is_reopen_attempt(todo.status, new_status) {
118 return Err(TrackError::TodoReopenForbidden {
119 from: todo.status.as_str().to_string(),
120 });
121 }
122 if !todo.status.can_transition_to(new_status) {
123 return Err(TrackError::InvalidStatusTransition {
124 from: todo.status.as_str().to_string(),
125 to: new_status.as_str().to_string(),
126 });
127 }
128
129 let completed_at = if new_status == TodoStatus::Done {
130 Some(Utc::now().to_rfc3339())
131 } else {
132 None
133 };
134
135 let conn = self.db.get_connection();
136 let affected = conn.execute(
137 "UPDATE todos SET status = ?1, completed_at = ?2 WHERE id = ?3",
138 params![new_status.as_str(), completed_at, todo_id],
139 )?;
140
141 if affected == 0 {
142 return Err(TrackError::TodoNotFound(todo_id));
143 }
144
145 self.db.increment_rev("todos")?;
146 Ok(())
147 }
148
149 pub fn clear_legacy_worktree_flags(&self, task_id: Option<i64>) -> Result<usize> {
151 let conn = self.db.get_connection();
152 let affected = if let Some(task_id) = task_id {
153 conn.execute(
154 "UPDATE todos SET worktree_requested = 0 WHERE task_id = ?1 AND worktree_requested = 1",
155 params![task_id],
156 )?
157 } else {
158 conn.execute(
159 "UPDATE todos SET worktree_requested = 0 WHERE worktree_requested = 1",
160 [],
161 )?
162 };
163
164 if affected > 0 {
165 self.db.increment_rev("todos")?;
166 }
167
168 Ok(affected)
169 }
170
171 pub fn delete_todo(&self, todo_id: i64) -> Result<()> {
172 let conn = self.db.get_connection();
173 let affected = conn.execute("DELETE FROM todos WHERE id = ?1", params![todo_id])?;
174
175 if affected == 0 {
176 return Err(TrackError::TodoNotFound(todo_id));
177 }
178
179 self.db.increment_rev("todos")?;
180 Ok(())
181 }
182
183 pub fn move_to_next(&self, task_id: i64, task_index: i64) -> Result<()> {
188 self.db.with_transaction(|| {
189 let conn = self.db.get_connection();
190
191 let todo = self.get_todo_by_index(task_id, task_index)?;
193
194 if todo.status != TodoStatus::Pending {
196 return Err(TrackError::InvalidStatusTransition {
197 from: todo.status.as_str().to_string(),
198 to: TodoStatus::Pending.as_str().to_string(),
199 });
200 }
201
202 let pending_query = format!(
204 "SELECT id, task_index FROM todos WHERE task_id = ?1 AND status = '{}' ORDER BY task_index ASC",
205 TodoStatus::PENDING
206 );
207 let mut stmt = conn.prepare(&pending_query)?;
208
209 let pending_todos: Vec<(i64, i64)> = stmt
210 .query_map(params![task_id], |row| {
211 Ok((row.get(0)?, row.get(1)?))
212 })?
213 .collect::<std::result::Result<Vec<_>, _>>()?;
214
215 if pending_todos.is_empty() {
216 return Err(TrackError::NoPendingTodos);
217 }
218
219 let move_pos = pending_todos.iter().position(|(_, idx)| *idx == task_index);
221 if move_pos.is_none() {
222 return Err(TrackError::TodoNotPending(task_index));
223 }
224 let move_pos = move_pos.unwrap();
225
226 if move_pos == 0 {
228 return Ok(());
229 }
230
231 let mut reordered = pending_todos.clone();
233 let moved_todo = reordered.remove(move_pos);
234 reordered.insert(0, moved_todo);
235
236 let min_index = pending_todos[0].1;
238
239 let temp_base = -(reordered.len() as i64) - 1;
242
243 for (i, (id, _)) in reordered.iter().enumerate() {
245 conn.execute(
246 "UPDATE todos SET task_index = ?1 WHERE id = ?2",
247 params![temp_base - i as i64, id],
248 )?;
249 }
250
251 for (i, (id, _)) in reordered.iter().enumerate() {
253 conn.execute(
254 "UPDATE todos SET task_index = ?1 WHERE id = ?2",
255 params![min_index + i as i64, id],
256 )?;
257 }
258
259 self.db.increment_rev("todos")?;
260 Ok(())
261 })
262 }
263
264 pub fn copy_incomplete_todos(
278 &self,
279 from_task_id: i64,
280 to_task_id: i64,
281 ) -> Result<std::collections::HashMap<i64, i64>> {
282 self.db
283 .with_transaction(|| self.copy_incomplete_todos_in_tx(from_task_id, to_task_id))
284 }
285
286 pub(crate) fn copy_incomplete_todos_in_tx(
288 &self,
289 from_task_id: i64,
290 to_task_id: i64,
291 ) -> Result<std::collections::HashMap<i64, i64>> {
292 use std::collections::HashMap;
293
294 let mut mapping = HashMap::new();
295 let conn = self.db.get_connection();
296
297 let pending_query = format!(
298 "SELECT task_index, content FROM todos WHERE task_id = ?1 AND status = '{}' ORDER BY task_index ASC",
299 TodoStatus::PENDING
300 );
301 let mut stmt = conn.prepare(&pending_query)?;
302
303 let pending_todos: Vec<(i64, String)> = stmt
304 .query_map(params![from_task_id], |row| Ok((row.get(0)?, row.get(1)?)))?
305 .collect::<std::result::Result<Vec<_>, _>>()?;
306
307 for (old_index, content) in pending_todos {
308 let next_index: i64 = conn.query_row(
309 "SELECT COALESCE(MAX(task_index), 0) + 1 FROM todos WHERE task_id = ?1",
310 params![to_task_id],
311 |row| row.get(0),
312 )?;
313
314 let now = Utc::now().to_rfc3339();
315
316 conn.execute(
317 "INSERT INTO todos (task_id, task_index, content, status, worktree_requested, requires_workspace, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
318 params![
319 to_task_id,
320 next_index,
321 content,
322 TodoStatus::Pending.as_str(),
323 0,
324 1,
325 now
326 ],
327 )?;
328
329 mapping.insert(old_index, next_index);
330 }
331
332 if !mapping.is_empty() {
333 self.db.increment_rev("todos")?;
334 }
335
336 Ok(mapping)
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::db::Database;
344 use crate::services::TaskService;
345
346 fn setup_db() -> Database {
347 Database::new_in_memory().unwrap()
348 }
349
350 fn create_test_task(db: &Database) -> i64 {
351 let task_service = TaskService::new(db);
352 task_service
353 .create_task("Test Task", None, None, None)
354 .unwrap()
355 .id
356 }
357
358 #[test]
359 fn test_add_todo_empty_content() {
360 let db = setup_db();
361 let task_id = create_test_task(&db);
362 let service = TodoService::new(&db);
363
364 let result = service.add_todo(task_id, " ", false);
365 assert!(matches!(result, Err(TrackError::EmptyTodoContent)));
366 }
367
368 #[test]
369 fn test_add_todo_success() {
370 let db = setup_db();
371 let task_id = create_test_task(&db);
372 let service = TodoService::new(&db);
373
374 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
375 assert_eq!(todo.content, "Test TODO");
376 assert_eq!(todo.status, TodoStatus::Pending);
377 assert!(!todo.worktree_requested);
378 }
379
380 #[test]
381 fn test_add_todo_no_workspace() {
382 let db = setup_db();
383 let task_id = create_test_task(&db);
384 let service = TodoService::new(&db);
385
386 let todo = service
387 .add_todo(
388 task_id,
389 "Research",
390 crate::models::TodoAddOptions::from_flags(false, true),
391 )
392 .unwrap();
393 assert!(!todo.requires_workspace);
394 }
395
396 #[test]
397 fn test_add_todo_with_worktree_success() {
398 let db = setup_db();
399 let task_id = create_test_task(&db);
400 let service = TodoService::new(&db);
401
402 let todo = service.add_todo(task_id, "Test TODO", true).unwrap();
403 assert_eq!(todo.content, "Test TODO");
404 assert_eq!(todo.status, TodoStatus::Pending);
405 assert!(todo.worktree_requested);
406 }
407
408 #[test]
409 fn test_get_todo_success() {
410 let db = setup_db();
411 let task_id = create_test_task(&db);
412 let service = TodoService::new(&db);
413
414 let created = service.add_todo(task_id, "Test TODO", false).unwrap();
415 let retrieved = service.get_todo(created.id).unwrap();
416 assert_eq!(retrieved.id, created.id);
417 assert_eq!(retrieved.content, "Test TODO");
418 }
419
420 #[test]
421 fn test_get_todo_not_found() {
422 let db = setup_db();
423 let service = TodoService::new(&db);
424
425 let result = service.get_todo(999);
426 assert!(matches!(result, Err(TrackError::TodoNotFound(999))));
427 }
428
429 #[test]
430 fn test_list_todos() {
431 let db = setup_db();
432 let task_id = create_test_task(&db);
433 let service = TodoService::new(&db);
434
435 service.add_todo(task_id, "TODO 1", false).unwrap();
436 service.add_todo(task_id, "TODO 2", false).unwrap();
437
438 let todos = service.list_todos(task_id).unwrap();
439 assert_eq!(todos.len(), 2);
440 assert_eq!(todos[0].content, "TODO 1");
441 assert_eq!(todos[1].content, "TODO 2");
442 }
443
444 #[test]
445 fn test_update_status_success() {
446 let db = setup_db();
447 let task_id = create_test_task(&db);
448 let service = TodoService::new(&db);
449
450 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
451 service.update_status(todo.id, "done").unwrap();
452
453 let updated = service.get_todo(todo.id).unwrap();
454 assert_eq!(updated.status, TodoStatus::Done);
455 assert!(updated.completed_at.is_some());
456 }
457
458 #[test]
459 fn test_rejects_reopen_from_done() {
460 let db = setup_db();
461 let task_id = create_test_task(&db);
462 let service = TodoService::new(&db);
463
464 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
465 service.update_status(todo.id, "done").unwrap();
466
467 let result = service.update_status(todo.id, "pending");
468 assert!(matches!(
469 result,
470 Err(TrackError::TodoReopenForbidden { .. })
471 ));
472 }
473
474 #[test]
475 fn test_rejects_reopen_from_cancelled() {
476 let db = setup_db();
477 let task_id = create_test_task(&db);
478 let service = TodoService::new(&db);
479
480 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
481 service.update_status(todo.id, "cancelled").unwrap();
482
483 let result = service.update_status(todo.id, "pending");
484 assert!(matches!(
485 result,
486 Err(TrackError::TodoReopenForbidden { .. })
487 ));
488 }
489
490 #[test]
491 fn test_update_status_invalid() {
492 let db = setup_db();
493 let task_id = create_test_task(&db);
494 let service = TodoService::new(&db);
495
496 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
497 let result = service.update_status(todo.id, "invalid_status");
498 assert!(matches!(result, Err(TrackError::InvalidStatus(_))));
499 }
500
501 #[test]
502 fn test_update_status_not_found() {
503 let db = setup_db();
504 let service = TodoService::new(&db);
505
506 let result = service.update_status(999, "done");
507 assert!(matches!(result, Err(TrackError::TodoNotFound(999))));
508 }
509
510 #[test]
511 fn test_delete_todo_success() {
512 let db = setup_db();
513 let task_id = create_test_task(&db);
514 let service = TodoService::new(&db);
515
516 let todo = service.add_todo(task_id, "Test TODO", false).unwrap();
517 service.delete_todo(todo.id).unwrap();
518
519 let result = service.get_todo(todo.id);
520 assert!(matches!(result, Err(TrackError::TodoNotFound(_))));
521 }
522
523 #[test]
524 fn test_delete_todo_not_found() {
525 let db = setup_db();
526 let service = TodoService::new(&db);
527
528 let result = service.delete_todo(999);
529 assert!(matches!(result, Err(TrackError::TodoNotFound(999))));
530 }
531
532 #[test]
533 fn test_task_index_sequential() {
534 let db = setup_db();
535 let task_id = create_test_task(&db);
536 let service = TodoService::new(&db);
537
538 let todo1 = service.add_todo(task_id, "TODO 1", false).unwrap();
539 let todo2 = service.add_todo(task_id, "TODO 2", false).unwrap();
540 let todo3 = service.add_todo(task_id, "TODO 3", false).unwrap();
541
542 assert_eq!(todo1.task_index, 1);
543 assert_eq!(todo2.task_index, 2);
544 assert_eq!(todo3.task_index, 3);
545 }
546
547 #[test]
548 fn test_task_index_independence() {
549 let db = setup_db();
550 let task_service = TaskService::new(&db);
551 let task1_id = task_service
552 .create_task("Task 1", None, None, None)
553 .unwrap()
554 .id;
555 let task2_id = task_service
556 .create_task("Task 2", None, None, None)
557 .unwrap()
558 .id;
559 let service = TodoService::new(&db);
560
561 let task1_todo1 = service.add_todo(task1_id, "Task 1 TODO 1", false).unwrap();
562 let task2_todo1 = service.add_todo(task2_id, "Task 2 TODO 1", false).unwrap();
563 let task1_todo2 = service.add_todo(task1_id, "Task 1 TODO 2", false).unwrap();
564 let task2_todo2 = service.add_todo(task2_id, "Task 2 TODO 2", false).unwrap();
565
566 assert_eq!(task1_todo1.task_index, 1);
568 assert_eq!(task1_todo2.task_index, 2);
569 assert_eq!(task2_todo1.task_index, 1);
570 assert_eq!(task2_todo2.task_index, 2);
571 }
572
573 #[test]
574 fn test_get_todo_by_index_success() {
575 let db = setup_db();
576 let task_id = create_test_task(&db);
577 let service = TodoService::new(&db);
578
579 service.add_todo(task_id, "TODO 1", false).unwrap();
580 let created = service.add_todo(task_id, "TODO 2", false).unwrap();
581 service.add_todo(task_id, "TODO 3", false).unwrap();
582
583 let retrieved = service.get_todo_by_index(task_id, 2).unwrap();
584 assert_eq!(retrieved.id, created.id);
585 assert_eq!(retrieved.task_index, 2);
586 assert_eq!(retrieved.content, "TODO 2");
587 }
588
589 #[test]
590 fn test_get_todo_by_index_not_found() {
591 let db = setup_db();
592 let task_id = create_test_task(&db);
593 let service = TodoService::new(&db);
594
595 service.add_todo(task_id, "TODO 1", false).unwrap();
596
597 let result = service.get_todo_by_index(task_id, 5);
598 assert!(result.is_err());
599 }
600
601 #[test]
602 fn test_list_todos_ordered_by_index() {
603 let db = setup_db();
604 let task_id = create_test_task(&db);
605 let service = TodoService::new(&db);
606
607 service.add_todo(task_id, "TODO 1", false).unwrap();
608 service.add_todo(task_id, "TODO 2", false).unwrap();
609 service.add_todo(task_id, "TODO 3", false).unwrap();
610
611 let todos = service.list_todos(task_id).unwrap();
612 assert_eq!(todos.len(), 3);
613 assert_eq!(todos[0].task_index, 1);
614 assert_eq!(todos[1].task_index, 2);
615 assert_eq!(todos[2].task_index, 3);
616 }
617
618 #[test]
619 fn test_move_to_next_success() {
620 let db = setup_db();
621 let task_id = create_test_task(&db);
622 let service = TodoService::new(&db);
623
624 service.add_todo(task_id, "TODO 1", false).unwrap();
626 service.add_todo(task_id, "TODO 2", false).unwrap();
627 service.add_todo(task_id, "TODO 3", false).unwrap();
628 service.add_todo(task_id, "TODO 4", false).unwrap();
629
630 service.move_to_next(task_id, 4).unwrap();
632
633 let todos = service.list_todos(task_id).unwrap();
634 assert_eq!(todos.len(), 4);
635 assert_eq!(todos[0].task_index, 1);
637 assert_eq!(todos[0].content, "TODO 4");
638 assert_eq!(todos[1].task_index, 2);
640 assert_eq!(todos[1].content, "TODO 1");
641 assert_eq!(todos[2].task_index, 3);
642 assert_eq!(todos[2].content, "TODO 2");
643 assert_eq!(todos[3].task_index, 4);
644 assert_eq!(todos[3].content, "TODO 3");
645 }
646
647 #[test]
648 fn test_move_to_next_already_at_front() {
649 let db = setup_db();
650 let task_id = create_test_task(&db);
651 let service = TodoService::new(&db);
652
653 service.add_todo(task_id, "TODO 1", false).unwrap();
654 service.add_todo(task_id, "TODO 2", false).unwrap();
655
656 service.move_to_next(task_id, 1).unwrap();
658
659 let todos = service.list_todos(task_id).unwrap();
660 assert_eq!(todos[0].task_index, 1);
661 assert_eq!(todos[0].content, "TODO 1");
662 assert_eq!(todos[1].task_index, 2);
663 assert_eq!(todos[1].content, "TODO 2");
664 }
665
666 #[test]
667 fn test_move_to_next_with_done_todos() {
668 let db = setup_db();
669 let task_id = create_test_task(&db);
670 let service = TodoService::new(&db);
671
672 let todo1 = service.add_todo(task_id, "TODO 1", false).unwrap();
674 service.add_todo(task_id, "TODO 2", false).unwrap();
675 service.add_todo(task_id, "TODO 3", false).unwrap();
676 service.add_todo(task_id, "TODO 4", false).unwrap();
677
678 service.update_status(todo1.id, "done").unwrap();
680
681 service.move_to_next(task_id, 4).unwrap();
683
684 let todos = service.list_todos(task_id).unwrap();
685 assert_eq!(todos[0].task_index, 1);
687 assert_eq!(todos[0].content, "TODO 1");
688 assert_eq!(todos[0].status, TodoStatus::Done);
689 assert_eq!(todos[1].task_index, 2);
691 assert_eq!(todos[1].content, "TODO 4");
692 assert_eq!(todos[1].status, TodoStatus::Pending);
693 }
694
695 #[test]
696 fn test_move_to_next_done_todo_fails() {
697 let db = setup_db();
698 let task_id = create_test_task(&db);
699 let service = TodoService::new(&db);
700
701 let todo1 = service.add_todo(task_id, "TODO 1", false).unwrap();
702 service.add_todo(task_id, "TODO 2", false).unwrap();
703
704 service.update_status(todo1.id, "done").unwrap();
706
707 let result = service.move_to_next(task_id, 1);
709 assert!(result.is_err());
710 }
711
712 #[test]
713 fn test_copy_incomplete_todos_public_api() {
714 let db = setup_db();
715 let from_task = create_test_task(&db);
716 let to_task = create_test_task(&db);
717 let service = TodoService::new(&db);
718
719 service.add_todo(from_task, "Keep", false).unwrap();
720 let done_todo = service.add_todo(from_task, "Skip", false).unwrap();
721 service.update_status(done_todo.id, "done").unwrap();
722
723 let mapping = service.copy_incomplete_todos(from_task, to_task).unwrap();
724 assert_eq!(mapping.len(), 1);
725
726 let copied = service.list_todos(to_task).unwrap();
727 assert_eq!(copied.len(), 1);
728 assert_eq!(copied[0].content, "Keep");
729 }
730}