1use crate::ids::WorkspaceId;
2use std::collections::HashSet;
3
4use anyhow::{Result, bail};
5use sqlx::SqliteConnection;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate};
9use crate::ids::{TaskId, now};
10use crate::refs::get_task_in_workspace;
11use crate::undo::{UndoCommand, UndoContext, UndoPayload, record_tui_undo};
12use crate::workspaces::Workspace;
13
14impl Database {
15 pub async fn add_task_dependency(
16 &self,
17 workspace: &Workspace,
18 task_id: &TaskId,
19 depends_on_id: &TaskId,
20 ) -> Result<DependencyOutcome> {
21 self.add_task_dependency_with_undo(workspace, task_id, depends_on_id, UndoContext::None)
22 .await
23 }
24
25 pub async fn add_task_dependency_with_undo(
26 &self,
27 workspace: &Workspace,
28 task_id: &TaskId,
29 depends_on_id: &TaskId,
30 undo: UndoContext,
31 ) -> Result<DependencyOutcome> {
32 let mut conn = self.acquire().await?;
33 let mut tx = begin_immediate(&mut conn).await?;
34 let outcome =
35 add_task_dependency_in_transaction(&mut tx, workspace, task_id, depends_on_id).await?;
36 if outcome.changed
37 && let UndoContext::Tui { summary } = undo
38 {
39 record_tui_undo(
40 &mut tx,
41 &workspace.id,
42 &summary,
43 UndoPayload {
44 commands: vec![UndoCommand::AddTaskDependency {
45 task_id: outcome.task.id.clone(),
46 depends_on_task_id: outcome.depends_on.id.clone(),
47 }],
48 },
49 )
50 .await?;
51 }
52 tx.commit().await?;
53 Ok(outcome)
54 }
55
56 pub async fn remove_task_dependency(
57 &self,
58 workspace: &Workspace,
59 task_id: &TaskId,
60 depends_on_id: &TaskId,
61 ) -> Result<DependencyOutcome> {
62 self.remove_task_dependency_with_undo(workspace, task_id, depends_on_id, UndoContext::None)
63 .await
64 }
65
66 pub async fn remove_task_dependency_with_undo(
67 &self,
68 workspace: &Workspace,
69 task_id: &TaskId,
70 depends_on_id: &TaskId,
71 undo: UndoContext,
72 ) -> Result<DependencyOutcome> {
73 let mut conn = self.acquire().await?;
74 let mut tx = begin_immediate(&mut conn).await?;
75 let outcome =
76 remove_task_dependency_in_transaction(&mut tx, workspace, task_id, depends_on_id)
77 .await?;
78 if outcome.changed
79 && let UndoContext::Tui { summary } = undo
80 {
81 record_tui_undo(
82 &mut tx,
83 &workspace.id,
84 &summary,
85 UndoPayload {
86 commands: vec![UndoCommand::RemoveTaskDependency {
87 task_id: outcome.task.id.clone(),
88 depends_on_task_id: outcome.depends_on.id.clone(),
89 }],
90 },
91 )
92 .await?;
93 }
94 tx.commit().await?;
95 Ok(outcome)
96 }
97}
98
99pub struct DependencyOutcome {
100 pub task: crate::types::Task,
101 pub depends_on: crate::types::Task,
102 pub changed: bool,
103}
104
105struct DependencyPair {
106 task: crate::types::Task,
107 depends_on: crate::types::Task,
108}
109
110async fn load_dependency_pair(
111 conn: &mut SqliteConnection,
112 workspace: &Workspace,
113 task_id: &crate::ids::TaskId,
114 depends_on_id: &crate::ids::TaskId,
115) -> Result<DependencyPair> {
116 if task_id == depends_on_id {
117 bail!("error dependency-self task_id={task_id}");
118 }
119
120 let task = get_task_in_workspace(conn, workspace, task_id).await?;
121 let depends_on = get_task_in_workspace(conn, workspace, depends_on_id).await?;
122
123 Ok(DependencyPair { task, depends_on })
124}
125
126async fn record_dependency_change(
127 conn: &mut SqliteConnection,
128 workspace: &Workspace,
129 pair: &DependencyPair,
130 op_type: &'static str,
131) -> Result<()> {
132 append_change(
133 conn,
134 ChangeEntity::Task,
135 &pair.task.id,
136 Some("dependencies"),
137 op_type,
138 ChangePayload::workspace(workspace).set("depends_on_task_id", pair.depends_on.id.clone()),
139 )
140 .await?;
141 Ok(())
142}
143
144#[cfg(any(test, feature = "test-support"))]
145pub async fn add_task_dependency(
146 conn: &mut SqliteConnection,
147 workspace: &Workspace,
148 task_id: &crate::ids::TaskId,
149 depends_on_id: &crate::ids::TaskId,
150) -> Result<DependencyOutcome> {
151 let mut tx = begin_immediate(conn).await?;
152 let outcome =
153 add_task_dependency_in_transaction(&mut tx, workspace, task_id, depends_on_id).await?;
154 tx.commit().await?;
155 Ok(outcome)
156}
157
158async fn add_task_dependency_in_transaction(
159 conn: &mut SqliteConnection,
160 workspace: &Workspace,
161 task_id: &crate::ids::TaskId,
162 depends_on_id: &crate::ids::TaskId,
163) -> Result<DependencyOutcome> {
164 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
165
166 if dependency_path_exists(
167 conn,
168 &pair.task.workspace_id,
169 &pair.depends_on.id,
170 &pair.task.id,
171 )
172 .await?
173 {
174 bail!("error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_id}");
175 }
176
177 let created_at = now();
178 let changed = sqlx::query(
179 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
180 VALUES (?, ?, ?, ?)",
181 )
182 .bind(&pair.task.workspace_id)
183 .bind(&pair.task.id)
184 .bind(&pair.depends_on.id)
185 .bind(&created_at)
186 .execute(&mut *conn)
187 .await?
188 .rows_affected()
189 > 0;
190
191 if changed {
192 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_ADD).await?;
193 }
194
195 Ok(DependencyOutcome {
196 task: pair.task,
197 depends_on: pair.depends_on,
198 changed,
199 })
200}
201
202async fn remove_task_dependency_in_transaction(
203 conn: &mut SqliteConnection,
204 workspace: &Workspace,
205 task_id: &crate::ids::TaskId,
206 depends_on_id: &crate::ids::TaskId,
207) -> Result<DependencyOutcome> {
208 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
209
210 let changed = sqlx::query(
211 "DELETE FROM task_dependencies
212 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
213 )
214 .bind(&pair.task.workspace_id)
215 .bind(&pair.task.id)
216 .bind(&pair.depends_on.id)
217 .execute(&mut *conn)
218 .await?
219 .rows_affected()
220 > 0;
221
222 if changed {
223 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_REMOVE).await?;
224 }
225
226 Ok(DependencyOutcome {
227 task: pair.task,
228 depends_on: pair.depends_on,
229 changed,
230 })
231}
232
233pub async fn dependency_path_exists(
234 conn: &mut SqliteConnection,
235 workspace_id: &WorkspaceId,
236 from_task_id: &crate::ids::TaskId,
237 to_task_id: &crate::ids::TaskId,
238) -> Result<bool> {
239 let mut visited = HashSet::new();
240 let mut stack = vec![from_task_id.clone()];
241 while let Some(current) = stack.pop() {
242 if !visited.insert(current.clone()) {
243 continue;
244 }
245 if ¤t == to_task_id {
246 return Ok(true);
247 }
248 let next = sqlx::query_scalar::<_, crate::ids::TaskId>(
249 "SELECT depends_on_task_id
250 FROM task_dependencies
251 WHERE workspace_id = ? AND task_id = ?",
252 )
253 .bind(workspace_id)
254 .bind(¤t)
255 .fetch_all(&mut *conn)
256 .await?;
257 stack.extend(next);
258 }
259 Ok(false)
260}