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
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();
+ }
}