add DAG topological sort as alternative ordering strategy
Introduce Ordering::Dag alongside the existing Ordering::Position.
Set RANGER_DAG_ORDER=1 to switch at runtime.
DAG ordering uses Kahn's algorithm on 'before' edges with min-ID
tiebreaker. Position-based lex ordering remains the default and is
unchanged.
New library code:
- edge::ordered_ids — deterministic topo sort for a set of task IDs
- edge::splice_out_before — remove a task from before-edge chains
- edge::insert_before_task / insert_after_task — chain insertion
- edge::list_for_task_ids — batch edge fetch
- task::move_task_dag — move via edge manipulation
- task::reorder_dag — state-change placement via DAG
Removed the one-outgoing-before constraint on edges (was linked-list
model; DAG ordering needs fan-out for proper root/leaf placement).
All functions accept an Ordering parameter; CLI resolves it from
RANGER_DAG_ORDER env var.
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index a611656..31c279f 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -7,6 +7,7 @@ use ranger::models::{Backlog, State};
use ranger::ops;
use ranger::ops::task::ListFilter;
+use super::task::resolve_ordering;
use crate::completions;
use crate::output;
@@ -49,6 +50,7 @@ pub enum BacklogCommands {
pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Result<()> {
let mut conn = pool.acquire().await?;
+ let ordering = resolve_ordering();
match command {
BacklogCommands::Create { name } => {
@@ -84,7 +86,7 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
state: Some(state.clone()),
..Default::default()
};
- let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter, ordering).await?;
if !tasks.is_empty() {
state_groups
.insert(state.to_string(), serde_json::to_value(&tasks).unwrap());
@@ -106,7 +108,7 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
state: Some(state.clone()),
..Default::default()
};
- let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter, ordering).await?;
if !tasks.is_empty() {
println!("\n[{}]", state);
for t in &tasks {
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 703a481..2a4cbfa 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -4,13 +4,15 @@ use axum::response::{IntoResponse, Redirect};
use axum::{Router, routing::get};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use ranger::key;
-use ranger::models::Task;
+use ranger::models::{Ordering, Task};
use ranger::ops;
use ranger::ops::task::ListFilter;
use sqlx::SqlitePool;
use std::net::SocketAddr;
use tokio::net::TcpListener;
+use super::task::resolve_ordering;
+
/// Static CSS embedded at compile time from `static/style.css`.
const STYLE_CSS: &str = include_str!("../../../../static/style.css");
@@ -18,6 +20,7 @@ const STYLE_CSS: &str = include_str!("../../../../static/style.css");
struct AppState {
pool: SqlitePool,
default_backlog: Option<String>,
+ ordering: Ordering,
}
pub async fn run(
@@ -28,6 +31,7 @@ pub async fn run(
let state = AppState {
pool: pool.clone(),
default_backlog,
+ ordering: resolve_ordering(),
};
let app = Router::new()
@@ -118,7 +122,7 @@ async fn render_board(state: &AppState, backlog_name: &str) -> color_eyre::Resul
state: Some(s.clone()),
..Default::default()
};
- let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter, state.ordering).await?;
let views = to_task_views(&tasks, &prefixes, &mut conn).await?;
match s {
ranger::models::State::InProgress => in_progress = views,
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 1e4e6f9..69080bc 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -5,13 +5,22 @@ use clap_complete::engine::ArgValueCompleter;
use color_eyre::eyre::{Result, bail};
use ranger::db::{SqliteConnection, SqlitePool};
use ranger::key;
-use ranger::models::{State, Task};
+use ranger::models::{Ordering, State, Task};
use ranger::ops;
use ranger::ops::task::{ListFilter, Placement};
use crate::completions;
use crate::output;
+/// Read the ordering strategy from the `RANGER_DAG_ORDER` env var.
+/// Set `RANGER_DAG_ORDER=1` to use DAG topological ordering.
+pub fn resolve_ordering() -> Ordering {
+ match std::env::var("RANGER_DAG_ORDER").as_deref() {
+ Ok("1") | Ok("true") => Ordering::Dag,
+ _ => Ordering::Position,
+ }
+}
+
/// Positioning flags shared by create, edit, and move.
#[derive(Args)]
pub struct PositionArgs {
@@ -170,6 +179,7 @@ pub async fn default_backlog_id(pool: &SqlitePool) -> Option<i64> {
pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result<()> {
let backlog_scope = default_backlog_id(pool).await;
+ let ordering = resolve_ordering();
match command {
TaskCommands::Create {
@@ -197,7 +207,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
.await?;
if let Some(ref anchors) = anchors {
- ops::task::move_task(&mut tx, &task, anchors.as_placement()).await?;
+ ops::task::move_task(&mut tx, &task, anchors.as_placement(), ordering).await?;
}
tx.commit().await?;
@@ -224,7 +234,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
let backlog_keys = ops::task::keys_for_backlog(&mut conn, bl.id).await?;
let prefixes = key::unique_prefix_lengths(&backlog_keys);
- let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter, ordering).await?;
output::print_list(&tasks, json, |t| print_task(t, &prefixes));
} else {
// List all tasks (no backlog filter)
@@ -233,7 +243,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let backlogs = ops::backlog::list(&mut conn).await?;
let mut all_tasks = Vec::new();
for bl in &backlogs {
- let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter, ordering).await?;
for t in tasks {
if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
all_tasks.push(t);
@@ -310,11 +320,12 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
title.as_deref(),
description.as_deref(),
state,
+ ordering,
)
.await?;
if let Some(ref anchors) = anchors {
- ops::task::move_task(&mut conn, &updated, anchors.as_placement()).await?;
+ ops::task::move_task(&mut conn, &updated, anchors.as_placement(), ordering).await?;
}
let all_keys = ops::task::all_keys(&mut conn).await?;
@@ -328,7 +339,8 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
match anchors {
Some(anchors) => {
- ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
+ ops::task::move_task(&mut conn, &task, anchors.as_placement(), ordering)
+ .await?;
let all_keys = ops::task::all_keys(&mut conn).await?;
let prefixes = key::unique_prefix_lengths(&all_keys);
println!(
diff --git a/src/error.rs b/src/error.rs
index 3017f7e..92e71de 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,8 +13,6 @@ pub enum RangerError {
BacklogNotFound(String),
#[error("adding this edge would create a cycle")]
CycleDetected,
- #[error("task already has an outgoing 'before' edge")]
- DuplicateBeforeEdge,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("migration error: {0}")]
diff --git a/src/models.rs b/src/models.rs
index 4e58d5f..29c1db2 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -3,6 +3,16 @@ use sqlx::FromRow;
use crate::timestamp::Timestamp;
+/// Controls how tasks are ordered within a backlog/state group.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Ordering {
+ /// Lexicographic position strings (legacy).
+ #[default]
+ Position,
+ /// DAG topological sort using `before` edges, with task ID as tiebreaker.
+ Dag,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
diff --git a/src/ops/edge.rs b/src/ops/edge.rs
index 0c75025..4085081 100644
--- a/src/ops/edge.rs
+++ b/src/ops/edge.rs
@@ -3,7 +3,8 @@ use crate::models::{EdgeType, TaskEdge};
use petgraph::graph::DiGraph;
use petgraph::graph::NodeIndex;
use sqlx::sqlite::SqliteConnection;
-use std::collections::HashMap;
+use std::cmp::Reverse;
+use std::collections::{BinaryHeap, HashMap, HashSet};
pub async fn add(
conn: &mut SqliteConnection,
@@ -15,20 +16,6 @@ pub async fn add(
let edges = list_all(&mut *conn).await?;
check_cycle(&edges, from_task_id, to_task_id)?;
- // Enforce: at most one outgoing 'before' edge per task
- if edge_type == EdgeType::Before {
- let existing: Option<i64> = sqlx::query_scalar(
- "SELECT id FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
- )
- .bind(from_task_id)
- .fetch_optional(&mut *conn)
- .await?;
-
- if existing.is_some() {
- return Err(RangerError::DuplicateBeforeEdge);
- }
- }
-
let edge = sqlx::query_as::<_, TaskEdge>(
"INSERT INTO task_edges (from_task_id, to_task_id, edge_type) \
VALUES (?, ?, ?) \
@@ -157,6 +144,209 @@ pub fn topological_sort(edges: &[TaskEdge]) -> Vec<i64> {
}
}
+/// Deterministic topological sort using Kahn's algorithm with min-ID tiebreaker.
+///
+/// Only considers `before` edges between task IDs in `task_ids`.
+/// Every ID in `task_ids` appears exactly once in the output.
+pub fn ordered_ids(task_ids: &[i64], edges: &[TaskEdge]) -> Vec<i64> {
+ if task_ids.is_empty() {
+ return Vec::new();
+ }
+
+ let id_set: HashSet<i64> = task_ids.iter().copied().collect();
+
+ // Build adjacency list and in-degree map — only 'before' edges within the set
+ let mut adj: HashMap<i64, Vec<i64>> = HashMap::new();
+ let mut in_degree: HashMap<i64, usize> = HashMap::new();
+
+ for &id in task_ids {
+ adj.entry(id).or_default();
+ in_degree.entry(id).or_insert(0);
+ }
+
+ for edge in edges {
+ if edge.edge_type == EdgeType::Before
+ && id_set.contains(&edge.from_task_id)
+ && id_set.contains(&edge.to_task_id)
+ {
+ adj.entry(edge.from_task_id)
+ .or_default()
+ .push(edge.to_task_id);
+ *in_degree.entry(edge.to_task_id).or_insert(0) += 1;
+ }
+ }
+
+ // Kahn's algorithm with a min-heap for deterministic ordering
+ let mut heap: BinaryHeap<Reverse<i64>> = BinaryHeap::new();
+ for (&id, °) in &in_degree {
+ if deg == 0 {
+ heap.push(Reverse(id));
+ }
+ }
+
+ let mut result = Vec::with_capacity(task_ids.len());
+ while let Some(Reverse(id)) = heap.pop() {
+ result.push(id);
+ for &next in &adj[&id] {
+ let deg = in_degree.get_mut(&next).unwrap();
+ *deg -= 1;
+ if *deg == 0 {
+ heap.push(Reverse(next));
+ }
+ }
+ }
+
+ result
+}
+
+/// Remove a task from any `before` chains it participates in.
+///
+/// If the task has predecessors (P → task) and a successor (task → S),
+/// reconnects each predecessor directly to the successor (P → S).
+pub async fn splice_out_before(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+) -> Result<(), RangerError> {
+ // Get the outgoing 'before' edge: task → successor
+ let outgoing: Option<TaskEdge> = sqlx::query_as(
+ "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+ FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
+ )
+ .bind(task_id)
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ // Get all incoming 'before' edges: predecessor → task
+ let incoming: Vec<TaskEdge> = sqlx::query_as(
+ "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+ FROM task_edges WHERE to_task_id = ? AND edge_type = 'before'",
+ )
+ .bind(task_id)
+ .fetch_all(&mut *conn)
+ .await?;
+
+ // Delete all 'before' edges involving this task
+ sqlx::query("DELETE FROM task_edges WHERE (from_task_id = ? OR to_task_id = ?) AND edge_type = 'before'")
+ .bind(task_id)
+ .bind(task_id)
+ .execute(&mut *conn)
+ .await?;
+
+ // Reconnect: each predecessor → successor
+ if let Some(succ) = &outgoing {
+ for pred in &incoming {
+ sqlx::query(
+ "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, edge_type) \
+ VALUES (?, ?, 'before')",
+ )
+ .bind(pred.from_task_id)
+ .bind(succ.to_task_id)
+ .execute(&mut *conn)
+ .await?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Insert a task immediately before a target in the `before` chain.
+///
+/// Any predecessors of the target are rewired to point to the inserted task.
+pub async fn insert_before_task(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ target_id: i64,
+) -> Result<(), RangerError> {
+ // Rewire incoming 'before' edges to target → point to task instead
+ let incoming: Vec<TaskEdge> = sqlx::query_as(
+ "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+ FROM task_edges WHERE to_task_id = ? AND edge_type = 'before'",
+ )
+ .bind(target_id)
+ .fetch_all(&mut *conn)
+ .await?;
+
+ for pred in &incoming {
+ sqlx::query("DELETE FROM task_edges WHERE id = ?")
+ .bind(pred.id)
+ .execute(&mut *conn)
+ .await?;
+ sqlx::query(
+ "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, edge_type) \
+ VALUES (?, ?, 'before')",
+ )
+ .bind(pred.from_task_id)
+ .bind(task_id)
+ .execute(&mut *conn)
+ .await?;
+ }
+
+ // Add task → target
+ add(&mut *conn, task_id, target_id, EdgeType::Before).await?;
+ Ok(())
+}
+
+/// Insert a task immediately after an anchor in the `before` chain.
+///
+/// All outgoing `before` edges from the anchor are rewired through the task.
+pub async fn insert_after_task(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ anchor_id: i64,
+) -> Result<(), RangerError> {
+ // Collect all outgoing 'before' edges from anchor
+ let outgoing: Vec<TaskEdge> = sqlx::query_as(
+ "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+ FROM task_edges WHERE from_task_id = ? AND edge_type = 'before'",
+ )
+ .bind(anchor_id)
+ .fetch_all(&mut *conn)
+ .await?;
+
+ for succ in &outgoing {
+ let successor_id = succ.to_task_id;
+ // Remove anchor → successor
+ remove(&mut *conn, anchor_id, successor_id, EdgeType::Before).await?;
+ // Add task → successor
+ add(&mut *conn, task_id, successor_id, EdgeType::Before).await?;
+ }
+
+ // Add anchor → task
+ add(&mut *conn, anchor_id, task_id, EdgeType::Before).await?;
+ Ok(())
+}
+
+/// Fetch all edges involving any of the given task IDs.
+pub async fn list_for_task_ids(
+ conn: &mut SqliteConnection,
+ task_ids: &[i64],
+) -> Result<Vec<TaskEdge>, RangerError> {
+ if task_ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ // Build a comma-separated placeholder list
+ let placeholders: String = task_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
+ let query = format!(
+ "SELECT id, from_task_id, to_task_id, edge_type, created_at \
+ FROM task_edges \
+ WHERE from_task_id IN ({placeholders}) OR to_task_id IN ({placeholders}) \
+ ORDER BY created_at"
+ );
+
+ let mut q = sqlx::query_as::<_, TaskEdge>(&query);
+ // Bind task_ids twice (once for each IN clause)
+ for &id in task_ids {
+ q = q.bind(id);
+ }
+ for &id in task_ids {
+ q = q.bind(id);
+ }
+
+ let edges = q.fetch_all(&mut *conn).await?;
+ Ok(edges)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -275,7 +465,7 @@ mod tests {
}
#[tokio::test]
- async fn duplicate_before_edge_rejected() {
+ async fn multiple_before_edges_allowed() {
let pool = test_pool().await;
let mut conn = pool.acquire().await.unwrap();
let bl = backlog::create(&mut conn, "Test").await.unwrap();
@@ -284,11 +474,14 @@ mod tests {
let t3 = create_task(&mut conn, bl.id, "Task 3").await;
add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
- let err = add(&mut conn, t1, t3, EdgeType::Before).await.unwrap_err();
- assert!(
- err.to_string().contains("before"),
- "expected duplicate before error, got: {err}"
- );
+ add(&mut conn, t1, t3, EdgeType::Before).await.unwrap();
+
+ let edges = list_for_task(&mut conn, t1).await.unwrap();
+ let before_edges: Vec<_> = edges
+ .iter()
+ .filter(|e| e.edge_type == EdgeType::Before)
+ .collect();
+ assert_eq!(before_edges.len(), 2);
}
#[tokio::test]
@@ -403,4 +596,266 @@ mod tests {
let sorted = topological_sort(&edges);
assert_eq!(sorted, vec![t1, t2, t3]);
}
+
+ // -- ordered_ids tests --
+
+ #[test]
+ fn ordered_ids_empty() {
+ assert!(ordered_ids(&[], &[]).is_empty());
+ }
+
+ #[test]
+ fn ordered_ids_no_edges_sorts_by_id() {
+ let result = ordered_ids(&[30, 10, 20], &[]);
+ assert_eq!(result, vec![10, 20, 30]);
+ }
+
+ #[tokio::test]
+ async fn ordered_ids_respects_before_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ // t3 before t1 (override natural id order)
+ add(&mut conn, t3, t1, EdgeType::Before).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let result = ordered_ids(&[t1, t2, t3], &edges);
+ // t2 has lowest id among unconstrained, t3 must come before t1
+ assert_eq!(result, vec![t2, t3, t1]);
+ }
+
+ #[tokio::test]
+ async fn ordered_ids_ignores_blocks_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ // t2 blocks t1 — should NOT affect ordering
+ add(&mut conn, t2, t1, EdgeType::Blocks).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let result = ordered_ids(&[t1, t2], &edges);
+ assert_eq!(result, vec![t1, t2], "blocks edges should not affect order");
+ }
+
+ #[tokio::test]
+ async fn ordered_ids_filters_to_given_ids() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ // Only ask about t2 and t3 — edge t1→t2 should be ignored since t1 not in set
+ let result = ordered_ids(&[t2, t3], &edges);
+ assert_eq!(result, vec![t2, t3]);
+ }
+
+ // -- splice_out_before tests --
+
+ #[tokio::test]
+ async fn splice_out_middle_of_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+ add(&mut conn, t2, t3, EdgeType::Before).await.unwrap();
+
+ splice_out_before(&mut conn, t2).await.unwrap();
+
+ // t1 → t3 should now exist; no edges involving t2
+ let edges = list_all(&mut conn).await.unwrap();
+ assert_eq!(edges.len(), 1);
+ assert_eq!(edges[0].from_task_id, t1);
+ assert_eq!(edges[0].to_task_id, t3);
+ assert_eq!(edges[0].edge_type, EdgeType::Before);
+ }
+
+ #[tokio::test]
+ async fn splice_out_head_of_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+ splice_out_before(&mut conn, t1).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ assert!(edges.is_empty());
+ }
+
+ #[tokio::test]
+ async fn splice_out_tail_of_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+ splice_out_before(&mut conn, t2).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ assert!(edges.is_empty());
+ }
+
+ #[tokio::test]
+ async fn splice_out_preserves_blocks_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+ add(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
+
+ splice_out_before(&mut conn, t2).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ assert_eq!(edges.len(), 1);
+ assert_eq!(edges[0].edge_type, EdgeType::Blocks);
+ }
+
+ #[tokio::test]
+ async fn splice_out_no_edges_is_noop() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+
+ // Should not error
+ splice_out_before(&mut conn, t1).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ assert!(edges.is_empty());
+ }
+
+ // -- insert_before_task tests --
+
+ #[tokio::test]
+ async fn insert_before_into_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ // Chain: t1 → t2
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+ // Insert t3 before t2 → chain becomes t1 → t3 → t2
+ insert_before_task(&mut conn, t3, t2).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let before_edges: Vec<_> = edges
+ .iter()
+ .filter(|e| e.edge_type == EdgeType::Before)
+ .collect();
+ assert_eq!(before_edges.len(), 2);
+
+ let sorted = ordered_ids(&[t1, t2, t3], &edges);
+ assert_eq!(sorted, vec![t1, t3, t2]);
+ }
+
+ #[tokio::test]
+ async fn insert_before_at_head() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ // Insert t2 before t1 (t1 has no predecessor)
+ insert_before_task(&mut conn, t2, t1).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let sorted = ordered_ids(&[t1, t2], &edges);
+ assert_eq!(sorted, vec![t2, t1]);
+ }
+
+ // -- insert_after_task tests --
+
+ #[tokio::test]
+ async fn insert_after_into_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ // Chain: t1 → t2
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+
+ // Insert t3 after t1 → chain becomes t1 → t3 → t2
+ insert_after_task(&mut conn, t3, t1).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let sorted = ordered_ids(&[t1, t2, t3], &edges);
+ assert_eq!(sorted, vec![t1, t3, t2]);
+ }
+
+ #[tokio::test]
+ async fn insert_after_at_tail() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+
+ // Insert t2 after t1 (t1 has no successor)
+ insert_after_task(&mut conn, t2, t1).await.unwrap();
+
+ let edges = list_all(&mut conn).await.unwrap();
+ let sorted = ordered_ids(&[t1, t2], &edges);
+ assert_eq!(sorted, vec![t1, t2]);
+ }
+
+ // -- list_for_task_ids tests --
+
+ #[tokio::test]
+ async fn list_for_task_ids_empty() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let edges = list_for_task_ids(&mut conn, &[]).await.unwrap();
+ assert!(edges.is_empty());
+ }
+
+ #[tokio::test]
+ async fn list_for_task_ids_returns_relevant_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+ let t1 = create_task(&mut conn, bl.id, "Task 1").await;
+ let t2 = create_task(&mut conn, bl.id, "Task 2").await;
+ let t3 = create_task(&mut conn, bl.id, "Task 3").await;
+
+ add(&mut conn, t1, t2, EdgeType::Before).await.unwrap();
+ add(&mut conn, t2, t3, EdgeType::Before).await.unwrap();
+
+ // Only ask about t1 and t2
+ let edges = list_for_task_ids(&mut conn, &[t1, t2]).await.unwrap();
+ // Should get t1→t2 and t2→t3 (t2 is involved in both)
+ assert_eq!(edges.len(), 2);
+ }
}
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 969f1da..7c18f16 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -1,8 +1,10 @@
use crate::error::RangerError;
use crate::key;
-use crate::models::{State, Task};
+use crate::models::{Ordering, State, Task};
+use crate::ops::edge;
use crate::position;
use sqlx::sqlite::SqliteConnection;
+use std::collections::HashMap;
const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
@@ -61,6 +63,7 @@ pub async fn list(
conn: &mut SqliteConnection,
backlog_id: i64,
filter: &ListFilter,
+ ordering: Ordering,
) -> Result<Vec<Task>, RangerError> {
let archived_clause = if filter.include_archived {
""
@@ -74,11 +77,15 @@ pub async fn list(
""
};
- let tasks = if let Some(state) = &filter.state {
+ let order_clause = match ordering {
+ Ordering::Position => " ORDER BY position",
+ Ordering::Dag => " ORDER BY id",
+ };
+
+ let mut tasks = if let Some(state) = &filter.state {
let query = format!(
"SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
- WHERE backlog_id = ? AND state = ?{archived_clause} \
- ORDER BY position"
+ WHERE backlog_id = ? AND state = ?{archived_clause}{order_clause}"
);
let mut q = sqlx::query_as::<_, Task>(&query);
if let Some(tag) = &filter.tag {
@@ -91,8 +98,7 @@ pub async fn list(
} else {
let query = format!(
"SELECT {TASK_COLUMNS} FROM tasks{tag_join} \
- WHERE backlog_id = ?{archived_clause} \
- ORDER BY position"
+ WHERE backlog_id = ?{archived_clause}{order_clause}"
);
let mut q = sqlx::query_as::<_, Task>(&query);
if let Some(tag) = &filter.tag {
@@ -100,6 +106,20 @@ pub async fn list(
}
q.bind(backlog_id).fetch_all(&mut *conn).await?
};
+
+ if ordering == Ordering::Dag {
+ let task_ids: Vec<i64> = tasks.iter().map(|t| t.id).collect();
+ let edges = edge::list_for_task_ids(&mut *conn, &task_ids).await?;
+ let sorted_ids = edge::ordered_ids(&task_ids, &edges);
+
+ let task_map: HashMap<i64, usize> = sorted_ids
+ .iter()
+ .enumerate()
+ .map(|(i, &id)| (id, i))
+ .collect();
+ tasks.sort_by_key(|t| task_map.get(&t.id).copied().unwrap_or(usize::MAX));
+ }
+
Ok(tasks)
}
@@ -165,6 +185,7 @@ pub async fn edit(
title: Option<&str>,
description: Option<&str>,
state: Option<State>,
+ ordering: Ordering,
) -> Result<Task, RangerError> {
if let Some(title) = title {
sqlx::query("UPDATE tasks SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
@@ -194,7 +215,7 @@ pub async fn edit(
.await?;
if old_state != *new_state {
- reorder(&mut *conn, task_id, &old_state, new_state).await?;
+ reorder(&mut *conn, task_id, &old_state, new_state, ordering).await?;
}
}
@@ -243,6 +264,7 @@ pub async fn move_task(
conn: &mut SqliteConnection,
task: &Task,
placement: Placement<'_>,
+ ordering: Ordering,
) -> Result<(), RangerError> {
for anchor in placement.anchors() {
if anchor.state != task.state {
@@ -253,6 +275,17 @@ pub async fn move_task(
}
}
+ match ordering {
+ Ordering::Position => move_task_position(&mut *conn, task, &placement).await,
+ Ordering::Dag => move_task_dag(&mut *conn, task, &placement).await,
+ }
+}
+
+async fn move_task_position(
+ conn: &mut SqliteConnection,
+ task: &Task,
+ placement: &Placement<'_>,
+) -> Result<(), RangerError> {
let new_pos = match placement {
Placement::After(anchor) => {
let next =
@@ -272,7 +305,51 @@ pub async fn move_task(
set_position(&mut *conn, task.id, &new_pos).await
}
-/// Reorder a task's position when its state changes.
+async fn move_task_dag(
+ conn: &mut SqliteConnection,
+ task: &Task,
+ placement: &Placement<'_>,
+) -> Result<(), RangerError> {
+ edge::splice_out_before(&mut *conn, task.id).await?;
+
+ match placement {
+ Placement::Before(anchor) => {
+ edge::insert_before_task(&mut *conn, task.id, anchor.id).await?;
+ }
+ Placement::After(anchor) => {
+ edge::insert_after_task(&mut *conn, task.id, anchor.id).await?;
+ }
+ Placement::Between { after, before } => {
+ // Remove direct edge between anchors if it exists
+ edge::remove(
+ &mut *conn,
+ after.id,
+ before.id,
+ crate::models::EdgeType::Before,
+ )
+ .await?;
+ // Insert: after → task → before
+ edge::add(
+ &mut *conn,
+ after.id,
+ task.id,
+ crate::models::EdgeType::Before,
+ )
+ .await?;
+ edge::add(
+ &mut *conn,
+ task.id,
+ before.id,
+ crate::models::EdgeType::Before,
+ )
+ .await?;
+ }
+ }
+
+ Ok(())
+}
+
+/// Reorder a task when its state changes.
///
/// Moving up (toward done): place at end of target state group.
/// Moving down (toward icebox): place at beginning of target state group.
@@ -281,6 +358,19 @@ async fn reorder(
task_id: i64,
old_state: &State,
new_state: &State,
+ ordering: Ordering,
+) -> Result<(), RangerError> {
+ match ordering {
+ Ordering::Position => reorder_position(&mut *conn, task_id, old_state, new_state).await,
+ Ordering::Dag => reorder_dag(&mut *conn, task_id, old_state, new_state).await,
+ }
+}
+
+async fn reorder_position(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ old_state: &State,
+ new_state: &State,
) -> Result<(), RangerError> {
let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
.bind(task_id)
@@ -322,6 +412,76 @@ async fn reorder(
set_position(&mut *conn, task_id, &new_pos).await
}
+async fn reorder_dag(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ _old_state: &State,
+ new_state: &State,
+) -> Result<(), RangerError> {
+ let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
+ .bind(task_id)
+ .fetch_one(&mut *conn)
+ .await?;
+
+ // Remove the task from its old before-edge chains
+ edge::splice_out_before(&mut *conn, task_id).await?;
+
+ // Find tasks in the target state to determine placement
+ let target_tasks: Vec<Task> = {
+ let query = format!(
+ "SELECT {TASK_COLUMNS} FROM tasks \
+ WHERE backlog_id = ? AND state = ? AND id != ? \
+ ORDER BY id"
+ );
+ sqlx::query_as::<_, Task>(&query)
+ .bind(backlog_id)
+ .bind(new_state.as_str())
+ .bind(task_id)
+ .fetch_all(&mut *conn)
+ .await?
+ };
+
+ if target_tasks.is_empty() {
+ return Ok(());
+ }
+
+ let task_ids: Vec<i64> = target_tasks.iter().map(|t| t.id).collect();
+ let edges = edge::list_for_task_ids(&mut *conn, &task_ids).await?;
+ let before_edge_type = crate::models::EdgeType::Before;
+
+ // Find roots (no incoming before) and leaves (no outgoing before) within the state
+ let has_incoming: std::collections::HashSet<i64> = edges
+ .iter()
+ .filter(|e| e.edge_type == before_edge_type && task_ids.contains(&e.from_task_id))
+ .map(|e| e.to_task_id)
+ .collect();
+ let has_outgoing: std::collections::HashSet<i64> = edges
+ .iter()
+ .filter(|e| e.edge_type == before_edge_type && task_ids.contains(&e.to_task_id))
+ .map(|e| e.from_task_id)
+ .collect();
+
+ let moving_up = new_state.rank() > _old_state.rank();
+
+ if moving_up {
+ // Place at end: add leaf → task for every leaf (no outgoing before)
+ for &id in &task_ids {
+ if !has_outgoing.contains(&id) {
+ edge::add(&mut *conn, id, task_id, before_edge_type.clone()).await?;
+ }
+ }
+ } else {
+ // Place at beginning: add task → root for every root (no incoming before)
+ for &id in &task_ids {
+ if !has_incoming.contains(&id) {
+ edge::add(&mut *conn, task_id, id, before_edge_type.clone()).await?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
// -- Position query helpers --
async fn last_position(
@@ -449,6 +609,7 @@ pub async fn rebalance(conn: &mut SqliteConnection, backlog_id: i64) -> Result<u
include_archived: true,
..Default::default()
},
+ Ordering::Position,
)
.await?;
let positions = position::spread(tasks.len());
@@ -545,7 +706,7 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks.len(), 3);
@@ -589,6 +750,7 @@ mod tests {
state: Some(State::Icebox),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -602,6 +764,7 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -655,6 +818,7 @@ mod tests {
Some("Updated"),
Some("A description"),
Some(State::Queued),
+ Ordering::Position,
)
.await
.unwrap();
@@ -686,7 +850,7 @@ mod tests {
let result = get_by_key_prefix(&mut conn, &task.key, None).await;
assert!(result.is_err());
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks.len(), 0);
@@ -788,9 +952,16 @@ mod tests {
.await
.unwrap();
- let updated = edit(&mut conn, task.id, Some("New title"), None, None)
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ task.id,
+ Some("New title"),
+ None,
+ None,
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.title, "New title");
assert_eq!(updated.state, State::Icebox);
}
@@ -824,11 +995,11 @@ mod tests {
.unwrap();
// Move first task after second
- move_task(&mut conn, &t1, Placement::After(&t2))
+ move_task(&mut conn, &t1, Placement::After(&t2), Ordering::Position)
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks[0].id, t2.id);
@@ -874,11 +1045,11 @@ mod tests {
.await
.unwrap();
- move_task(&mut conn, &t3, Placement::Before(&t1))
+ move_task(&mut conn, &t3, Placement::Before(&t1), Ordering::Position)
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks[0].id, t3.id);
@@ -925,11 +1096,11 @@ mod tests {
.await
.unwrap();
- move_task(&mut conn, &t1, Placement::After(&t3))
+ move_task(&mut conn, &t1, Placement::After(&t3), Ordering::Position)
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks[0].id, t2.id);
@@ -983,11 +1154,12 @@ mod tests {
after: &t1,
before: &t2,
},
+ Ordering::Position,
)
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(tasks[0].id, t1.id);
@@ -1023,9 +1195,14 @@ mod tests {
.await
.unwrap();
- let err = move_task(&mut conn, &queued, Placement::Before(&done))
- .await
- .unwrap_err();
+ let err = move_task(
+ &mut conn,
+ &queued,
+ Placement::Before(&done),
+ Ordering::Position,
+ )
+ .await
+ .unwrap_err();
assert!(err.to_string().contains("queued"));
assert!(err.to_string().contains("done"));
}
@@ -1072,9 +1249,16 @@ mod tests {
.unwrap();
// Move queued task to done — should land after Done 2
- let updated = edit(&mut conn, q1.id, None, None, Some(State::Done))
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ q1.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Done);
let done = list(
@@ -1084,6 +1268,7 @@ mod tests {
state: Some(State::Done),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1138,9 +1323,16 @@ mod tests {
.unwrap();
// Move in_progress task to queued — should land before Queued 1
- let updated = edit(&mut conn, ip.id, None, None, Some(State::Queued))
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ ip.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Queued);
let queued = list(
@@ -1150,6 +1342,7 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1194,9 +1387,16 @@ mod tests {
let original_pos = t1.position.clone();
// Edit to same state — position should not change
- let updated = edit(&mut conn, t1.id, None, None, Some(State::Queued))
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ t1.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.position, original_pos);
let queued = list(
@@ -1206,6 +1406,7 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1232,9 +1433,16 @@ mod tests {
.unwrap();
// Move to done (empty group) — should succeed
- let updated = edit(&mut conn, t1.id, None, None, Some(State::Done))
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ t1.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Done);
let done = list(
@@ -1244,6 +1452,7 @@ mod tests {
state: Some(State::Done),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1270,9 +1479,16 @@ mod tests {
.unwrap();
// Move to icebox (empty group) — should succeed
- let updated = edit(&mut conn, t1.id, None, None, Some(State::Icebox))
- .await
- .unwrap();
+ let updated = edit(
+ &mut conn,
+ t1.id,
+ None,
+ None,
+ Some(State::Icebox),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Icebox);
let icebox = list(
@@ -1282,6 +1498,7 @@ mod tests {
state: Some(State::Icebox),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1310,17 +1527,18 @@ mod tests {
.unwrap();
}
- let before: Vec<String> = list(&mut conn, bl.id, &ListFilter::default())
- .await
- .unwrap()
- .iter()
- .map(|t| t.position.clone())
- .collect();
+ let before: Vec<String> =
+ list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ .await
+ .unwrap()
+ .iter()
+ .map(|t| t.position.clone())
+ .collect();
let count = rebalance(&mut conn, bl.id).await.unwrap();
assert_eq!(count, 3);
- let after = list(&mut conn, bl.id, &ListFilter::default())
+ let after = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
let after_positions: Vec<String> = after.iter().map(|t| t.position.clone()).collect();
@@ -1423,7 +1641,7 @@ mod tests {
assert!(archived.archived);
// Default list excludes archived
- let visible = list(&mut conn, bl.id, &ListFilter::default())
+ let visible = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(visible.len(), 1);
@@ -1437,6 +1655,7 @@ mod tests {
include_archived: true,
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
@@ -1446,7 +1665,7 @@ mod tests {
let restored = set_archived(&mut conn, t2.id, false).await.unwrap();
assert!(!restored.archived);
- let visible = list(&mut conn, bl.id, &ListFilter::default())
+ let visible = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
.await
.unwrap();
assert_eq!(visible.len(), 2);
@@ -1490,10 +1709,740 @@ mod tests {
tag: Some("bug".to_string()),
..Default::default()
},
+ Ordering::Position,
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Tagged queued");
}
+
+ // ---- DAG ordering tests ----
+
+ #[tokio::test]
+ async fn dag_list_orders_by_id_with_no_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "C",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks.len(), 3);
+ assert_eq!(tasks[0].id, t1.id);
+ assert_eq!(tasks[1].id, t2.id);
+ assert_eq!(tasks[2].id, t3.id);
+ }
+
+ #[tokio::test]
+ async fn dag_list_respects_before_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "C",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // t3 should come before t1 (override natural id order)
+ crate::ops::edge::add(&mut conn, t3.id, t1.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks[0].id, t2.id, "t2 has lowest id, no constraints");
+ assert_eq!(tasks[1].id, t3.id, "t3 before t1 by edge");
+ assert_eq!(tasks[2].id, t1.id);
+ }
+
+ #[tokio::test]
+ async fn dag_move_before() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move t2 before t1
+ move_task(&mut conn, &t2, Placement::Before(&t1), Ordering::Dag)
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks[0].id, t2.id);
+ assert_eq!(tasks[1].id, t1.id);
+ }
+
+ #[tokio::test]
+ async fn dag_move_after() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "C",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move t1 after t3 (t1 is normally first by id)
+ move_task(&mut conn, &t1, Placement::After(&t3), Ordering::Dag)
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks[0].id, t2.id);
+ assert_eq!(tasks[1].id, t3.id);
+ assert_eq!(tasks[2].id, t1.id);
+ }
+
+ #[tokio::test]
+ async fn dag_move_between() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "C",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Set up chain: t1 → t2
+ crate::ops::edge::add(&mut conn, t1.id, t2.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+
+ // Move t3 between t1 and t2
+ move_task(
+ &mut conn,
+ &t3,
+ Placement::Between {
+ after: &t1,
+ before: &t2,
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks[0].id, t1.id);
+ assert_eq!(tasks[1].id, t3.id);
+ assert_eq!(tasks[2].id, t2.id);
+ }
+
+ #[tokio::test]
+ async fn dag_move_rejects_cross_state() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let queued = create(
+ &mut conn,
+ CreateTask {
+ title: "Q",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let done = create(
+ &mut conn,
+ CreateTask {
+ title: "D",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let err = move_task(&mut conn, &queued, Placement::Before(&done), Ordering::Dag)
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("queued"));
+ assert!(err.to_string().contains("done"));
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_up_places_at_end() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let d1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 1",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let d2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 2",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let updated = edit(
+ &mut conn,
+ q1.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Done);
+
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(done.len(), 3);
+ assert_eq!(done[0].id, d1.id);
+ assert_eq!(done[1].id, d2.id);
+ assert_eq!(done[2].id, q1.id, "newly done task should be at end");
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_down_places_at_beginning() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let q2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 2",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let ip = create(
+ &mut conn,
+ CreateTask {
+ title: "In Progress",
+ backlog_id: bl.id,
+ state: Some(State::InProgress),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let updated = edit(
+ &mut conn,
+ ip.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Queued);
+
+ let queued = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Queued),
+ ..Default::default()
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(queued.len(), 3);
+ assert_eq!(queued[0].id, ip.id, "demoted task should be at beginning");
+ assert_eq!(queued[1].id, q1.id);
+ assert_eq!(queued[2].id, q2.id);
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_to_empty_group() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let updated = edit(
+ &mut conn,
+ t1.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Done);
+
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(done.len(), 1);
+ assert_eq!(done[0].id, t1.id);
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_same_state_is_noop() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let updated = edit(
+ &mut conn,
+ t1.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Queued);
+
+ // No edges should have been created
+ let edges = crate::ops::edge::list_all(&mut conn).await.unwrap();
+ assert!(edges.is_empty());
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_up_into_group_with_existing_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ // Done tasks with an existing chain: d1 → d2
+ let d1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 1",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let d2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 2",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ crate::ops::edge::add(&mut conn, d1.id, d2.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+
+ // Queued task to promote
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ edit(
+ &mut conn,
+ q1.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ // d1 → d2 chain, q1 added after d2 (d2 is the only leaf)
+ assert_eq!(done.len(), 3);
+ assert_eq!(done[0].id, d1.id);
+ assert_eq!(done[1].id, d2.id);
+ assert_eq!(done[2].id, q1.id);
+ }
+
+ #[tokio::test]
+ async fn dag_state_change_down_into_group_with_existing_edges() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ // Queued tasks with an existing chain: q1 → q2
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let q2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 2",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ crate::ops::edge::add(&mut conn, q1.id, q2.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+
+ // In-progress task to demote
+ let ip = create(
+ &mut conn,
+ CreateTask {
+ title: "IP",
+ backlog_id: bl.id,
+ state: Some(State::InProgress),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ edit(
+ &mut conn,
+ ip.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+
+ let queued = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Queued),
+ ..Default::default()
+ },
+ Ordering::Dag,
+ )
+ .await
+ .unwrap();
+ // ip added before q1 (q1 is the only root), chain: ip → q1 → q2
+ assert_eq!(queued.len(), 3);
+ assert_eq!(queued[0].id, ip.id);
+ assert_eq!(queued[1].id, q1.id);
+ assert_eq!(queued[2].id, q2.id);
+ }
+
+ #[tokio::test]
+ async fn dag_move_splices_out_of_old_chain() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "A",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "B",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "C",
+ backlog_id: bl.id,
+ state: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Chain: t1 → t2 → t3
+ crate::ops::edge::add(&mut conn, t1.id, t2.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+ crate::ops::edge::add(&mut conn, t2.id, t3.id, crate::models::EdgeType::Before)
+ .await
+ .unwrap();
+
+ // Move t2 after t3 — should splice out and re-chain t1 → t3
+ move_task(&mut conn, &t2, Placement::After(&t3), Ordering::Dag)
+ .await
+ .unwrap();
+
+ let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ .await
+ .unwrap();
+ assert_eq!(tasks[0].id, t1.id);
+ assert_eq!(tasks[1].id, t3.id);
+ assert_eq!(tasks[2].id, t2.id);
+ }
}