Skip to main content

track/services/
todo_service.rs

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        // Use transaction to make SELECT MAX + INSERT atomic
28        self.db.with_transaction(|| {
29            let conn = self.db.get_connection();
30
31            // Get next task_index for this task
32            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    /// Updates a TODO status from a CLI/API string value.
91    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    /// Marks a TODO as done.
98    pub fn mark_done(&self, todo_id: i64) -> Result<()> {
99        self.transition_status(todo_id, TodoStatus::Done)
100    }
101
102    /// Applies a validated status transition.
103    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    /// Move a TODO to the front (make it the next todo to work on)
150    ///
151    /// This reorders the task_index so that the specified todo becomes the oldest pending todo.
152    /// Only pending todos are affected by the reordering.
153    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            // Get the todo to move
158            let todo = self.get_todo_by_index(task_id, task_index)?;
159
160            // Only allow moving pending todos
161            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            // Get all pending todos for this task, ordered by task_index
169            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            // Find the position of the todo to move
186            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 already at the front, nothing to do
193            if move_pos == 0 {
194                return Ok(());
195            }
196
197            // Reorder: move the todo to the front
198            let mut reordered = pending_todos.clone();
199            let moved_todo = reordered.remove(move_pos);
200            reordered.insert(0, moved_todo);
201
202            // Get the minimum task_index to use as starting point
203            let min_index = pending_todos[0].1;
204
205            // Update all pending todos with new task_index values
206            // Use negative temporary indices to avoid UNIQUE constraint violations
207            let temp_base = -(reordered.len() as i64) - 1;
208
209            // First, move all to temporary indices
210            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            // Then, move them to final indices
218            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    /// Copies incomplete (pending) todos from one task to another.
231    ///
232    /// Returns a mapping of old task_index to new task_index for the copied todos.
233    /// This mapping is used to copy linked scraps.
234    ///
235    /// # Arguments
236    ///
237    /// * `from_task_id` - The task ID to copy from
238    /// * `to_task_id` - The task ID to copy to
239    ///
240    /// # Returns
241    ///
242    /// A HashMap mapping old task_index values to new task_index values.
243    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    /// Copies pending todos between tasks. Caller must already hold a DB transaction.
253    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        // Each task should have independent indexing starting from 1
509        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        // Create 4 todos
567        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        // Move TODO #4 to the front
573        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        // TODO #4 should now be at index 1 (the front)
578        assert_eq!(todos[0].task_index, 1);
579        assert_eq!(todos[0].content, "TODO 4");
580        // Others should be shifted
581        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        // Try to move TODO #1 to the front (it's already there)
599        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        // Create todos and mark some as done
615        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        // Mark TODO #1 as done
621        service.update_status(todo1.id, "done").unwrap();
622
623        // Move TODO #4 to the front (should be first among pending)
624        service.move_to_next(task_id, 4).unwrap();
625
626        let todos = service.list_todos(task_id).unwrap();
627        // TODO #1 should still be at index 1 (done)
628        assert_eq!(todos[0].task_index, 1);
629        assert_eq!(todos[0].content, "TODO 1");
630        assert_eq!(todos[0].status, TodoStatus::Done);
631        // TODO #4 should be at index 2 (first pending)
632        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        // Mark TODO #1 as done
647        service.update_status(todo1.id, "done").unwrap();
648
649        // Try to move a done todo
650        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}