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