Skip to main content

aven_core/operations/
dependencies.rs

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 &current == 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(&current)
275        .fetch_all(&mut *conn)
276        .await?;
277        stack.extend(next);
278    }
279    Ok(false)
280}