Enforce parent-child ordering and state constraints
Two rules prevent inconsistent parent/subtask relationships:
1. A parent task cannot be marked done while any subtask is incomplete.
2. A parent must be positioned after its children — no moving a parent
   before a subtask, or a subtask after its parent.

Assisted-by: Claude Opus 4.6 via pi
change luvtsrnmmsxwmlwmokwtyuqlkkvwoyps
commit 7bde0dd6bb808fe89de4512d9de110a3fe57e39f
author Alpha Chen <alpha@kejadlen.dev>
date
parent tzmrxuxp
diff --git a/src/error.rs b/src/error.rs
index 17ad532..342d88c 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -9,6 +9,12 @@ pub enum RangerError {
         task_state: String,
         anchor_state: String,
     },
+    #[error("can't mark parent task done: {count} subtask(s) still incomplete")]
+    IncompleteSubtasks { count: usize },
+    #[error("can't move parent task before its subtask")]
+    ParentBeforeChild,
+    #[error("can't move subtask after its parent")]
+    ChildAfterParent,
     #[error("database error: {0}")]
     Db(#[from] sqlx::Error),
     #[error("migration error: {0}")]
diff --git a/src/ops/task.rs b/src/ops/task.rs
index ffa9e6b..e0d0f04 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -171,6 +171,22 @@ pub async fn edit(
             .await?;
     }
     if let Some(new_state) = &state {
+        // Prevent marking a parent done while subtasks are incomplete
+        if *new_state == State::Done {
+            let incomplete: i64 = sqlx::query_scalar(
+                "SELECT COUNT(*) FROM tasks WHERE parent_id = ? AND state != 'done'",
+            )
+            .bind(task_id)
+            .fetch_one(&mut *conn)
+            .await?;
+
+            if incomplete > 0 {
+                return Err(RangerError::IncompleteSubtasks {
+                    count: incomplete as usize,
+                });
+            }
+        }
+
         // Fetch the current state to determine direction
         let old_state: State = sqlx::query_scalar("SELECT state FROM tasks WHERE id = ?")
             .bind(task_id)
@@ -243,6 +259,34 @@ pub async fn move_task(
         }
     }
 
+    // Parent-child ordering: parent must come after children.
+    // Check if task is being moved before one of its own children,
+    // or if a child is being moved after its parent.
+    for anchor in placement.anchors() {
+        // Is anchor a child of task? (task is the parent being moved)
+        if anchor.parent_id == Some(task.id) {
+            // task is parent, anchor is child — parent can't go before child
+            match &placement {
+                Placement::Before(_) => return Err(RangerError::ParentBeforeChild),
+                Placement::Between { before, .. } if before.id == anchor.id => {
+                    return Err(RangerError::ParentBeforeChild);
+                }
+                _ => {}
+            }
+        }
+        // Is task a child of anchor? (anchor is the parent)
+        if task.parent_id == Some(anchor.id) {
+            // task is child, anchor is parent — child can't go after parent
+            match &placement {
+                Placement::After(_) => return Err(RangerError::ChildAfterParent),
+                Placement::Between { after, .. } if after.id == anchor.id => {
+                    return Err(RangerError::ChildAfterParent);
+                }
+                _ => {}
+            }
+        }
+    }
+
     let new_pos = match placement {
         Placement::After(anchor) => {
             let next =
@@ -1480,4 +1524,469 @@ mod tests {
             .unwrap();
         assert_eq!(visible.len(), 2);
     }
+
+    // -- Parent-child constraint tests --
+
+    #[tokio::test]
+    async fn parent_cannot_be_marked_done_with_incomplete_subtasks() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: Some(parent.id),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let err = edit(&mut conn, parent.id, None, None, Some(State::Done))
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("incomplete"),
+            "expected incomplete subtasks error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn parent_can_be_marked_done_when_all_subtasks_done() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: Some(State::Done),
+                parent_id: Some(parent.id),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(&mut conn, parent.id, None, None, Some(State::Done))
+            .await
+            .unwrap();
+        assert_eq!(updated.state, State::Done);
+    }
+
+    #[tokio::test]
+    async fn parent_can_change_to_non_done_state_with_incomplete_subtasks() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: Some(parent.id),
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        // Moving to in_progress is fine even with incomplete subtasks
+        let updated = edit(&mut conn, parent.id, None, None, Some(State::InProgress))
+            .await
+            .unwrap();
+        assert_eq!(updated.state, State::InProgress);
+    }
+
+    #[tokio::test]
+    async fn task_without_subtasks_can_be_marked_done_freely() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let task = create(
+            &mut conn,
+            CreateTask {
+                title: "Leaf task",
+                backlog_id: bl.id,
+                state: Some(State::Queued),
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+
+        let updated = edit(&mut conn, task.id, None, None, Some(State::Done))
+            .await
+            .unwrap();
+        assert_eq!(updated.state, State::Done);
+    }
+
+    #[tokio::test]
+    async fn move_parent_before_subtask_rejected() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // child first, parent second (correct ordering)
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        // Set parent_id after creation to avoid ordering chicken-and-egg
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Try to move parent before its child — should fail
+        let err = move_task(&mut conn, &parent, Placement::Before(&child))
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("parent"),
+            "expected parent-before-child error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn move_subtask_after_parent_rejected() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // child first, parent second (correct ordering)
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Try to move child after its parent — should fail
+        let err = move_task(&mut conn, &child, Placement::After(&parent))
+            .await
+            .unwrap_err();
+        assert!(
+            err.to_string().contains("subtask"),
+            "expected child-after-parent error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn move_parent_between_with_child_as_before_rejected() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // order: other, child, parent
+        let other = create(
+            &mut conn,
+            CreateTask {
+                title: "Other",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Try to place parent between other and child (before=child) — should fail
+        let err = move_task(
+            &mut conn,
+            &parent,
+            Placement::Between {
+                after: &other,
+                before: &child,
+            },
+        )
+        .await
+        .unwrap_err();
+        assert!(
+            err.to_string().contains("parent"),
+            "expected parent-before-child error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn move_subtask_between_with_parent_as_after_rejected() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        // order: child, parent, other
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let other = create(
+            &mut conn,
+            CreateTask {
+                title: "Other",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Try to place child between parent and other (after=parent) — should fail
+        let err = move_task(
+            &mut conn,
+            &child,
+            Placement::Between {
+                after: &parent,
+                before: &other,
+            },
+        )
+        .await
+        .unwrap_err();
+        assert!(
+            err.to_string().contains("subtask"),
+            "expected child-after-parent error, got: {err}"
+        );
+    }
+
+    #[tokio::test]
+    async fn move_parent_after_child_is_fine() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Moving parent after its child is fine (preserves child-before-parent order)
+        move_task(&mut conn, &parent, Placement::After(&child))
+            .await
+            .unwrap();
+    }
+
+    #[tokio::test]
+    async fn move_subtask_before_parent_is_fine() {
+        let pool = test_pool().await;
+        let mut conn = pool.acquire().await.unwrap();
+        let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+        let child = create(
+            &mut conn,
+            CreateTask {
+                title: "Child",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        let parent = create(
+            &mut conn,
+            CreateTask {
+                title: "Parent",
+                backlog_id: bl.id,
+                state: None,
+                parent_id: None,
+                description: None,
+            },
+        )
+        .await
+        .unwrap();
+        sqlx::query("UPDATE tasks SET parent_id = ? WHERE id = ?")
+            .bind(parent.id)
+            .bind(child.id)
+            .execute(&mut *conn)
+            .await
+            .unwrap();
+        let child = get_by_id(&mut conn, child.id).await.unwrap();
+
+        // Moving child before its parent is fine (preserves child-before-parent order)
+        move_task(&mut conn, &child, Placement::Before(&parent))
+            .await
+            .unwrap();
+    }
 }