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(
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        // Use transaction to make SELECT MAX + INSERT atomic
34        self.db.with_transaction(|| {
35            let conn = self.db.get_connection();
36
37            // Get next task_index for this task
38            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    /// Updates a TODO status from a CLI/API string value.
103    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    /// Marks a TODO as done.
110    pub fn mark_done(&self, todo_id: i64) -> Result<()> {
111        self.transition_status(todo_id, TodoStatus::Done)
112    }
113
114    /// Applies a validated status transition.
115    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    /// Clears legacy per-TODO `worktree_requested` flags (jj-task migration).
150    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    /// Move a TODO to the front (make it the next todo to work on)
184    ///
185    /// This reorders the task_index so that the specified todo becomes the oldest pending todo.
186    /// Only pending todos are affected by the reordering.
187    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            // Get the todo to move
192            let todo = self.get_todo_by_index(task_id, task_index)?;
193
194            // Only allow moving pending todos
195            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            // Get all pending todos for this task, ordered by task_index
203            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            // Find the position of the todo to move
220            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 already at the front, nothing to do
227            if move_pos == 0 {
228                return Ok(());
229            }
230
231            // Reorder: move the todo to the front
232            let mut reordered = pending_todos.clone();
233            let moved_todo = reordered.remove(move_pos);
234            reordered.insert(0, moved_todo);
235
236            // Get the minimum task_index to use as starting point
237            let min_index = pending_todos[0].1;
238
239            // Update all pending todos with new task_index values
240            // Use negative temporary indices to avoid UNIQUE constraint violations
241            let temp_base = -(reordered.len() as i64) - 1;
242
243            // First, move all to temporary indices
244            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            // Then, move them to final indices
252            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    /// Copies incomplete (pending) todos from one task to another.
265    ///
266    /// Returns a mapping of old task_index to new task_index for the copied todos.
267    /// This mapping is used to copy linked scraps.
268    ///
269    /// # Arguments
270    ///
271    /// * `from_task_id` - The task ID to copy from
272    /// * `to_task_id` - The task ID to copy to
273    ///
274    /// # Returns
275    ///
276    /// A HashMap mapping old task_index values to new task_index values.
277    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    /// Copies pending todos between tasks. Caller must already hold a DB transaction.
287    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        // Each task should have independent indexing starting from 1
567        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        // Create 4 todos
625        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        // Move TODO #4 to the front
631        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        // TODO #4 should now be at index 1 (the front)
636        assert_eq!(todos[0].task_index, 1);
637        assert_eq!(todos[0].content, "TODO 4");
638        // Others should be shifted
639        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        // Try to move TODO #1 to the front (it's already there)
657        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        // Create todos and mark some as done
673        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        // Mark TODO #1 as done
679        service.update_status(todo1.id, "done").unwrap();
680
681        // Move TODO #4 to the front (should be first among pending)
682        service.move_to_next(task_id, 4).unwrap();
683
684        let todos = service.list_todos(task_id).unwrap();
685        // TODO #1 should still be at index 1 (done)
686        assert_eq!(todos[0].task_index, 1);
687        assert_eq!(todos[0].content, "TODO 1");
688        assert_eq!(todos[0].status, TodoStatus::Done);
689        // TODO #4 should be at index 2 (first pending)
690        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        // Mark TODO #1 as done
705        service.update_status(todo1.id, "done").unwrap();
706
707        // Try to move a done todo
708        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}