use crate::db::models::Dependency;
use crate::error::{IntentError, Result};
use sqlx::SqlitePool;
pub async fn check_circular_dependency(
pool: &SqlitePool,
blocking_task_id: i64,
blocked_task_id: i64,
) -> Result<bool> {
if blocking_task_id == blocked_task_id {
return Ok(true);
}
let has_cycle: bool = sqlx::query_scalar(
r#"
WITH RECURSIVE dep_chain(task_id, depth) AS (
-- Start from the NEW blocking task
SELECT ? as task_id, 0 as depth
UNION ALL
-- Follow what each task depends on (its blocking tasks)
SELECT d.blocking_task_id, dc.depth + 1
FROM dependencies d
JOIN dep_chain dc ON d.blocked_task_id = dc.task_id
WHERE dc.depth < 100
)
SELECT COUNT(*) > 0
FROM dep_chain
WHERE task_id = ?
"#,
)
.bind(blocking_task_id)
.bind(blocked_task_id)
.fetch_one(pool)
.await?;
Ok(has_cycle)
}
pub async fn add_dependency(
pool: &SqlitePool,
blocking_task_id: i64,
blocked_task_id: i64,
) -> Result<Dependency> {
let blocking_exists: bool =
sqlx::query_scalar::<_, bool>("SELECT COUNT(*) > 0 FROM tasks WHERE id = ?")
.bind(blocking_task_id)
.fetch_one(pool)
.await?;
if !blocking_exists {
return Err(IntentError::TaskNotFound(blocking_task_id));
}
let blocked_exists: bool =
sqlx::query_scalar::<_, bool>("SELECT COUNT(*) > 0 FROM tasks WHERE id = ?")
.bind(blocked_task_id)
.fetch_one(pool)
.await?;
if !blocked_exists {
return Err(IntentError::TaskNotFound(blocked_task_id));
}
if check_circular_dependency(pool, blocking_task_id, blocked_task_id).await? {
return Err(IntentError::CircularDependency {
blocking_task_id,
blocked_task_id,
});
}
let result = sqlx::query(
r#"
INSERT INTO dependencies (blocking_task_id, blocked_task_id)
VALUES (?, ?)
"#,
)
.bind(blocking_task_id)
.bind(blocked_task_id)
.execute(pool)
.await?;
let dependency_id = result.last_insert_rowid();
let dependency = sqlx::query_as::<_, Dependency>(
"SELECT id, blocking_task_id, blocked_task_id, created_at FROM dependencies WHERE id = ?",
)
.bind(dependency_id)
.fetch_one(pool)
.await?;
Ok(dependency)
}
pub async fn get_incomplete_blocking_tasks(
pool: &SqlitePool,
task_id: i64,
) -> Result<Option<Vec<i64>>> {
let incomplete_blocking: Vec<i64> = sqlx::query_scalar::<_, i64>(
r#"
SELECT d.blocking_task_id
FROM dependencies d
JOIN tasks t ON t.id = d.blocking_task_id
WHERE d.blocked_task_id = ?
AND t.status IN ('todo', 'doing')
"#,
)
.bind(task_id)
.fetch_all(pool)
.await?;
if incomplete_blocking.is_empty() {
Ok(None)
} else {
Ok(Some(incomplete_blocking))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{create_pool, run_migrations};
use tempfile::TempDir;
async fn setup_test_db() -> (TempDir, SqlitePool) {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let pool = create_pool(&db_path).await.unwrap();
run_migrations(&pool).await.unwrap();
(temp_dir, pool)
}
async fn create_test_task(pool: &SqlitePool, name: &str) -> i64 {
sqlx::query("INSERT INTO tasks (name, status) VALUES (?, 'todo')")
.bind(name)
.execute(pool)
.await
.unwrap()
.last_insert_rowid()
}
#[tokio::test]
async fn test_check_circular_dependency_self() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let is_circular = check_circular_dependency(&pool, task_a, task_a)
.await
.unwrap();
assert!(is_circular);
}
#[tokio::test]
async fn test_check_circular_dependency_direct_cycle() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_b)
.bind(task_a)
.execute(&pool)
.await
.unwrap();
let is_circular = check_circular_dependency(&pool, task_a, task_b)
.await
.unwrap();
assert!(is_circular);
}
#[tokio::test]
async fn test_check_circular_dependency_transitive_cycle() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
let task_c = create_test_task(&pool, "Task C").await;
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_b)
.bind(task_a)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_c)
.bind(task_b)
.execute(&pool)
.await
.unwrap();
let is_circular = check_circular_dependency(&pool, task_a, task_c)
.await
.unwrap();
assert!(is_circular);
}
#[tokio::test]
async fn test_check_circular_dependency_no_cycle() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
let task_c = create_test_task(&pool, "Task C").await;
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_b)
.bind(task_a)
.execute(&pool)
.await
.unwrap();
let is_circular = check_circular_dependency(&pool, task_a, task_c)
.await
.unwrap();
assert!(!is_circular);
}
#[tokio::test]
async fn test_check_circular_dependency_deep_chain() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
let task_c = create_test_task(&pool, "Task C").await;
let task_d = create_test_task(&pool, "Task D").await;
let task_e = create_test_task(&pool, "Task E").await;
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_b)
.bind(task_a)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_c)
.bind(task_b)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_d)
.bind(task_c)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO dependencies (blocking_task_id, blocked_task_id) VALUES (?, ?)")
.bind(task_e)
.bind(task_d)
.execute(&pool)
.await
.unwrap();
let is_circular = check_circular_dependency(&pool, task_a, task_e)
.await
.unwrap();
assert!(is_circular);
}
#[tokio::test]
async fn test_add_dependency_success() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
let dep = add_dependency(&pool, task_b, task_a).await.unwrap();
assert_eq!(dep.blocking_task_id, task_b);
assert_eq!(dep.blocked_task_id, task_a);
}
#[tokio::test]
async fn test_add_dependency_circular_error() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
add_dependency(&pool, task_b, task_a).await.unwrap();
let result = add_dependency(&pool, task_a, task_b).await;
assert!(matches!(
result,
Err(IntentError::CircularDependency { .. })
));
}
#[tokio::test]
async fn test_add_dependency_task_not_found() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let result = add_dependency(&pool, 9999, task_a).await;
assert!(matches!(result, Err(IntentError::TaskNotFound(9999))));
}
#[tokio::test]
async fn test_get_incomplete_blocking_tasks_blocked() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
add_dependency(&pool, task_b, task_a).await.unwrap();
let incomplete = get_incomplete_blocking_tasks(&pool, task_a).await.unwrap();
assert!(incomplete.is_some());
assert_eq!(incomplete.unwrap(), vec![task_b]);
}
#[tokio::test]
async fn test_get_incomplete_blocking_tasks_not_blocked() {
let (_temp, pool) = setup_test_db().await;
let task_a = create_test_task(&pool, "Task A").await;
let task_b = create_test_task(&pool, "Task B").await;
add_dependency(&pool, task_b, task_a).await.unwrap();
sqlx::query("UPDATE tasks SET status = 'done' WHERE id = ?")
.bind(task_b)
.execute(&pool)
.await
.unwrap();
let incomplete = get_incomplete_blocking_tasks(&pool, task_a).await.unwrap();
assert!(incomplete.is_none());
}
}