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 = undo
39 {
40 record_tui_undo(
41 &mut tx,
42 &workspace.id,
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_writer().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 = undo
80 {
81 record_tui_undo(
82 &mut tx,
83 &workspace.id,
84 UndoPayload {
85 commands: vec![UndoCommand::RemoveTaskDependency {
86 task_id: outcome.task.id.clone(),
87 depends_on_task_id: outcome.depends_on.id.clone(),
88 }],
89 },
90 )
91 .await?;
92 }
93 tx.commit().await?;
94 Ok(outcome)
95 }
96}
97
98pub struct DependencyOutcome {
99 pub task: crate::types::Task,
100 pub depends_on: crate::types::Task,
101 pub changed: bool,
102}
103
104struct DependencyPair {
105 task: crate::types::Task,
106 depends_on: crate::types::Task,
107}
108
109async fn load_dependency_pair(
110 conn: &mut SqliteConnection,
111 workspace: &Workspace,
112 task_id: &crate::ids::TaskId,
113 depends_on_id: &crate::ids::TaskId,
114) -> Result<DependencyPair> {
115 if task_id == depends_on_id {
116 bail!("error dependency-self task_id={task_id}");
117 }
118
119 let task = get_task_in_workspace(conn, workspace, task_id).await?;
120 let depends_on = get_task_in_workspace(conn, workspace, depends_on_id).await?;
121
122 Ok(DependencyPair { task, depends_on })
123}
124
125async fn record_dependency_change(
126 conn: &mut SqliteConnection,
127 workspace: &Workspace,
128 pair: &DependencyPair,
129 op_type: &'static str,
130) -> Result<()> {
131 append_change(
132 conn,
133 ChangeEntity::Task,
134 &pair.task.id,
135 Some("dependencies"),
136 op_type,
137 ChangePayload::workspace(workspace).set("depends_on_task_id", pair.depends_on.id.clone()),
138 )
139 .await?;
140 Ok(())
141}
142
143#[cfg(any(test, feature = "test-support"))]
144pub async fn add_task_dependency(
145 conn: &mut SqliteConnection,
146 workspace: &Workspace,
147 task_id: &crate::ids::TaskId,
148 depends_on_id: &crate::ids::TaskId,
149) -> Result<DependencyOutcome> {
150 let mut tx = begin_immediate(conn).await?;
151 let outcome =
152 add_task_dependency_in_transaction(&mut tx, workspace, task_id, depends_on_id).await?;
153 tx.commit().await?;
154 Ok(outcome)
155}
156
157async fn add_task_dependency_in_transaction(
158 conn: &mut SqliteConnection,
159 workspace: &Workspace,
160 task_id: &crate::ids::TaskId,
161 depends_on_id: &crate::ids::TaskId,
162) -> Result<DependencyOutcome> {
163 let created_at = now();
164 crate::operations::route_recurrence_task_mutation(
165 conn,
166 workspace,
167 task_id,
168 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
169 &created_at,
170 )
171 .await?;
172 crate::operations::route_recurrence_task_mutation(
173 conn,
174 workspace,
175 depends_on_id,
176 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
177 &created_at,
178 )
179 .await?;
180 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
181
182 if dependency_path_exists(
183 conn,
184 &pair.task.workspace_id,
185 &pair.depends_on.id,
186 &pair.task.id,
187 )
188 .await?
189 {
190 bail!("error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_id}");
191 }
192 let changed = sqlx::query(
193 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
194 VALUES (?, ?, ?, ?)",
195 )
196 .bind(&pair.task.workspace_id)
197 .bind(&pair.task.id)
198 .bind(&pair.depends_on.id)
199 .bind(&created_at)
200 .execute(&mut *conn)
201 .await?
202 .rows_affected()
203 > 0;
204
205 if changed {
206 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_ADD).await?;
207 }
208
209 Ok(DependencyOutcome {
210 task: pair.task,
211 depends_on: pair.depends_on,
212 changed,
213 })
214}
215
216async fn remove_task_dependency_in_transaction(
217 conn: &mut SqliteConnection,
218 workspace: &Workspace,
219 task_id: &crate::ids::TaskId,
220 depends_on_id: &crate::ids::TaskId,
221) -> Result<DependencyOutcome> {
222 let mutation_at = now();
223 crate::operations::route_recurrence_task_mutation(
224 conn,
225 workspace,
226 task_id,
227 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
228 &mutation_at,
229 )
230 .await?;
231 crate::operations::route_recurrence_task_mutation(
232 conn,
233 workspace,
234 depends_on_id,
235 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
236 &mutation_at,
237 )
238 .await?;
239 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
240
241 let changed = sqlx::query(
242 "DELETE FROM task_dependencies
243 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
244 )
245 .bind(&pair.task.workspace_id)
246 .bind(&pair.task.id)
247 .bind(&pair.depends_on.id)
248 .execute(&mut *conn)
249 .await?
250 .rows_affected()
251 > 0;
252
253 if changed {
254 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_REMOVE).await?;
255 }
256
257 Ok(DependencyOutcome {
258 task: pair.task,
259 depends_on: pair.depends_on,
260 changed,
261 })
262}
263
264pub async fn dependency_path_exists(
265 conn: &mut SqliteConnection,
266 workspace_id: &WorkspaceId,
267 from_task_id: &crate::ids::TaskId,
268 to_task_id: &crate::ids::TaskId,
269) -> Result<bool> {
270 let mut visited = HashSet::new();
271 let mut stack = vec![from_task_id.clone()];
272 while let Some(current) = stack.pop() {
273 if !visited.insert(current.clone()) {
274 continue;
275 }
276 if ¤t == to_task_id {
277 return Ok(true);
278 }
279 let next = sqlx::query_scalar::<_, crate::ids::TaskId>(
280 "SELECT depends_on_task_id
281 FROM task_dependencies
282 WHERE workspace_id = ? AND task_id = ?",
283 )
284 .bind(workspace_id)
285 .bind(¤t)
286 .fetch_all(&mut *conn)
287 .await?;
288 stack.extend(next);
289 }
290 Ok(false)
291}