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_writer().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_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 { 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 crate::operations::route_recurrence_task_field(conn, workspace, task_id, "dependencies", "")
165 .await?;
166 crate::operations::route_recurrence_task_field(
167 conn,
168 workspace,
169 depends_on_id,
170 "dependencies",
171 "",
172 )
173 .await?;
174 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
175
176 if dependency_path_exists(
177 conn,
178 &pair.task.workspace_id,
179 &pair.depends_on.id,
180 &pair.task.id,
181 )
182 .await?
183 {
184 bail!("error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_id}");
185 }
186
187 let created_at = now();
188 let changed = sqlx::query(
189 "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
190 VALUES (?, ?, ?, ?)",
191 )
192 .bind(&pair.task.workspace_id)
193 .bind(&pair.task.id)
194 .bind(&pair.depends_on.id)
195 .bind(&created_at)
196 .execute(&mut *conn)
197 .await?
198 .rows_affected()
199 > 0;
200
201 if changed {
202 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_ADD).await?;
203 }
204
205 Ok(DependencyOutcome {
206 task: pair.task,
207 depends_on: pair.depends_on,
208 changed,
209 })
210}
211
212async fn remove_task_dependency_in_transaction(
213 conn: &mut SqliteConnection,
214 workspace: &Workspace,
215 task_id: &crate::ids::TaskId,
216 depends_on_id: &crate::ids::TaskId,
217) -> Result<DependencyOutcome> {
218 crate::operations::route_recurrence_task_field(conn, workspace, task_id, "dependencies", "")
219 .await?;
220 crate::operations::route_recurrence_task_field(
221 conn,
222 workspace,
223 depends_on_id,
224 "dependencies",
225 "",
226 )
227 .await?;
228 let pair = load_dependency_pair(conn, workspace, task_id, depends_on_id).await?;
229
230 let changed = sqlx::query(
231 "DELETE FROM task_dependencies
232 WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
233 )
234 .bind(&pair.task.workspace_id)
235 .bind(&pair.task.id)
236 .bind(&pair.depends_on.id)
237 .execute(&mut *conn)
238 .await?
239 .rows_affected()
240 > 0;
241
242 if changed {
243 record_dependency_change(conn, workspace, &pair, op_type::DEPENDENCY_REMOVE).await?;
244 }
245
246 Ok(DependencyOutcome {
247 task: pair.task,
248 depends_on: pair.depends_on,
249 changed,
250 })
251}
252
253pub async fn dependency_path_exists(
254 conn: &mut SqliteConnection,
255 workspace_id: &WorkspaceId,
256 from_task_id: &crate::ids::TaskId,
257 to_task_id: &crate::ids::TaskId,
258) -> Result<bool> {
259 let mut visited = HashSet::new();
260 let mut stack = vec![from_task_id.clone()];
261 while let Some(current) = stack.pop() {
262 if !visited.insert(current.clone()) {
263 continue;
264 }
265 if ¤t == to_task_id {
266 return Ok(true);
267 }
268 let next = sqlx::query_scalar::<_, crate::ids::TaskId>(
269 "SELECT depends_on_task_id
270 FROM task_dependencies
271 WHERE workspace_id = ? AND task_id = ?",
272 )
273 .bind(workspace_id)
274 .bind(¤t)
275 .fetch_all(&mut *conn)
276 .await?;
277 stack.extend(next);
278 }
279 Ok(false)
280}