1use crate::ids::{ProjectId, WorkspaceId};
2
3use anyhow::{Result, bail};
4use sqlx::{Row, SqliteConnection};
5use tracing::info;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate};
9use crate::ids::now;
10use crate::labels::normalize_label;
11use crate::projects::{
12 create_project_in_workspace, normalize_key, resolve_existing_project_in_workspace,
13};
14use crate::types::Project;
15use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
16use crate::workspaces::Workspace;
17
18pub struct LabelDeleteOutcome {
19 pub name: String,
20 pub changed: bool,
21}
22
23pub struct LabelOutcome {
24 pub name: String,
25 pub created: bool,
26 pub change_id: Option<String>,
27}
28
29pub struct ProjectOutcome {
30 pub project: Project,
31 pub created: bool,
32 pub change_id: Option<String>,
33}
34
35pub struct ProjectDeleteOutcome {
36 pub project: Project,
37}
38
39pub struct ProjectRenameOutcome {
40 pub previous: Project,
41 pub project: Project,
42 pub changed: bool,
43}
44
45impl Database {
46 pub async fn create_label(&self, workspace: &Workspace, name: &str) -> Result<LabelOutcome> {
47 let mut conn = self.acquire().await?;
48 let mut tx = begin_immediate(&mut conn).await?;
49 let outcome = match create_label_operation(&mut tx, workspace, name).await {
50 Ok(outcome) => outcome,
51 Err(error) => {
52 tx.rollback().await?;
53 return Err(error);
54 }
55 };
56 tx.commit().await?;
57 Ok(outcome)
58 }
59
60 pub async fn create_label_with_tui_undo(
61 &self,
62 workspace: &Workspace,
63 name: &str,
64 ) -> Result<LabelOutcome> {
65 let mut conn = self.acquire().await?;
66 let mut tx = begin_immediate(&mut conn).await?;
67 let outcome = create_label_operation(&mut tx, workspace, name).await?;
68 if outcome.created {
69 record_tui_undo(
70 &mut tx,
71 &workspace.id,
72 &format!("label {}", outcome.name),
73 UndoPayload {
74 commands: vec![UndoCommand::DeleteCreatedLabel {
75 label: outcome.name.clone(),
76 create_change_id: outcome.change_id.clone().unwrap_or_default(),
77 }],
78 },
79 )
80 .await?;
81 }
82 tx.commit().await?;
83 Ok(outcome)
84 }
85
86 pub async fn delete_label(
87 &self,
88 workspace: &Workspace,
89 name: &str,
90 ) -> Result<LabelDeleteOutcome> {
91 let mut conn = self.acquire().await?;
92 delete_label_operation(&mut conn, workspace, name).await
93 }
94
95 pub async fn create_project(
96 &self,
97 workspace: &Workspace,
98 name: &str,
99 ) -> Result<ProjectOutcome> {
100 let mut conn = self.acquire().await?;
101 let mut tx = begin_immediate(&mut conn).await?;
102 let outcome = match create_project_operation(&mut tx, workspace, name).await {
103 Ok(outcome) => outcome,
104 Err(error) => {
105 tx.rollback().await?;
106 return Err(error);
107 }
108 };
109 tx.commit().await?;
110 Ok(outcome)
111 }
112
113 pub async fn create_project_with_tui_undo(
114 &self,
115 workspace: &Workspace,
116 name: &str,
117 ) -> Result<ProjectOutcome> {
118 let mut conn = self.acquire().await?;
119 let mut tx = begin_immediate(&mut conn).await?;
120 let outcome = create_project_operation(&mut tx, workspace, name).await?;
121 if outcome.created {
122 record_tui_undo(
123 &mut tx,
124 &workspace.id,
125 &format!("project {}", outcome.project.key),
126 UndoPayload {
127 commands: vec![UndoCommand::DeleteCreatedProject {
128 project_key: outcome.project.key.clone(),
129 create_change_id: outcome.change_id.clone().unwrap_or_default(),
130 expected_name: outcome.project.name.clone(),
131 expected_prefix: outcome.project.prefix.clone(),
132 }],
133 },
134 )
135 .await?;
136 }
137 tx.commit().await?;
138 Ok(outcome)
139 }
140
141 pub async fn delete_project(
142 &self,
143 workspace: &Workspace,
144 project: &str,
145 ) -> Result<ProjectDeleteOutcome> {
146 let mut conn = self.acquire().await?;
147 delete_project_operation(&mut conn, workspace, project).await
148 }
149
150 pub async fn rename_project(
151 &self,
152 workspace: &Workspace,
153 project: &str,
154 new_name: &str,
155 prefix: Option<&str>,
156 ) -> Result<ProjectRenameOutcome> {
157 let mut conn = self.acquire().await?;
158 rename_project_operation(&mut conn, workspace, project, new_name, prefix).await
159 }
160
161 pub async fn rename_project_with_tui_undo(
162 &self,
163 workspace: &Workspace,
164 project: &str,
165 new_name: &str,
166 prefix: Option<&str>,
167 ) -> Result<ProjectRenameOutcome> {
168 let mut conn = self.acquire().await?;
169 let (outcome, ()) = rename_project_operation_before_commit(
170 &mut conn,
171 workspace,
172 project,
173 new_name,
174 prefix,
175 |_| Ok(()),
176 true,
177 )
178 .await?;
179 Ok(outcome)
180 }
181
182 pub async fn rename_project_before_commit<T, F>(
183 &self,
184 workspace: &Workspace,
185 project: &str,
186 new_name: &str,
187 prefix: Option<&str>,
188 before_commit: F,
189 ) -> Result<(ProjectRenameOutcome, T)>
190 where
191 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
192 {
193 let mut conn = self.acquire().await?;
194 rename_project_operation_before_commit(
195 &mut conn,
196 workspace,
197 project,
198 new_name,
199 prefix,
200 before_commit,
201 false,
202 )
203 .await
204 }
205}
206
207#[derive(Clone, Copy)]
208pub struct ProjectMetadata<'a> {
209 pub key: &'a str,
210 pub name: &'a str,
211 pub prefix: &'a str,
212}
213
214pub(crate) async fn create_label_operation(
215 conn: &mut SqliteConnection,
216 workspace: &Workspace,
217 name: &str,
218) -> Result<LabelOutcome> {
219 let name = normalize_label(name);
220 if name.is_empty() {
221 bail!("error invalid-label");
222 }
223 let existed = sqlx::query_scalar::<_, i64>(
224 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
225 )
226 .bind(&workspace.id)
227 .bind(&name)
228 .fetch_one(&mut *conn)
229 .await?
230 > 0;
231 let created_at = now();
232 sqlx::query("INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
233 .bind(&workspace.id)
234 .bind(&name)
235 .bind(&created_at)
236 .execute(&mut *conn)
237 .await?;
238 let created = !existed;
239 let change_id = if created {
240 Some(
241 append_change(
242 conn,
243 ChangeEntity::Label,
244 &name,
245 None,
246 op_type::CREATE_LABEL,
247 ChangePayload::workspace(workspace)
248 .set("name", name.clone())
249 .set("created_at", created_at),
250 )
251 .await?,
252 )
253 } else {
254 None
255 };
256 if created {
257 info!("label created");
258 }
259 Ok(LabelOutcome {
260 name,
261 created,
262 change_id,
263 })
264}
265
266pub async fn delete_label_operation(
267 conn: &mut SqliteConnection,
268 workspace: &Workspace,
269 name: &str,
270) -> Result<LabelDeleteOutcome> {
271 let name = normalize_label(name);
272 if name.is_empty() {
273 bail!("error invalid-label");
274 }
275 let mut tx = begin_immediate(conn).await?;
276 let deleted_at = now();
277 let task_labels = sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND label = ?")
278 .bind(&workspace.id)
279 .bind(&name)
280 .execute(&mut *tx)
281 .await?
282 .rows_affected();
283 let labels = sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
284 .bind(&workspace.id)
285 .bind(&name)
286 .execute(&mut *tx)
287 .await?
288 .rows_affected();
289 let changed = task_labels > 0 || labels > 0;
290 if changed {
291 append_change(
292 &mut tx,
293 ChangeEntity::Label,
294 &name,
295 None,
296 op_type::LABEL_DELETE,
297 ChangePayload::workspace(workspace)
298 .set("name", name.clone())
299 .set("deleted_at", deleted_at),
300 )
301 .await?;
302 }
303 tx.commit().await?;
304 if changed {
305 info!("label deleted");
306 }
307 Ok(LabelDeleteOutcome { name, changed })
308}
309
310pub(crate) async fn create_project_operation(
311 conn: &mut SqliteConnection,
312 workspace: &Workspace,
313 name: &str,
314) -> Result<ProjectOutcome> {
315 let outcome = create_project_in_workspace(conn, &workspace.id, name).await?;
316 if outcome.created {
317 info!(project_key = %outcome.project.key, "project created");
318 }
319 Ok(ProjectOutcome {
320 project: outcome.project,
321 created: outcome.created,
322 change_id: outcome.change_id,
323 })
324}
325
326pub async fn delete_project_operation(
327 conn: &mut SqliteConnection,
328 workspace: &Workspace,
329 project: &str,
330) -> Result<ProjectDeleteOutcome> {
331 let mut tx = begin_immediate(conn).await?;
332 let project = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
333 let deleted_at = now();
334 let task_refs: i64 =
335 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
336 .bind(&project.workspace_id)
337 .bind(&project.id)
338 .fetch_one(&mut *tx)
339 .await?;
340 sqlx::query("DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ?")
341 .bind(&project.workspace_id)
342 .bind(&project.id)
343 .execute(&mut *tx)
344 .await?;
345 let deleted = if task_refs > 0 {
346 sqlx::query(
347 "UPDATE projects SET deleted = 1, updated_at = ? WHERE workspace_id = ? AND key = ?",
348 )
349 .bind(&deleted_at)
350 .bind(&project.workspace_id)
351 .bind(&project.key)
352 .execute(&mut *tx)
353 .await?
354 } else {
355 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
356 .bind(&project.workspace_id)
357 .bind(&project.key)
358 .execute(&mut *tx)
359 .await?
360 };
361 if deleted.rows_affected() != 1 {
362 bail!("error project-delete-race project={}", project.key);
363 }
364 append_change(
365 &mut tx,
366 ChangeEntity::Project,
367 project.id.as_str(),
368 None,
369 op_type::PROJECT_DELETE,
370 ChangePayload::workspace(workspace).set("deleted_at", deleted_at),
371 )
372 .await?;
373 tx.commit().await?;
374 info!("project deleted");
375 Ok(ProjectDeleteOutcome { project })
376}
377
378pub async fn rename_project_operation(
379 conn: &mut SqliteConnection,
380 workspace: &Workspace,
381 project: &str,
382 new_name: &str,
383 prefix: Option<&str>,
384) -> Result<ProjectRenameOutcome> {
385 let (outcome, ()) = rename_project_operation_before_commit(
386 conn,
387 workspace,
388 project,
389 new_name,
390 prefix,
391 |_| Ok(()),
392 false,
393 )
394 .await?;
395 Ok(outcome)
396}
397
398async fn rename_project_operation_before_commit<T, F>(
399 conn: &mut SqliteConnection,
400 workspace: &Workspace,
401 project: &str,
402 new_name: &str,
403 prefix: Option<&str>,
404 before_commit: F,
405 tui_undo: bool,
406) -> Result<(ProjectRenameOutcome, T)>
407where
408 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
409{
410 let mut tx = begin_immediate(conn).await?;
411 let previous = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
412 let new_name = new_name.trim();
413 let key = normalize_key(new_name);
414 if key.is_empty() {
415 bail!("error invalid-project input={new_name:?}");
416 }
417 if let Some(existing) = resolve_project_key(&mut tx, &workspace.id, &key).await?
418 && existing.id != previous.id
419 {
420 bail!("error project-exists project={key}");
421 }
422 let prefix = match prefix {
423 Some(prefix) => {
424 let prefix = normalize_prefix(prefix)?;
425 if project_prefix_exists(&mut tx, &workspace.id, &prefix, Some(&previous.id)).await? {
426 bail!("error project-prefix-exists prefix={prefix}");
427 }
428 prefix
429 }
430 None => {
431 unique_project_prefix_excluding(&mut tx, &workspace.id, &key, Some(&previous.id))
432 .await?
433 }
434 };
435 let changed = previous.key != key || previous.name != new_name || previous.prefix != prefix;
436 if !changed {
437 let outcome = ProjectRenameOutcome {
438 project: previous.clone(),
439 previous,
440 changed: false,
441 };
442 let value = before_commit(&outcome)?;
443 tx.commit().await?;
444 return Ok((outcome, value));
445 }
446 let project = set_project_metadata(
447 &mut tx,
448 workspace,
449 &previous.id,
450 ProjectMetadata {
451 key: &key,
452 name: new_name,
453 prefix: &prefix,
454 },
455 true,
456 )
457 .await?;
458 let outcome = ProjectRenameOutcome {
459 previous,
460 project,
461 changed: true,
462 };
463 let value = before_commit(&outcome)?;
464 if tui_undo {
465 record_tui_undo(
466 &mut tx,
467 &workspace.id,
468 &format!("project {}", outcome.project.key),
469 UndoPayload {
470 commands: vec![UndoCommand::SetProjectMetadata {
471 project_id: outcome.project.id.clone(),
472 before_key: outcome.previous.key.clone(),
473 before_name: outcome.previous.name.clone(),
474 before_prefix: outcome.previous.prefix.clone(),
475 after_key: outcome.project.key.clone(),
476 after_name: outcome.project.name.clone(),
477 after_prefix: outcome.project.prefix.clone(),
478 }],
479 },
480 )
481 .await?;
482 }
483 tx.commit().await?;
484 info!(project_id = %outcome.project.id, project_key = %outcome.project.key, "project renamed");
485 Ok((outcome, value))
486}
487
488pub async fn set_project_metadata(
489 conn: &mut SqliteConnection,
490 workspace: &Workspace,
491 project_id: &ProjectId,
492 metadata: ProjectMetadata<'_>,
493 record_change: bool,
494) -> Result<Project> {
495 let ts = now();
496 let updated = sqlx::query(
497 "UPDATE projects SET key = ?, name = ?, prefix = ?, updated_at = ?
498 WHERE workspace_id = ? AND id = ? AND deleted = 0",
499 )
500 .bind(metadata.key)
501 .bind(metadata.name)
502 .bind(metadata.prefix)
503 .bind(&ts)
504 .bind(&workspace.id)
505 .bind(project_id)
506 .execute(&mut *conn)
507 .await?;
508 if updated.rows_affected() != 1 {
509 bail!("error project-metadata-target-missing project_id={project_id}");
510 }
511 if record_change {
512 insert_project_metadata_change(conn, workspace, project_id, metadata, &ts).await?;
513 }
514 Ok(Project {
515 id: project_id.clone(),
516 workspace_id: workspace.id.clone(),
517 key: metadata.key.to_string(),
518 name: metadata.name.to_string(),
519 prefix: metadata.prefix.to_string(),
520 })
521}
522
523pub async fn insert_project_metadata_change(
524 conn: &mut SqliteConnection,
525 workspace: &Workspace,
526 project_id: &ProjectId,
527 metadata: ProjectMetadata<'_>,
528 updated_at: &str,
529) -> Result<String> {
530 append_change(
531 conn,
532 ChangeEntity::Project,
533 project_id.as_str(),
534 None,
535 op_type::SET_PROJECT_METADATA,
536 ChangePayload::workspace(workspace)
537 .set("key", metadata.key)
538 .set("name", metadata.name)
539 .set("prefix", metadata.prefix)
540 .set("updated_at", updated_at),
541 )
542 .await
543}
544
545async fn resolve_project_key(
546 conn: &mut SqliteConnection,
547 workspace_id: &WorkspaceId,
548 key: &str,
549) -> Result<Option<Project>> {
550 let row = sqlx::query(
551 "SELECT id, workspace_id, key, name, prefix
552 FROM projects
553 WHERE workspace_id = ? AND key = ?",
554 )
555 .bind(workspace_id)
556 .bind(key)
557 .fetch_optional(&mut *conn)
558 .await?;
559 Ok(row.map(|row| Project {
560 id: row.get("id"),
561 workspace_id: row.get("workspace_id"),
562 key: row.get("key"),
563 name: row.get("name"),
564 prefix: row.get("prefix"),
565 }))
566}
567
568async fn unique_project_prefix_excluding(
569 conn: &mut SqliteConnection,
570 workspace_id: &WorkspaceId,
571 key: &str,
572 exclude_project_id: Option<&ProjectId>,
573) -> Result<String> {
574 let base = crate::projects::prefix_base(key);
575 let mut candidate = base.clone();
576 let mut n = 2;
577 while project_prefix_exists(conn, workspace_id, &candidate, exclude_project_id).await? {
578 candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
579 n += 1;
580 }
581 Ok(candidate)
582}
583
584async fn project_prefix_exists(
585 conn: &mut SqliteConnection,
586 workspace_id: &WorkspaceId,
587 prefix: &str,
588 exclude_project_id: Option<&ProjectId>,
589) -> Result<bool> {
590 Ok(sqlx::query_scalar::<_, i64>(
591 "SELECT count(*) FROM projects
592 WHERE workspace_id = ? AND prefix = ? AND (? IS NULL OR id != ?)",
593 )
594 .bind(workspace_id)
595 .bind(prefix)
596 .bind(exclude_project_id)
597 .bind(exclude_project_id)
598 .fetch_one(&mut *conn)
599 .await?
600 > 0)
601}
602
603fn normalize_prefix(prefix: &str) -> Result<String> {
604 let prefix = prefix.trim().to_ascii_uppercase();
605 if (2..=8).contains(&prefix.len()) && prefix.chars().all(|ch| ch.is_ascii_alphanumeric()) {
606 Ok(prefix)
607 } else {
608 bail!("error invalid-project-prefix prefix={prefix:?}")
609 }
610}