@@ -1,4 +1,4 @@
-use anyhow::Result;
+use anyhow::{bail, Result};
use std::path::Path;
use crate::cli::DepAction;
@@ -9,6 +9,9 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
match action {
DepAction::Add { child, parent } => {
+ if db::would_cycle(&conn, parent, child)? {
+ bail!("adding dependency would create a cycle: {child} → {parent} → … → {child}");
+ }
conn.execute(
"INSERT OR IGNORE INTO blockers (task_id, blocker_id) VALUES (?1, ?2)",
[child, parent],
@@ -132,6 +132,44 @@ pub fn load_blockers(conn: &Connection, task_id: &str) -> Result<Vec<String>> {
Ok(blockers)
}
+/// Check whether `from` can reach `to` by following blocker edges.
+///
+/// Returns `true` if there is a path from `from` to `to` in the blocker
+/// graph (i.e. adding an edge `to → from` would create a cycle).
+/// Uses a visited-set so it terminates even if the graph already contains
+/// a cycle from bad data.
+pub fn would_cycle(conn: &Connection, from: &str, to: &str) -> Result<bool> {
+ use std::collections::{HashSet, VecDeque};
+
+ if from == to {
+ return Ok(true);
+ }
+
+ let mut visited = HashSet::new();
+ let mut queue = VecDeque::new();
+ queue.push_back(from.to_string());
+ visited.insert(from.to_string());
+
+ let mut stmt = conn.prepare("SELECT blocker_id FROM blockers WHERE task_id = ?1")?;
+
+ while let Some(current) = queue.pop_front() {
+ let neighbors: Vec<String> = stmt
+ .query_map([¤t], |r| r.get(0))?
+ .collect::<rusqlite::Result<_>>()?;
+
+ for neighbor in neighbors {
+ if neighbor == to {
+ return Ok(true);
+ }
+ if visited.insert(neighbor.clone()) {
+ queue.push_back(neighbor);
+ }
+ }
+ }
+
+ Ok(false)
+}
+
/// Load a full task with labels and blockers.
pub fn load_task_detail(conn: &Connection, id: &str) -> Result<TaskDetail> {
let task = conn.query_row(
@@ -90,3 +90,92 @@ fn dep_tree_shows_children() {
.stdout(predicate::str::contains(".1"))
.stdout(predicate::str::contains(".2"));
}
+
+#[test]
+fn dep_add_rejects_self_cycle() {
+ let tmp = init_tmp();
+ let a = create_task(&tmp, "Self-referential");
+
+ td().args(["dep", "add", &a, &a])
+ .current_dir(&tmp)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("cycle"));
+}
+
+#[test]
+fn dep_add_rejects_direct_cycle() {
+ let tmp = init_tmp();
+ let a = create_task(&tmp, "Task A");
+ let b = create_task(&tmp, "Task B");
+
+ // A blocked by B
+ td().args(["dep", "add", &a, &b])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+
+ // B blocked by A would create A → B → A
+ td().args(["dep", "add", &b, &a])
+ .current_dir(&tmp)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("cycle"));
+}
+
+#[test]
+fn dep_add_rejects_transitive_cycle() {
+ let tmp = init_tmp();
+ let a = create_task(&tmp, "Task A");
+ let b = create_task(&tmp, "Task B");
+ let c = create_task(&tmp, "Task C");
+
+ // A blocked by B, B blocked by C
+ td().args(["dep", "add", &a, &b])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+ td().args(["dep", "add", &b, &c])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+
+ // C blocked by A would create A → B → C → A
+ td().args(["dep", "add", &c, &a])
+ .current_dir(&tmp)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("cycle"));
+}
+
+#[test]
+fn dep_add_allows_diamond_without_cycle() {
+ let tmp = init_tmp();
+ let a = create_task(&tmp, "Task A");
+ let b = create_task(&tmp, "Task B");
+ let c = create_task(&tmp, "Task C");
+ let d = create_task(&tmp, "Task D");
+
+ // Diamond: D blocked by B and C, both blocked by A
+ td().args(["dep", "add", &d, &b])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+ td().args(["dep", "add", &d, &c])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+ td().args(["dep", "add", &b, &a])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+ td().args(["dep", "add", &c, &a])
+ .current_dir(&tmp)
+ .assert()
+ .success();
+
+ // Verify all edges exist — no false cycle detection
+ let t = get_task_json(&tmp, &d);
+ let blockers = t["blockers"].as_array().unwrap();
+ assert_eq!(blockers.len(), 2);
+}