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