remove edges, petgraph, and DAG ordering
Strip the entire edges system (before + blocks edge types), petgraph
dependency, Ordering enum, and all DAG code paths. Back to pure
lexicographic position ordering.
- Remove ops/edge.rs, commands/edge.rs, edge CLI subcommand
- Remove EdgeType, TaskEdge, InvalidEdgeTypeError from models
- Remove CycleDetected error variant
- Remove Ordering enum and RANGER_DAG_ORDER env var
- Remove DAG paths from list, move_task, edit, reorder
- Drop task_edges table via migration 011
- Remove petgraph from Cargo.toml
Blockers will be re-added as a simpler mechanism.
diff --git a/Cargo.lock b/Cargo.lock
index 6156cd0..3207865 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -524,12 +524,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
-[[package]]
-name = "fixedbitset"
-version = "0.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
-
[[package]]
name = "float-cmp"
version = "0.10.0"
@@ -1300,18 +1294,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-[[package]]
-name = "petgraph"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
-dependencies = [
- "fixedbitset",
- "hashbrown 0.15.5",
- "indexmap",
- "serde",
-]
-
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1507,7 +1489,6 @@ dependencies = [
"color-eyre",
"jiff",
"maud",
- "petgraph",
"predicates",
"rand",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index eee5950..0936b4d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,6 @@ serde = { version = "*", features = ["derive"] }
serde_json = "*"
sqlx = { version = "*", features = ["runtime-tokio", "sqlite", "migrate"] }
maud = { version = "*", features = ["axum"] }
-petgraph = "*"
thiserror = "*"
tokio = { version = "*", features = ["full"] }
xdg = "*"
diff --git a/migrations/011_drop_task_edges.sql b/migrations/011_drop_task_edges.sql
new file mode 100644
index 0000000..f5706e5
--- /dev/null
+++ b/migrations/011_drop_task_edges.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS task_edges;
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 31c279f..a611656 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -7,7 +7,6 @@ use ranger::models::{Backlog, State};
use ranger::ops;
use ranger::ops::task::ListFilter;
-use super::task::resolve_ordering;
use crate::completions;
use crate::output;
@@ -50,7 +49,6 @@ 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 } => {
@@ -86,7 +84,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, ordering).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
if !tasks.is_empty() {
state_groups
.insert(state.to_string(), serde_json::to_value(&tasks).unwrap());
@@ -108,7 +106,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, ordering).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
if !tasks.is_empty() {
println!("\n[{}]", state);
for t in &tasks {
diff --git a/src/bin/ranger/commands/edge.rs b/src/bin/ranger/commands/edge.rs
deleted file mode 100644
index f4fbb8e..0000000
--- a/src/bin/ranger/commands/edge.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-use clap::Subcommand;
-use clap_complete::engine::ArgValueCompleter;
-use color_eyre::eyre::Result;
-use ranger::db::SqlitePool;
-use ranger::models::EdgeType;
-use ranger::ops;
-
-use super::task::default_backlog_id;
-use crate::completions;
-use crate::output;
-
-#[derive(Subcommand)]
-pub enum EdgeCommands {
- /// Add an edge between tasks
- Add {
- /// Source task key or prefix
- #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
- from: String,
- /// Edge type: blocks or before
- edge_type: EdgeType,
- /// Target task key or prefix
- #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
- to: String,
- },
- /// Remove an edge between tasks
- #[command(visible_alias = "rm")]
- Remove {
- /// Source task key or prefix
- #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
- from: String,
- /// Edge type: blocks or before
- edge_type: EdgeType,
- /// Target task key or prefix
- #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
- to: String,
- },
- /// List edges for a task
- #[command(visible_alias = "ls")]
- List {
- /// Task key or prefix (omit to list all edges)
- #[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
- task: Option<String>,
- },
-}
-
-pub async fn run(pool: &SqlitePool, command: EdgeCommands, json: bool) -> Result<()> {
- let backlog_scope = default_backlog_id(pool).await;
- let mut conn = pool.acquire().await?;
-
- match command {
- EdgeCommands::Add {
- from,
- edge_type,
- to,
- } => {
- let from_task = ops::task::get_by_key_prefix(&mut conn, &from, backlog_scope).await?;
- let to_task = ops::task::get_by_key_prefix(&mut conn, &to, backlog_scope).await?;
-
- let edge = ops::edge::add(&mut conn, from_task.id, to_task.id, edge_type).await?;
- output::print(&edge, json, |e| {
- println!("{} {} {}", from, e.edge_type, to);
- });
- }
- EdgeCommands::Remove {
- from,
- edge_type,
- to,
- } => {
- let from_task = ops::task::get_by_key_prefix(&mut conn, &from, backlog_scope).await?;
- let to_task = ops::task::get_by_key_prefix(&mut conn, &to, backlog_scope).await?;
-
- let removed = ops::edge::remove(&mut conn, from_task.id, to_task.id, edge_type).await?;
- if !json {
- if removed {
- println!("Removed edge from {} to {}", from, to);
- } else {
- println!("No matching edge found");
- }
- }
- }
- EdgeCommands::List { task } => {
- let edges = if let Some(ref key) = task {
- let t = ops::task::get_by_key_prefix(&mut conn, key, backlog_scope).await?;
- ops::edge::list_for_task(&mut conn, t.id).await?
- } else {
- ops::edge::list_all(&mut conn).await?
- };
-
- if json {
- output::print_list(&edges, json, |_| {});
- } else {
- for e in &edges {
- let from = ops::task::get_by_id(&mut conn, e.from_task_id).await?;
- let to = ops::task::get_by_id(&mut conn, e.to_task_id).await?;
- println!(
- "{} {} {}",
- &from.key[..8.min(from.key.len())],
- e.edge_type,
- &to.key[..8.min(to.key.len())],
- );
- }
- }
- }
- }
- Ok(())
-}
diff --git a/src/bin/ranger/commands/mod.rs b/src/bin/ranger/commands/mod.rs
index c0fd35b..18d6800 100644
--- a/src/bin/ranger/commands/mod.rs
+++ b/src/bin/ranger/commands/mod.rs
@@ -1,6 +1,5 @@
pub mod backlog;
pub mod comment;
-pub mod edge;
pub mod serve;
pub mod tag;
pub mod task;
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 2a4cbfa..703a481 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -4,15 +4,13 @@ use axum::response::{IntoResponse, Redirect};
use axum::{Router, routing::get};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use ranger::key;
-use ranger::models::{Ordering, Task};
+use ranger::models::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");
@@ -20,7 +18,6 @@ const STYLE_CSS: &str = include_str!("../../../../static/style.css");
struct AppState {
pool: SqlitePool,
default_backlog: Option<String>,
- ordering: Ordering,
}
pub async fn run(
@@ -31,7 +28,6 @@ pub async fn run(
let state = AppState {
pool: pool.clone(),
default_backlog,
- ordering: resolve_ordering(),
};
let app = Router::new()
@@ -122,7 +118,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, state.ordering).await?;
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).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 78444d1..4fc5db4 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -5,22 +5,13 @@ use clap_complete::engine::ArgValueCompleter;
use color_eyre::eyre::{Result, bail};
use ranger::db::{SqliteConnection, SqlitePool};
use ranger::key;
-use ranger::models::{Ordering, State, Task};
+use ranger::models::{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 {
@@ -179,7 +170,6 @@ 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 {
@@ -207,7 +197,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(), ordering).await?;
+ ops::task::move_task(&mut tx, &task, anchors.as_placement()).await?;
}
tx.commit().await?;
@@ -234,7 +224,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, ordering).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
output::print_list(&tasks, json, |t| print_task(t, &prefixes));
} else {
// List all tasks (no backlog filter)
@@ -243,7 +233,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, ordering).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
for t in tasks {
if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
all_tasks.push(t);
@@ -258,14 +248,12 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
let comments = ops::comment::list(&mut conn, task.id).await?;
let tags = ops::tag::list_for_task(&mut conn, task.id).await?;
- let edges = ops::edge::list_for_task(&mut conn, task.id).await?;
if json {
let detail = serde_json::json!({
"task": task,
"comments": comments,
"tags": tags,
- "edges": edges,
});
println!("{}", serde_json::to_string_pretty(&detail).unwrap());
} else {
@@ -277,22 +265,6 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
println!("Tags: {}", tag_names.join(", "));
}
- if !edges.is_empty() {
- for e in &edges {
- let other_id = if e.from_task_id == task.id {
- e.to_task_id
- } else {
- e.from_task_id
- };
- let other = ops::task::get_by_id(&mut conn, other_id).await?;
- let other_key = output::format_key_from_map(&other.key, &prefixes);
- if e.from_task_id == task.id {
- println!("Edge: {} → {}", e.edge_type, other_key);
- } else {
- println!("Edge: {} ← {}", e.edge_type, other_key);
- }
- }
- }
if !comments.is_empty() {
println!();
for c in &comments {
@@ -320,12 +292,11 @@ 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(), ordering).await?;
+ ops::task::move_task(&mut conn, &updated, anchors.as_placement()).await?;
}
let all_keys = ops::task::all_keys(&mut conn).await?;
@@ -339,8 +310,7 @@ 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(), ordering)
- .await?;
+ ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
let all_keys = ops::task::all_keys(&mut conn).await?;
let prefixes = key::unique_prefix_lengths(&all_keys);
println!(
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index eb11752..71c9664 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -45,12 +45,6 @@ enum Commands {
#[command(subcommand)]
command: commands::comment::CommentCommands,
},
- /// Manage task edges (dependencies and ordering)
- #[command(visible_alias = "e")]
- Edge {
- #[command(subcommand)]
- command: commands::edge::EdgeCommands,
- },
/// Manage tags
#[command(visible_alias = "g")]
Tag {
@@ -110,9 +104,6 @@ async fn async_main() -> color_eyre::Result<()> {
Some(Commands::Comment { command }) => {
commands::comment::run(&pool, command, cli.json).await?;
}
- Some(Commands::Edge { command }) => {
- commands::edge::run(&pool, command, cli.json).await?;
- }
Some(Commands::Tag { command }) => {
commands::tag::run(&pool, command, cli.json).await?;
}
diff --git a/src/error.rs b/src/error.rs
index 92e71de..e361f0e 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -11,8 +11,6 @@ pub enum RangerError {
},
#[error("backlog not found: '{0}'")]
BacklogNotFound(String),
- #[error("adding this edge would create a cycle")]
- CycleDetected,
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("migration error: {0}")]
diff --git a/src/models.rs b/src/models.rs
index 1ea0a12..9702c9c 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -3,16 +3,6 @@ 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 {
@@ -125,74 +115,6 @@ pub struct Comment {
pub created_at: Timestamp,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum EdgeType {
- Blocks,
- Before,
-}
-
-impl EdgeType {
- pub fn as_str(&self) -> &'static str {
- match self {
- EdgeType::Blocks => "blocks",
- EdgeType::Before => "before",
- }
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-#[error("invalid edge type: '{0}'")]
-pub struct InvalidEdgeTypeError(String);
-
-impl std::str::FromStr for EdgeType {
- type Err = InvalidEdgeTypeError;
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- match s {
- "blocks" => Ok(EdgeType::Blocks),
- "before" => Ok(EdgeType::Before),
- _ => Err(InvalidEdgeTypeError(s.to_string())),
- }
- }
-}
-
-impl std::fmt::Display for EdgeType {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(self.as_str())
- }
-}
-
-impl sqlx::Type<sqlx::Sqlite> for EdgeType {
- fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
- <str as sqlx::Type<sqlx::Sqlite>>::type_info()
- }
-}
-
-impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for EdgeType {
- fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
- let s = <&str as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
- Ok(s.parse::<EdgeType>()?)
- }
-}
-
-impl sqlx::Encode<'_, sqlx::Sqlite> for EdgeType {
- fn encode_by_ref(
- &self,
- buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'_>,
- ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
- <&str as sqlx::Encode<sqlx::Sqlite>>::encode_by_ref(&self.as_str(), buf)
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
-pub struct TaskEdge {
- pub id: i64,
- pub from_task_id: i64,
- pub to_task_id: i64,
- pub edge_type: EdgeType,
- pub created_at: Timestamp,
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -212,39 +134,6 @@ mod tests {
}
}
- #[test]
- fn edge_type_roundtrips_through_display_and_parse() {
- for (edge_type, expected) in [(EdgeType::Blocks, "blocks"), (EdgeType::Before, "before")] {
- assert_eq!(edge_type.as_str(), expected);
- assert_eq!(edge_type.to_string(), expected);
- let parsed: EdgeType = expected.parse().unwrap();
- assert_eq!(parsed.as_str(), expected);
- }
- }
-
- #[test]
- fn edge_type_parse_invalid_returns_error() {
- let err = "bogus".parse::<EdgeType>().unwrap_err();
- assert_eq!(err.to_string(), "invalid edge type: 'bogus'");
- }
-
- #[tokio::test]
- async fn edge_type_sqlx_encode_roundtrips() {
- let dir = tempfile::tempdir().unwrap();
- let pool = crate::db::connect(&dir.path().join("test.db"))
- .await
- .unwrap();
- let mut conn = pool.acquire().await.unwrap();
-
- let edge_type = EdgeType::Blocks;
- let row: (String,) = sqlx::query_as("SELECT ?")
- .bind(&edge_type)
- .fetch_one(&mut *conn)
- .await
- .unwrap();
- assert_eq!(row.0, "blocks");
- }
-
#[test]
fn state_parse_invalid_returns_error() {
let err = "bogus".parse::<State>().unwrap_err();
diff --git a/src/ops/edge.rs b/src/ops/edge.rs
deleted file mode 100644
index 4085081..0000000
--- a/src/ops/edge.rs
+++ /dev/null
@@ -1,861 +0,0 @@
-use crate::error::RangerError;
-use crate::models::{EdgeType, TaskEdge};
-use petgraph::graph::DiGraph;
-use petgraph::graph::NodeIndex;
-use sqlx::sqlite::SqliteConnection;
-use std::cmp::Reverse;
-use std::collections::{BinaryHeap, HashMap, HashSet};
-
-pub async fn add(
- conn: &mut SqliteConnection,
- from_task_id: i64,
- to_task_id: i64,
- edge_type: EdgeType,
-) -> Result<TaskEdge, RangerError> {
- // Load the current DAG and check if adding this edge would create a cycle
- let edges = list_all(&mut *conn).await?;
- check_cycle(&edges, from_task_id, to_task_id)?;
-
- let edge = sqlx::query_as::<_, TaskEdge>(
- "INSERT INTO task_edges (from_task_id, to_task_id, edge_type) \
- VALUES (?, ?, ?) \
- RETURNING id, from_task_id, to_task_id, edge_type, created_at",
- )
- .bind(from_task_id)
- .bind(to_task_id)
- .bind(&edge_type)
- .fetch_one(&mut *conn)
- .await?;
-
- Ok(edge)
-}
-
-pub async fn remove(
- conn: &mut SqliteConnection,
- from_task_id: i64,
- to_task_id: i64,
- edge_type: EdgeType,
-) -> Result<bool, RangerError> {
- let result = sqlx::query(
- "DELETE FROM task_edges WHERE from_task_id = ? AND to_task_id = ? AND edge_type = ?",
- )
- .bind(from_task_id)
- .bind(to_task_id)
- .bind(&edge_type)
- .execute(&mut *conn)
- .await?;
-
- Ok(result.rows_affected() > 0)
-}
-
-pub async fn list_for_task(
- conn: &mut SqliteConnection,
- task_id: i64,
-) -> Result<Vec<TaskEdge>, RangerError> {
- let edges = sqlx::query_as::<_, TaskEdge>(
- "SELECT id, from_task_id, to_task_id, edge_type, created_at \
- FROM task_edges \
- WHERE from_task_id = ? OR to_task_id = ? \
- ORDER BY created_at",
- )
- .bind(task_id)
- .bind(task_id)
- .fetch_all(&mut *conn)
- .await?;
-
- Ok(edges)
-}
-
-pub async fn list_all(conn: &mut SqliteConnection) -> Result<Vec<TaskEdge>, RangerError> {
- let edges = sqlx::query_as::<_, TaskEdge>(
- "SELECT id, from_task_id, to_task_id, edge_type, created_at \
- FROM task_edges \
- ORDER BY created_at",
- )
- .fetch_all(&mut *conn)
- .await?;
-
- Ok(edges)
-}
-
-/// Build a petgraph DiGraph from task edges. Returns the graph and a mapping
-/// from task_id to NodeIndex.
-pub fn build_dag(edges: &[TaskEdge]) -> (DiGraph<i64, EdgeType>, HashMap<i64, NodeIndex>) {
- let mut graph = DiGraph::new();
- let mut node_map: HashMap<i64, NodeIndex> = HashMap::new();
-
- for edge in edges {
- let from_idx = *node_map
- .entry(edge.from_task_id)
- .or_insert_with(|| graph.add_node(edge.from_task_id));
- let to_idx = *node_map
- .entry(edge.to_task_id)
- .or_insert_with(|| graph.add_node(edge.to_task_id));
- graph.add_edge(from_idx, to_idx, edge.edge_type.clone());
- }
-
- (graph, node_map)
-}
-
-/// Check if adding an edge from → to would create a cycle.
-fn check_cycle(
- existing_edges: &[TaskEdge],
- from_task_id: i64,
- to_task_id: i64,
-) -> Result<(), RangerError> {
- let (mut graph, mut node_map) = build_dag(existing_edges);
-
- let from_idx = *node_map
- .entry(from_task_id)
- .or_insert_with(|| graph.add_node(from_task_id));
- let to_idx = *node_map
- .entry(to_task_id)
- .or_insert_with(|| graph.add_node(to_task_id));
-
- graph.add_edge(from_idx, to_idx, EdgeType::Blocks);
-
- if petgraph::algo::is_cyclic_directed(&graph) {
- return Err(RangerError::CycleDetected);
- }
-
- Ok(())
-}
-
-/// Produce a topological sort of task IDs from edges. Tasks with no edges
-/// are not included — callers should merge with the full task list.
-/// Uses task_id as a stable tiebreaker for deterministic ordering.
-pub fn topological_sort(edges: &[TaskEdge]) -> Vec<i64> {
- if edges.is_empty() {
- return Vec::new();
- }
-
- let (graph, node_map) = build_dag(edges);
-
- // petgraph's toposort returns nodes in topological order
- match petgraph::algo::toposort(&graph, None) {
- Ok(sorted) => sorted.iter().map(|idx| graph[*idx]).collect(),
- Err(_) => {
- // cov-excl-start — cycles are prevented on insert; this is defensive
- let mut ids: Vec<i64> = node_map.keys().copied().collect();
- ids.sort();
- ids
- // cov-excl-stop
- }
- }
-}
-
-/// 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::*;
- use crate::models::State;
- use crate::ops::{self, backlog, task};
-
- async fn test_pool() -> sqlx::SqlitePool {
- let dir = tempfile::tempdir().unwrap();
- // Leak the tempdir so it lives for the duration of the test
- let path = dir.path().join("test.db");
- let pool = crate::db::connect(&path).await.unwrap();
- std::mem::forget(dir);
- pool
- }
-
- async fn create_task(conn: &mut SqliteConnection, backlog_id: i64, title: &str) -> i64 {
- task::create(
- conn,
- task::CreateTask {
- title,
- backlog_id,
- state: Some(State::Queued),
- description: None,
- },
- )
- .await
- .unwrap()
- .id
- }
-
- #[tokio::test]
- async fn add_and_list_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 edge = add(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
- assert_eq!(edge.from_task_id, t1);
- assert_eq!(edge.to_task_id, t2);
- assert_eq!(edge.edge_type, EdgeType::Blocks);
-
- let edges = list_for_task(&mut conn, t1).await.unwrap();
- assert_eq!(edges.len(), 1);
-
- let edges = list_for_task(&mut conn, t2).await.unwrap();
- assert_eq!(edges.len(), 1);
- }
-
- #[tokio::test]
- async fn remove_edge() {
- 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::Blocks).await.unwrap();
- let removed = remove(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
- assert!(removed);
-
- let edges = list_for_task(&mut conn, t1).await.unwrap();
- assert!(edges.is_empty());
- }
-
- #[tokio::test]
- async fn remove_nonexistent_edge() {
- 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 removed = remove(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
- assert!(!removed);
- }
-
- #[tokio::test]
- async fn cycle_detection_direct() {
- 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::Blocks).await.unwrap();
- let err = add(&mut conn, t2, t1, EdgeType::Blocks).await.unwrap_err();
- assert!(err.to_string().contains("cycle"));
- }
-
- #[tokio::test]
- async fn cycle_detection_indirect() {
- 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::Blocks).await.unwrap();
- add(&mut conn, t2, t3, EdgeType::Blocks).await.unwrap();
- let err = add(&mut conn, t3, t1, EdgeType::Before).await.unwrap_err();
- assert!(err.to_string().contains("cycle"));
- }
-
- #[tokio::test]
- async fn cycle_detection_self_loop() {
- 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 err = add(&mut conn, t1, t1, EdgeType::Blocks).await.unwrap_err();
- assert!(err.to_string().contains("cycle"));
- }
-
- #[tokio::test]
- 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();
- 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, 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]
- async fn multiple_blocks_edges_allowed() {
- 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::Blocks).await.unwrap();
- add(&mut conn, t1, t3, EdgeType::Blocks).await.unwrap();
-
- let edges = list_for_task(&mut conn, t1).await.unwrap();
- assert_eq!(edges.len(), 2);
- }
-
- #[test]
- fn build_dag_empty() {
- let (graph, node_map) = build_dag(&[]);
- assert_eq!(graph.node_count(), 0);
- assert!(node_map.is_empty());
- }
-
- #[test]
- fn topological_sort_empty() {
- let result = topological_sort(&[]);
- assert!(result.is_empty());
- }
-
- #[tokio::test]
- async fn topological_sort_linear_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();
-
- let edges = list_all(&mut conn).await.unwrap();
- let sorted = topological_sort(&edges);
- assert_eq!(sorted, vec![t1, t2, t3]);
- }
-
- #[tokio::test]
- async fn topological_sort_diamond() {
- 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;
- let t4 = create_task(&mut conn, bl.id, "Task 4").await;
-
- // t1 → t2, t1 → t3, t2 → t4, t3 → t4
- add(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
- add(&mut conn, t1, t3, EdgeType::Blocks).await.unwrap();
- add(&mut conn, t2, t4, EdgeType::Blocks).await.unwrap();
- add(&mut conn, t3, t4, EdgeType::Blocks).await.unwrap();
-
- let edges = list_all(&mut conn).await.unwrap();
- let sorted = topological_sort(&edges);
-
- // t1 must come first, t4 must come last
- assert_eq!(sorted[0], t1);
- assert_eq!(*sorted.last().unwrap(), t4);
- // t2 and t3 must come before t4
- let pos2 = sorted.iter().position(|&id| id == t2).unwrap();
- let pos3 = sorted.iter().position(|&id| id == t3).unwrap();
- let pos4 = sorted.iter().position(|&id| id == t4).unwrap();
- assert!(pos2 < pos4);
- assert!(pos3 < pos4);
- }
-
- #[tokio::test]
- async fn edges_deleted_on_task_delete() {
- 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::Blocks).await.unwrap();
- ops::task::delete(&mut conn, t1).await.unwrap();
-
- let edges = list_all(&mut conn).await.unwrap();
- assert!(edges.is_empty());
- }
-
- #[tokio::test]
- async fn mixed_edge_types_in_dag() {
- 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;
-
- // t1 blocks t2, t2 before t3 — both in same DAG
- add(&mut conn, t1, t2, EdgeType::Blocks).await.unwrap();
- add(&mut conn, t2, t3, EdgeType::Before).await.unwrap();
-
- // t3 → t1 would create a cycle across edge types
- let err = add(&mut conn, t3, t1, EdgeType::Blocks).await.unwrap_err();
- assert!(err.to_string().contains("cycle"));
-
- let edges = list_all(&mut conn).await.unwrap();
- 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/mod.rs b/src/ops/mod.rs
index 2ade7f5..c746aa9 100644
--- a/src/ops/mod.rs
+++ b/src/ops/mod.rs
@@ -1,5 +1,4 @@
pub mod backlog;
pub mod comment;
-pub mod edge;
pub mod tag;
pub mod task;
diff --git a/src/ops/task.rs b/src/ops/task.rs
index b0d1f4c..31c774b 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -1,10 +1,8 @@
use crate::error::RangerError;
use crate::key;
-use crate::models::{Ordering, State, Task};
-use crate::ops::edge;
+use crate::models::{State, Task};
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, tasks.done_at";
@@ -70,7 +68,6 @@ 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 {
""
@@ -86,16 +83,12 @@ pub async fn list(
let is_done_only = filter.state.as_ref() == Some(&State::Done);
let order_clause = if is_done_only {
- // Done tasks order by completion time (most recent last)
" ORDER BY done_at"
} else {
- match ordering {
- Ordering::Position => " ORDER BY position",
- Ordering::Dag => " ORDER BY id",
- }
+ " ORDER BY position"
};
- let mut tasks = if let Some(state) = &filter.state {
+ let 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_clause}"
@@ -120,19 +113,6 @@ 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)
}
@@ -198,7 +178,6 @@ 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 = ?")
@@ -237,7 +216,7 @@ pub async fn edit(
.await?;
if old_state != *new_state {
- reorder(&mut *conn, task_id, &old_state, new_state, ordering).await?;
+ reorder(&mut *conn, task_id, &old_state, new_state).await?;
}
}
@@ -286,7 +265,6 @@ 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 {
@@ -297,17 +275,6 @@ 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 =
@@ -327,50 +294,6 @@ async fn move_task_position(
set_position(&mut *conn, task.id, &new_pos).await
}
-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.
@@ -380,19 +303,6 @@ 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)
@@ -434,76 +344,6 @@ async fn reorder_position(
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(
@@ -631,7 +471,6 @@ 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());
@@ -728,7 +567,7 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks.len(), 3);
@@ -772,7 +611,6 @@ mod tests {
state: Some(State::Icebox),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -786,7 +624,6 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -840,7 +677,6 @@ mod tests {
Some("Updated"),
Some("A description"),
Some(State::Queued),
- Ordering::Position,
)
.await
.unwrap();
@@ -872,7 +708,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(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks.len(), 0);
@@ -974,16 +810,9 @@ mod tests {
.await
.unwrap();
- let updated = edit(
- &mut conn,
- task.id,
- Some("New title"),
- None,
- None,
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, task.id, Some("New title"), None, None)
+ .await
+ .unwrap();
assert_eq!(updated.title, "New title");
assert_eq!(updated.state, State::Icebox);
}
@@ -1017,11 +846,11 @@ mod tests {
.unwrap();
// Move first task after second
- move_task(&mut conn, &t1, Placement::After(&t2), Ordering::Position)
+ move_task(&mut conn, &t1, Placement::After(&t2))
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks[0].id, t2.id);
@@ -1067,11 +896,11 @@ mod tests {
.await
.unwrap();
- move_task(&mut conn, &t3, Placement::Before(&t1), Ordering::Position)
+ move_task(&mut conn, &t3, Placement::Before(&t1))
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks[0].id, t3.id);
@@ -1118,11 +947,11 @@ mod tests {
.await
.unwrap();
- move_task(&mut conn, &t1, Placement::After(&t3), Ordering::Position)
+ move_task(&mut conn, &t1, Placement::After(&t3))
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks[0].id, t2.id);
@@ -1176,12 +1005,11 @@ mod tests {
after: &t1,
before: &t2,
},
- Ordering::Position,
)
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(tasks[0].id, t1.id);
@@ -1217,14 +1045,9 @@ mod tests {
.await
.unwrap();
- let err = move_task(
- &mut conn,
- &queued,
- Placement::Before(&done),
- Ordering::Position,
- )
- .await
- .unwrap_err();
+ let err = move_task(&mut conn, &queued, Placement::Before(&done))
+ .await
+ .unwrap_err();
assert!(err.to_string().contains("queued"));
assert!(err.to_string().contains("done"));
}
@@ -1271,16 +1094,9 @@ 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),
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, q1.id, None, None, Some(State::Done))
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Done);
let done = list(
@@ -1290,7 +1106,6 @@ mod tests {
state: Some(State::Done),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1345,16 +1160,9 @@ 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),
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, ip.id, None, None, Some(State::Queued))
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Queued);
let queued = list(
@@ -1364,7 +1172,6 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1409,16 +1216,9 @@ 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),
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Queued))
+ .await
+ .unwrap();
assert_eq!(updated.position, original_pos);
let queued = list(
@@ -1428,7 +1228,6 @@ mod tests {
state: Some(State::Queued),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1455,16 +1254,9 @@ mod tests {
.unwrap();
// Move to done (empty group) — should succeed
- let updated = edit(
- &mut conn,
- t1.id,
- None,
- None,
- Some(State::Done),
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Done))
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Done);
let done = list(
@@ -1474,7 +1266,6 @@ mod tests {
state: Some(State::Done),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1501,16 +1292,9 @@ mod tests {
.unwrap();
// Move to icebox (empty group) — should succeed
- let updated = edit(
- &mut conn,
- t1.id,
- None,
- None,
- Some(State::Icebox),
- Ordering::Position,
- )
- .await
- .unwrap();
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Icebox))
+ .await
+ .unwrap();
assert_eq!(updated.state, State::Icebox);
let icebox = list(
@@ -1520,7 +1304,6 @@ mod tests {
state: Some(State::Icebox),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1549,18 +1332,17 @@ mod tests {
.unwrap();
}
- let before: Vec<String> =
- list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
- .await
- .unwrap()
- .iter()
- .map(|t| t.position.clone())
- .collect();
+ let before: Vec<String> = list(&mut conn, bl.id, &ListFilter::default())
+ .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(), Ordering::Position)
+ let after = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
let after_positions: Vec<String> = after.iter().map(|t| t.position.clone()).collect();
@@ -1663,7 +1445,7 @@ mod tests {
assert!(archived.archived);
// Default list excludes archived
- let visible = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Position)
+ let visible = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(visible.len(), 1);
@@ -1677,7 +1459,6 @@ mod tests {
include_archived: true,
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1687,7 +1468,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(), Ordering::Position)
+ let visible = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap();
assert_eq!(visible.len(), 2);
@@ -1731,7 +1512,6 @@ mod tests {
tag: Some("bug".to_string()),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();
@@ -1739,846 +1519,103 @@ mod tests {
assert_eq!(results[0].title, "Tagged queued");
}
- // ---- DAG ordering tests ----
+ // ---- done_at tests ----
#[tokio::test]
- async fn dag_list_orders_by_id_with_no_edges() {
+ async fn create_with_done_state_sets_done_at() {
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(
+ let task = create(
&mut conn,
CreateTask {
- title: "C",
+ title: "Done task",
backlog_id: bl.id,
- state: None,
+ state: Some(State::Done),
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);
+ assert!(task.done_at.is_some(), "done task should have done_at set");
}
#[tokio::test]
- async fn dag_list_respects_before_edges() {
+ async fn create_with_non_done_state_has_no_done_at() {
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(
+ let task = create(
&mut conn,
CreateTask {
- title: "C",
+ title: "Queued task",
backlog_id: bl.id,
- state: None,
+ state: Some(State::Queued),
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);
+ assert!(
+ task.done_at.is_none(),
+ "non-done task should not have done_at"
+ );
}
#[tokio::test]
- async fn dag_move_before() {
+ async fn edit_to_done_sets_done_at() {
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(
+ let task = create(
&mut conn,
CreateTask {
- title: "B",
+ title: "Task",
backlog_id: bl.id,
- state: None,
+ state: Some(State::Queued),
description: None,
},
)
.await
.unwrap();
+ assert!(task.done_at.is_none());
- // 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)
+ let updated = edit(&mut conn, task.id, None, None, Some(State::Done))
.await
.unwrap();
- assert_eq!(tasks[0].id, t2.id);
- assert_eq!(tasks[1].id, t1.id);
+ assert!(
+ updated.done_at.is_some(),
+ "should set done_at on transition to done"
+ );
}
#[tokio::test]
- async fn dag_move_after() {
+ async fn edit_from_done_clears_done_at() {
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(
+ let task = create(
&mut conn,
CreateTask {
- title: "B",
+ title: "Task",
backlog_id: bl.id,
- state: None,
+ state: Some(State::Done),
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();
+ assert!(task.done_at.is_some());
- let tasks = list(&mut conn, bl.id, &ListFilter::default(), Ordering::Dag)
+ let updated = edit(&mut conn, task.id, None, None, Some(State::Queued))
.await
.unwrap();
- assert_eq!(tasks[0].id, t1.id);
- assert_eq!(tasks[1].id, t3.id);
- assert_eq!(tasks[2].id, t2.id);
- }
-
- // ---- done_at tests ----
-
- #[tokio::test]
- async fn create_with_done_state_sets_done_at() {
- 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: "Done task",
- backlog_id: bl.id,
- state: Some(State::Done),
- description: None,
- },
- )
- .await
- .unwrap();
-
- assert!(task.done_at.is_some(), "done task should have done_at set");
- }
-
- #[tokio::test]
- async fn create_with_non_done_state_has_no_done_at() {
- 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: "Queued task",
- backlog_id: bl.id,
- state: Some(State::Queued),
- description: None,
- },
- )
- .await
- .unwrap();
-
- assert!(
- task.done_at.is_none(),
- "non-done task should not have done_at"
- );
- }
-
- #[tokio::test]
- async fn edit_to_done_sets_done_at() {
- 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: "Task",
- backlog_id: bl.id,
- state: Some(State::Queued),
- description: None,
- },
- )
- .await
- .unwrap();
- assert!(task.done_at.is_none());
-
- let updated = edit(
- &mut conn,
- task.id,
- None,
- None,
- Some(State::Done),
- Ordering::Position,
- )
- .await
- .unwrap();
- assert!(
- updated.done_at.is_some(),
- "should set done_at on transition to done"
- );
- }
-
- #[tokio::test]
- async fn edit_from_done_clears_done_at() {
- 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: "Task",
- backlog_id: bl.id,
- state: Some(State::Done),
- description: None,
- },
- )
- .await
- .unwrap();
- assert!(task.done_at.is_some());
-
- let updated = edit(
- &mut conn,
- task.id,
- None,
- None,
- Some(State::Queued),
- Ordering::Position,
- )
- .await
- .unwrap();
assert!(
updated.done_at.is_none(),
"should clear done_at on transition away from done"
@@ -2657,7 +1694,6 @@ mod tests {
state: Some(State::Done),
..Default::default()
},
- Ordering::Position,
)
.await
.unwrap();