add CLI commands for task edges
- New 'edge' subcommand (alias 'e') with add, remove, list
- 'edge add <from> <type> <to>' — natural syntax like 'e add abc blocks def'
- 'edge remove' — delete an edge by from/to/type
- 'edge list [task]' — list edges for a task, or all edges
- 'task show' now displays edges with direction arrows
- JSON output includes edges in task show detail
- Tab completion for task keys in edge arguments
- EdgeType parsed directly by clap (no string intermediary)
diff --git a/.gitignore b/.gitignore
index ea8c4bf..6b3e392 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
/target
+*.profraw
diff --git a/AGENTS.md b/AGENTS.md
index bec095a..cc4e24c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,6 +66,7 @@ The integration test (`tests/cli.rs`) exercises the full workflow via the compil
- The `xdg` crate resolves `$XDG_DATA_HOME/ranger/ranger.db`. Override with `RANGER_DB` env var or `--db` flag.
- Backlogs are identified by name, not key. `RANGER_DEFAULT_BACKLOG` sets the default for `--backlog` flags.
- Migration uses `CREATE TABLE IF NOT EXISTS` so it's idempotent (safe to run on every connect).
+- SQLite doesn't support `ALTER TABLE DROP COLUMN` with foreign keys cleanly. When recreating a table, wrap in `PRAGMA foreign_keys = OFF/ON` to prevent `ON DELETE CASCADE` from wiping join tables (e.g. `task_tags`).
## VCS
diff --git a/Cargo.lock b/Cargo.lock
index af0b1ae..bbd646e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -524,6 +524,12 @@ 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"
@@ -1294,6 +1300,18 @@ 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"
@@ -1489,6 +1507,7 @@ dependencies = [
"color-eyre",
"jiff",
"maud",
+ "petgraph",
"predicates",
"rand",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 89c9067..4b62f2c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ 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/009_task_edges.sql b/migrations/009_task_edges.sql
new file mode 100644
index 0000000..89ccc6d
--- /dev/null
+++ b/migrations/009_task_edges.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS task_edges (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ from_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ to_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ edge_type TEXT NOT NULL CHECK (edge_type IN ('blocks', 'before')),
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ UNIQUE(from_task_id, to_task_id, edge_type)
+);
diff --git a/src/bin/ranger/commands/edge.rs b/src/bin/ranger/commands/edge.rs
new file mode 100644
index 0000000..f4fbb8e
--- /dev/null
+++ b/src/bin/ranger/commands/edge.rs
@@ -0,0 +1,106 @@
+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 18d6800..c0fd35b 100644
--- a/src/bin/ranger/commands/mod.rs
+++ b/src/bin/ranger/commands/mod.rs
@@ -1,5 +1,6 @@
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/task.rs b/src/bin/ranger/commands/task.rs
index d34deca..1e4e6f9 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -248,12 +248,14 @@ 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 {
@@ -265,6 +267,22 @@ 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 {
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index 8dacfb8..45f2623 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -42,6 +42,12 @@ 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 {
@@ -101,6 +107,9 @@ 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 17ad532..664d3ed 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -9,6 +9,10 @@ pub enum RangerError {
task_state: String,
anchor_state: 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 b09b601..4e58d5f 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -114,6 +114,74 @@ 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::*;
@@ -133,6 +201,39 @@ 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
new file mode 100644
index 0000000..0c75025
--- /dev/null
+++ b/src/ops/edge.rs
@@ -0,0 +1,406 @@
+use crate::error::RangerError;
+use crate::models::{EdgeType, TaskEdge};
+use petgraph::graph::DiGraph;
+use petgraph::graph::NodeIndex;
+use sqlx::sqlite::SqliteConnection;
+use std::collections::HashMap;
+
+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)?;
+
+ // 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 (?, ?, ?) \
+ 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
+ }
+ }
+}
+
+#[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 duplicate_before_edge_rejected() {
+ 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 err = add(&mut conn, t1, t3, EdgeType::Before).await.unwrap_err();
+ assert!(
+ err.to_string().contains("before"),
+ "expected duplicate before error, got: {err}"
+ );
+ }
+
+ #[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]);
+ }
+}
diff --git a/src/ops/mod.rs b/src/ops/mod.rs
index c746aa9..2ade7f5 100644
--- a/src/ops/mod.rs
+++ b/src/ops/mod.rs
@@ -1,4 +1,5 @@
pub mod backlog;
pub mod comment;
+pub mod edge;
pub mod tag;
pub mod task;