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