Skip to main content

aven_core/operations/
dependencies.rs

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 &current == 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(&current)
288        .fetch_all(&mut *conn)
289        .await?;
290        stack.extend(next);
291    }
292    Ok(false)
293}