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)
change nyklvmzywzxkzsktpznutwvvrkwtwovo
commit 22331980d8a676d1cd0c28e2cb47d8eca4ccc7c0
author Alpha Chen <alpha@kejadlen.dev>
date
parent uksvxvoo
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;