Skip to main content

aven_core/operations/
conflicts.rs

1use anyhow::{Result, bail};
2use sqlx::{Row, SqliteConnection};
3use tracing::info;
4
5use crate::change_log::op_type;
6use crate::db::{Database, begin_immediate, insert_change, set_field_version};
7use crate::ids::TaskId;
8use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
9use crate::projects::{resolve_existing_project_in_workspace, resolve_project_for_stored_value};
10use crate::refs::get_task_in_workspace;
11use crate::task_fields::TaskField;
12use crate::types::Task;
13use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
14use crate::workspaces::Workspace;
15
16impl Database {
17    pub async fn list_conflicts(
18        &self,
19        workspace: &Workspace,
20        project_key: Option<&str>,
21        field: Option<&str>,
22    ) -> Result<Vec<ConflictListItem>> {
23        let mut conn = self.acquire().await?;
24        list_conflicts(&mut conn, workspace, project_key, field).await
25    }
26
27    pub async fn task_conflicts(
28        &self,
29        workspace: &Workspace,
30        task_id: &TaskId,
31        field: Option<&str>,
32    ) -> Result<Vec<ConflictDetail>> {
33        let mut conn = self.acquire().await?;
34        task_conflicts(&mut conn, workspace, task_id, field).await
35    }
36
37    pub async fn conflict_variant_value(
38        &self,
39        workspace: &Workspace,
40        task_id: &TaskId,
41        field: &str,
42        token: &str,
43    ) -> Result<String> {
44        let mut conn = self.acquire().await?;
45        conflict_variant_value(&mut conn, workspace, task_id, field, token).await
46    }
47
48    pub async fn resolve_conflict_with_tui_undo(
49        &self,
50        workspace: &Workspace,
51        task_id: &TaskId,
52        field: &str,
53        value: &str,
54        summary: &str,
55    ) -> Result<ConflictResolutionOutcome> {
56        let mut conn = self.acquire().await?;
57        resolve_conflict_value(
58            &mut conn,
59            workspace,
60            task_id,
61            field,
62            ResolutionValue::Explicit(value),
63            Some(summary),
64        )
65        .await
66    }
67
68    pub async fn resolve_conflict(
69        &self,
70        workspace: &Workspace,
71        task_id: &TaskId,
72        field: &str,
73        value: &str,
74    ) -> Result<ConflictOutcome> {
75        let mut conn = self.acquire().await?;
76        resolve_conflict(&mut conn, workspace, task_id, field, value).await
77    }
78}
79
80pub struct ConflictListItem {
81    pub task_id: TaskId,
82    pub title: String,
83    pub project_key: String,
84    pub project_prefix: String,
85    pub field: String,
86    pub variant_a: String,
87    pub variant_b: String,
88}
89
90pub struct ConflictDetail {
91    pub field: String,
92    pub variant_a: String,
93    pub local_value: String,
94    pub variant_b: String,
95    pub remote_value: String,
96}
97
98pub struct ConflictOutcome {
99    pub task: Task,
100    pub field: String,
101}
102
103pub struct ConflictResolutionOutcome {
104    pub outcome: ConflictOutcome,
105    pub before: String,
106    pub after: String,
107    pub conflict_id: i64,
108}
109pub async fn list_conflicts(
110    conn: &mut SqliteConnection,
111    workspace: &Workspace,
112    project_key: Option<&str>,
113    field: Option<&str>,
114) -> Result<Vec<ConflictListItem>> {
115    let workspace_id = &workspace.id;
116    let project_id = if let Some(project) = project_key {
117        Some(
118            resolve_existing_project_in_workspace(conn, workspace_id, project)
119                .await?
120                .id,
121        )
122    } else {
123        None
124    };
125    let rows = sqlx::query(
126        r#"SELECT c.task_id, c.field, c.variant_a, c.variant_b,
127                 t.title, p.prefix, p.key AS project_key
128                 FROM conflicts c
129                 JOIN tasks t ON t.workspace_id = c.workspace_id AND t.id = c.task_id
130                 JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
131                 WHERE c.workspace_id = ? AND c.resolved = 0
132                 AND (? IS NULL OR t.project_id = ?)
133                 AND (? IS NULL OR c.field = ?)
134                 ORDER BY c.created_at"#,
135    )
136    .bind(workspace_id)
137    .bind(&project_id)
138    .bind(&project_id)
139    .bind(field)
140    .bind(field)
141    .fetch_all(&mut *conn)
142    .await?;
143    Ok(rows
144        .into_iter()
145        .map(|row| ConflictListItem {
146            task_id: row.get("task_id"),
147            title: row.get("title"),
148            project_key: row.get("project_key"),
149            project_prefix: row.get("prefix"),
150            field: row.get("field"),
151            variant_a: row.get("variant_a"),
152            variant_b: row.get("variant_b"),
153        })
154        .collect())
155}
156
157pub async fn task_conflicts(
158    conn: &mut SqliteConnection,
159    workspace: &Workspace,
160    task_id: &crate::ids::TaskId,
161    field: Option<&str>,
162) -> Result<Vec<ConflictDetail>> {
163    let workspace_id = &workspace.id;
164    let rows = sqlx::query(
165        r#"SELECT field, variant_a, local_value, variant_b, remote_value
166         FROM conflicts
167         WHERE workspace_id = ? AND task_id = ? AND resolved = 0 AND (? IS NULL OR field = ?)
168         ORDER BY field, id"#,
169    )
170    .bind(workspace_id)
171    .bind(task_id)
172    .bind(field)
173    .bind(field)
174    .fetch_all(&mut *conn)
175    .await?;
176    Ok(rows
177        .into_iter()
178        .map(|row| ConflictDetail {
179            field: row.get("field"),
180            variant_a: row.get("variant_a"),
181            local_value: row.get("local_value"),
182            variant_b: row.get("variant_b"),
183            remote_value: row.get("remote_value"),
184        })
185        .collect())
186}
187
188pub async fn conflict_variant_value(
189    conn: &mut SqliteConnection,
190    workspace: &Workspace,
191    task_id: &crate::ids::TaskId,
192    field: &str,
193    token: &str,
194) -> Result<String> {
195    for detail in task_conflicts(conn, workspace, task_id, Some(field)).await? {
196        if token == detail.variant_a {
197            return Ok(detail.local_value);
198        }
199        if token == detail.variant_b {
200            return Ok(detail.remote_value);
201        }
202    }
203    bail!("error unknown-variant token={token}")
204}
205
206pub(crate) enum ConflictValueChoice {
207    Local,
208    Remote,
209}
210
211#[derive(Debug)]
212pub(crate) struct ConflictNotFoundError {
213    task_id: TaskId,
214    field: &'static str,
215}
216
217impl std::fmt::Display for ConflictNotFoundError {
218    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(
220            formatter,
221            "error conflict-not-found task_id={} field={}",
222            self.task_id, self.field
223        )
224    }
225}
226
227impl std::error::Error for ConflictNotFoundError {}
228
229pub async fn resolve_conflict(
230    conn: &mut SqliteConnection,
231    workspace: &Workspace,
232    task_id: &crate::ids::TaskId,
233    field: &str,
234    value: &str,
235) -> Result<ConflictOutcome> {
236    Ok(resolve_conflict_value(
237        conn,
238        workspace,
239        task_id,
240        field,
241        ResolutionValue::Explicit(value),
242        None,
243    )
244    .await?
245    .outcome)
246}
247
248pub(crate) async fn resolve_conflict_choice(
249    conn: &mut SqliteConnection,
250    workspace: &Workspace,
251    task_id: &crate::ids::TaskId,
252    field: &str,
253    choice: ConflictValueChoice,
254) -> Result<ConflictOutcome> {
255    Ok(resolve_conflict_value(
256        conn,
257        workspace,
258        task_id,
259        field,
260        ResolutionValue::Choice(choice),
261        None,
262    )
263    .await?
264    .outcome)
265}
266
267enum ResolutionValue<'a> {
268    Explicit(&'a str),
269    Choice(ConflictValueChoice),
270}
271
272async fn resolve_conflict_value(
273    conn: &mut SqliteConnection,
274    workspace: &Workspace,
275    task_id: &crate::ids::TaskId,
276    field: &str,
277    resolution: ResolutionValue<'_>,
278    tui_summary: Option<&str>,
279) -> Result<ConflictResolutionOutcome> {
280    let task_field = TaskField::parse_or_unknown(field)?;
281    let field = task_field.as_str();
282    let mut tx = begin_immediate(conn).await?;
283    let before = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
284    let conflict_id = crate::undo::conflict_row_id(&mut tx, &workspace.id, task_id, field).await?;
285    let value = match resolution {
286        ResolutionValue::Explicit(value) => value.to_string(),
287        ResolutionValue::Choice(choice) => {
288            let values = sqlx::query_as::<_, (String, String)>(
289                "SELECT local_value, remote_value FROM conflicts
290                 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
291            )
292            .bind(&workspace.id)
293            .bind(task_id)
294            .bind(field)
295            .fetch_optional(&mut *tx)
296            .await?
297            .ok_or_else(|| {
298                anyhow::Error::new(ConflictNotFoundError {
299                    task_id: task_id.clone(),
300                    field,
301                })
302            })?;
303            match choice {
304                ConflictValueChoice::Local => values.0,
305                ConflictValueChoice::Remote => values.1,
306            }
307        }
308    };
309    if task_field == TaskField::IsEpic
310        && value == "0"
311        && crate::operations::task_has_epic_children(&mut tx, &workspace.id, task_id).await?
312    {
313        bail!("error epic-has-children task_id={task_id}");
314    }
315    let result = sqlx::query(
316        "UPDATE conflicts SET resolved = 1 WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0",
317    )
318    .bind(&workspace.id)
319    .bind(task_id)
320    .bind(field)
321    .execute(&mut *tx)
322    .await?;
323    if result.rows_affected() != 1 {
324        return Err(anyhow::Error::new(ConflictNotFoundError {
325            task_id: task_id.clone(),
326            field,
327        }));
328    }
329    let payload = if task_field.is_project() {
330        let project = resolve_project_for_stored_value(&mut tx, &workspace.id, &value).await?;
331        apply_project_id_in_workspace(&mut tx, &workspace.id, task_id, &project.id).await?;
332        TaskField::project_payload(&workspace.id, &workspace.key, &project)
333    } else {
334        apply_field_value_in_workspace(&mut tx, &workspace.id, task_id, field, &value).await?;
335        task_field.scalar_payload(&workspace.id, &workspace.key, &value)?
336    };
337    let change_id = insert_change(
338        &mut tx,
339        "task",
340        task_id,
341        Some(field),
342        op_type::RESOLVE_FIELD,
343        payload,
344        None,
345    )
346    .await?;
347    set_field_version(&mut tx, task_id, field, &change_id).await?;
348    let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
349    let after = crate::undo::task_field_value(&mut tx, &workspace.id, task_id, field).await?;
350    if let Some(summary) = tui_summary {
351        record_tui_undo(
352            &mut tx,
353            &workspace.id,
354            summary,
355            UndoPayload {
356                commands: vec![UndoCommand::RestoreConflictResolution {
357                    task_id: task_id.clone(),
358                    field: field.to_string(),
359                    before: before.clone(),
360                    after: after.clone(),
361                    conflict_id,
362                }],
363            },
364        )
365        .await?;
366    }
367    tx.commit().await?;
368    info!(task_id = %task_id, field = %field, "conflict resolved");
369    Ok(ConflictResolutionOutcome {
370        outcome: ConflictOutcome {
371            task,
372            field: field.to_string(),
373        },
374        before,
375        after,
376        conflict_id,
377    })
378}