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}