1use crate::ids::{ProjectId, WorkspaceId};
2use crate::recurrence::RecurrenceSeriesId;
3
4use anyhow::{Result, bail};
5use sqlx::{Row, SqliteConnection};
6use tracing::info;
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{Database, begin_immediate};
10use crate::ids::now;
11use crate::labels::normalize_label;
12use crate::projects::{
13 create_project_in_workspace, normalize_key, resolve_existing_project_in_workspace,
14};
15use crate::types::Project;
16use crate::undo::{UndoCommand, UndoPayload, record_tui_undo};
17use crate::workspaces::Workspace;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct LabelDeleteOutcome {
21 pub name: String,
22 pub changed: bool,
23 pub task_count: usize,
24 pub series_count: usize,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LabelRenameOutcome {
29 pub previous_name: String,
30 pub name: String,
31 pub changed: bool,
32 pub task_count: usize,
33 pub series_count: usize,
34}
35
36pub struct LabelOutcome {
37 pub name: String,
38 pub created: bool,
39 pub change_id: Option<String>,
40}
41
42pub struct ProjectOutcome {
43 pub project: Project,
44 pub created: bool,
45 pub change_id: Option<String>,
46}
47
48pub struct ProjectDeleteOutcome {
49 pub project: Project,
50 pub stopped_series_count: usize,
51}
52
53pub struct ProjectRenameOutcome {
54 pub previous: Project,
55 pub project: Project,
56 pub changed: bool,
57}
58
59impl Database {
60 pub async fn create_label(&self, workspace: &Workspace, name: &str) -> Result<LabelOutcome> {
61 let mut conn = self.acquire_writer().await?;
62 let mut tx = begin_immediate(&mut conn).await?;
63 let outcome = match create_label_operation(&mut tx, workspace, name).await {
64 Ok(outcome) => outcome,
65 Err(error) => {
66 tx.rollback().await?;
67 return Err(error);
68 }
69 };
70 tx.commit().await?;
71 Ok(outcome)
72 }
73
74 pub async fn create_label_with_tui_undo(
75 &self,
76 workspace: &Workspace,
77 name: &str,
78 ) -> Result<LabelOutcome> {
79 let mut conn = self.acquire_writer().await?;
80 let mut tx = begin_immediate(&mut conn).await?;
81 let outcome = create_label_operation(&mut tx, workspace, name).await?;
82 if outcome.created {
83 record_tui_undo(
84 &mut tx,
85 &workspace.id,
86 &format!("label {}", outcome.name),
87 UndoPayload {
88 commands: vec![UndoCommand::DeleteCreatedLabel {
89 label: outcome.name.clone(),
90 create_change_id: outcome.change_id.clone().unwrap_or_default(),
91 }],
92 },
93 )
94 .await?;
95 }
96 tx.commit().await?;
97 Ok(outcome)
98 }
99
100 pub async fn delete_label(
101 &self,
102 workspace: &Workspace,
103 name: &str,
104 ) -> Result<LabelDeleteOutcome> {
105 let mut conn = self.acquire_writer().await?;
106 delete_label_operation(&mut conn, workspace, name, false).await
107 }
108
109 pub async fn delete_label_with_tui_undo(
110 &self,
111 workspace: &Workspace,
112 name: &str,
113 ) -> Result<LabelDeleteOutcome> {
114 let mut conn = self.acquire_writer().await?;
115 delete_label_operation(&mut conn, workspace, name, true).await
116 }
117
118 pub async fn rename_label_with_tui_undo(
119 &self,
120 workspace: &Workspace,
121 name: &str,
122 new_name: &str,
123 ) -> Result<LabelRenameOutcome> {
124 let mut conn = self.acquire_writer().await?;
125 rename_label_operation(&mut conn, workspace, name, new_name, true).await
126 }
127
128 pub async fn create_project(
129 &self,
130 workspace: &Workspace,
131 name: &str,
132 ) -> Result<ProjectOutcome> {
133 let mut conn = self.acquire_writer().await?;
134 let mut tx = begin_immediate(&mut conn).await?;
135 let outcome = match create_project_operation(&mut tx, workspace, name).await {
136 Ok(outcome) => outcome,
137 Err(error) => {
138 tx.rollback().await?;
139 return Err(error);
140 }
141 };
142 tx.commit().await?;
143 Ok(outcome)
144 }
145
146 pub async fn create_project_with_tui_undo(
147 &self,
148 workspace: &Workspace,
149 name: &str,
150 ) -> Result<ProjectOutcome> {
151 let mut conn = self.acquire_writer().await?;
152 let mut tx = begin_immediate(&mut conn).await?;
153 let outcome = create_project_operation(&mut tx, workspace, name).await?;
154 if outcome.created {
155 record_tui_undo(
156 &mut tx,
157 &workspace.id,
158 &format!("project {}", outcome.project.key),
159 UndoPayload {
160 commands: vec![UndoCommand::DeleteCreatedProject {
161 project_key: outcome.project.key.clone(),
162 create_change_id: outcome.change_id.clone().unwrap_or_default(),
163 expected_name: outcome.project.name.clone(),
164 expected_prefix: outcome.project.prefix.clone(),
165 }],
166 },
167 )
168 .await?;
169 }
170 tx.commit().await?;
171 Ok(outcome)
172 }
173
174 pub async fn delete_project(
175 &self,
176 workspace: &Workspace,
177 project: &str,
178 ) -> Result<ProjectDeleteOutcome> {
179 let mut conn = self.acquire_writer().await?;
180 delete_project_operation(&mut conn, workspace, project).await
181 }
182
183 pub async fn rename_project(
184 &self,
185 workspace: &Workspace,
186 project: &str,
187 new_name: &str,
188 prefix: Option<&str>,
189 ) -> Result<ProjectRenameOutcome> {
190 let mut conn = self.acquire_writer().await?;
191 rename_project_operation(&mut conn, workspace, project, new_name, prefix).await
192 }
193
194 pub async fn rename_project_with_tui_undo(
195 &self,
196 workspace: &Workspace,
197 project: &str,
198 new_name: &str,
199 prefix: Option<&str>,
200 ) -> Result<ProjectRenameOutcome> {
201 let mut conn = self.acquire_writer().await?;
202 let (outcome, ()) = rename_project_operation_before_commit(
203 &mut conn,
204 workspace,
205 project,
206 new_name,
207 prefix,
208 |_| Ok(()),
209 true,
210 )
211 .await?;
212 Ok(outcome)
213 }
214
215 pub async fn rename_project_before_commit<T, F>(
216 &self,
217 workspace: &Workspace,
218 project: &str,
219 new_name: &str,
220 prefix: Option<&str>,
221 before_commit: F,
222 ) -> Result<(ProjectRenameOutcome, T)>
223 where
224 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
225 {
226 let mut conn = self.acquire_writer().await?;
227 rename_project_operation_before_commit(
228 &mut conn,
229 workspace,
230 project,
231 new_name,
232 prefix,
233 before_commit,
234 false,
235 )
236 .await
237 }
238}
239
240#[derive(Clone, Copy)]
241pub struct ProjectMetadata<'a> {
242 pub key: &'a str,
243 pub name: &'a str,
244 pub prefix: &'a str,
245}
246
247pub(crate) async fn create_label_operation(
248 conn: &mut SqliteConnection,
249 workspace: &Workspace,
250 name: &str,
251) -> Result<LabelOutcome> {
252 let name = normalize_label(name);
253 if name.is_empty() {
254 bail!("error invalid-label");
255 }
256 let existed = sqlx::query_scalar::<_, i64>(
257 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
258 )
259 .bind(&workspace.id)
260 .bind(&name)
261 .fetch_one(&mut *conn)
262 .await?
263 > 0;
264 let created_at = now();
265 sqlx::query("INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
266 .bind(&workspace.id)
267 .bind(&name)
268 .bind(&created_at)
269 .execute(&mut *conn)
270 .await?;
271 let created = !existed;
272 let change_id = if created {
273 Some(
274 append_change(
275 conn,
276 ChangeEntity::Label,
277 &name,
278 None,
279 op_type::CREATE_LABEL,
280 ChangePayload::workspace(workspace)
281 .set("name", name.clone())
282 .set("created_at", created_at),
283 )
284 .await?,
285 )
286 } else {
287 None
288 };
289 if created {
290 info!("label created");
291 }
292 Ok(LabelOutcome {
293 name,
294 created,
295 change_id,
296 })
297}
298
299pub async fn rename_label_operation(
300 conn: &mut SqliteConnection,
301 workspace: &Workspace,
302 name: &str,
303 new_name: &str,
304 tui_undo: bool,
305) -> Result<LabelRenameOutcome> {
306 let name = normalize_label(name);
307 let new_name = normalize_label(new_name);
308 if name.is_empty() || new_name.is_empty() {
309 bail!("error invalid-label");
310 }
311 let mut tx = begin_immediate(conn).await?;
312 let snapshot = label_snapshot(&mut tx, &workspace.id, &name)
313 .await?
314 .ok_or_else(|| anyhow::anyhow!("error label-not-found label={name}"))?;
315 if name == new_name {
316 tx.commit().await?;
317 return Ok(LabelRenameOutcome {
318 previous_name: name.clone(),
319 name,
320 changed: false,
321 task_count: snapshot.task_ids.len(),
322 series_count: snapshot.series_ids.len(),
323 });
324 }
325 if label_snapshot(&mut tx, &workspace.id, &new_name)
326 .await?
327 .is_some()
328 {
329 bail!("error label-exists label={new_name}");
330 }
331 set_label_name(&mut tx, workspace, &name, &new_name).await?;
332 if tui_undo {
333 record_tui_undo(
334 &mut tx,
335 &workspace.id,
336 &format!("label {new_name}"),
337 UndoPayload {
338 commands: vec![UndoCommand::SetLabelName {
339 before: name.clone(),
340 after: new_name.clone(),
341 }],
342 },
343 )
344 .await?;
345 }
346 tx.commit().await?;
347 info!("label renamed");
348 Ok(LabelRenameOutcome {
349 previous_name: name,
350 name: new_name,
351 changed: true,
352 task_count: snapshot.task_ids.len(),
353 series_count: snapshot.series_ids.len(),
354 })
355}
356
357pub async fn delete_label_operation(
358 conn: &mut SqliteConnection,
359 workspace: &Workspace,
360 name: &str,
361 tui_undo: bool,
362) -> Result<LabelDeleteOutcome> {
363 let name = normalize_label(name);
364 if name.is_empty() {
365 bail!("error invalid-label");
366 }
367 let mut tx = begin_immediate(conn).await?;
368 let Some(snapshot) = label_snapshot(&mut tx, &workspace.id, &name).await? else {
369 tx.commit().await?;
370 return Ok(LabelDeleteOutcome {
371 name,
372 changed: false,
373 task_count: 0,
374 series_count: 0,
375 });
376 };
377 sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND label = ?")
378 .bind(&workspace.id)
379 .bind(&name)
380 .execute(&mut *tx)
381 .await?;
382 sqlx::query("DELETE FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?")
383 .bind(&workspace.id)
384 .bind(&name)
385 .execute(&mut *tx)
386 .await?;
387 let labels = sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
388 .bind(&workspace.id)
389 .bind(&name)
390 .execute(&mut *tx)
391 .await?;
392 if labels.rows_affected() != 1 {
393 bail!("error label-delete-race label={name}");
394 }
395 append_change(
396 &mut tx,
397 ChangeEntity::Label,
398 &name,
399 None,
400 op_type::LABEL_DELETE,
401 ChangePayload::workspace(workspace)
402 .set("name", name.clone())
403 .set("deleted_at", now()),
404 )
405 .await?;
406 if tui_undo {
407 record_tui_undo(
408 &mut tx,
409 &workspace.id,
410 &format!("label {name}"),
411 UndoPayload {
412 commands: vec![UndoCommand::RestoreDeletedLabel {
413 name: name.clone(),
414 created_at: snapshot.created_at.clone(),
415 task_ids: snapshot.task_ids.clone(),
416 series_ids: snapshot.series_ids.clone(),
417 }],
418 },
419 )
420 .await?;
421 }
422 tx.commit().await?;
423 info!("label deleted");
424 Ok(LabelDeleteOutcome {
425 name,
426 changed: true,
427 task_count: snapshot.task_ids.len(),
428 series_count: snapshot.series_ids.len(),
429 })
430}
431
432#[derive(Debug)]
433struct LabelSnapshot {
434 created_at: String,
435 task_ids: Vec<crate::ids::TaskId>,
436 series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
437}
438
439async fn label_snapshot(
440 conn: &mut SqliteConnection,
441 workspace_id: &WorkspaceId,
442 name: &str,
443) -> Result<Option<LabelSnapshot>> {
444 let Some(created_at) = sqlx::query_scalar::<_, String>(
445 "SELECT created_at FROM labels WHERE workspace_id = ? AND name = ?",
446 )
447 .bind(workspace_id)
448 .bind(name)
449 .fetch_optional(&mut *conn)
450 .await?
451 else {
452 return Ok(None);
453 };
454 let task_ids = sqlx::query_scalar(
455 "SELECT task_id FROM task_labels WHERE workspace_id = ? AND label = ? ORDER BY task_id",
456 )
457 .bind(workspace_id)
458 .bind(name)
459 .fetch_all(&mut *conn)
460 .await?;
461 let series_ids = sqlx::query_scalar(
462 "SELECT series_id FROM recurrence_series_labels WHERE workspace_id = ? AND label = ? ORDER BY series_id",
463 )
464 .bind(workspace_id)
465 .bind(name)
466 .fetch_all(&mut *conn)
467 .await?;
468 Ok(Some(LabelSnapshot {
469 created_at,
470 task_ids,
471 series_ids,
472 }))
473}
474
475pub(crate) async fn set_label_name(
476 conn: &mut SqliteConnection,
477 workspace: &Workspace,
478 name: &str,
479 new_name: &str,
480) -> Result<()> {
481 sqlx::query("UPDATE labels SET name = ? WHERE workspace_id = ? AND name = ?")
482 .bind(new_name)
483 .bind(&workspace.id)
484 .bind(name)
485 .execute(&mut *conn)
486 .await?;
487 sqlx::query("UPDATE task_labels SET label = ? WHERE workspace_id = ? AND label = ?")
488 .bind(new_name)
489 .bind(&workspace.id)
490 .bind(name)
491 .execute(&mut *conn)
492 .await?;
493 sqlx::query(
494 "UPDATE recurrence_series_labels SET label = ? WHERE workspace_id = ? AND label = ?",
495 )
496 .bind(new_name)
497 .bind(&workspace.id)
498 .bind(name)
499 .execute(&mut *conn)
500 .await?;
501 append_change(
502 conn,
503 ChangeEntity::Label,
504 name,
505 None,
506 op_type::SET_LABEL_NAME,
507 ChangePayload::workspace(workspace)
508 .set("name", name)
509 .set("new_name", new_name)
510 .set("renamed_at", now()),
511 )
512 .await?;
513 Ok(())
514}
515
516pub(crate) async fn create_project_operation(
517 conn: &mut SqliteConnection,
518 workspace: &Workspace,
519 name: &str,
520) -> Result<ProjectOutcome> {
521 let outcome = create_project_in_workspace(conn, &workspace.id, name).await?;
522 if outcome.created {
523 info!(project_key = %outcome.project.key, "project created");
524 }
525 Ok(ProjectOutcome {
526 project: outcome.project,
527 created: outcome.created,
528 change_id: outcome.change_id,
529 })
530}
531
532pub async fn delete_project_operation(
533 conn: &mut SqliteConnection,
534 workspace: &Workspace,
535 project: &str,
536) -> Result<ProjectDeleteOutcome> {
537 let mut tx = begin_immediate(conn).await?;
538 let project = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
539 let deleted_at = now();
540 let series_ids = sqlx::query_scalar::<_, RecurrenceSeriesId>(
541 "SELECT id FROM recurrence_series
542 WHERE workspace_id = ? AND project_id = ? AND deleted = 0 AND state != 'stopped'
543 ORDER BY id",
544 )
545 .bind(&project.workspace_id)
546 .bind(&project.id)
547 .fetch_all(&mut *tx)
548 .await?;
549 for series_id in &series_ids {
550 super::recurrence::stop_recurrence_series_for_project_delete_in_transaction(
551 &mut tx,
552 workspace,
553 series_id,
554 &deleted_at,
555 )
556 .await?;
557 }
558 let task_refs: i64 =
559 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
560 .bind(&project.workspace_id)
561 .bind(&project.id)
562 .fetch_one(&mut *tx)
563 .await?;
564 let series_refs: i64 = sqlx::query_scalar(
565 "SELECT count(*) FROM recurrence_series WHERE workspace_id = ? AND project_id = ?",
566 )
567 .bind(&project.workspace_id)
568 .bind(&project.id)
569 .fetch_one(&mut *tx)
570 .await?;
571 sqlx::query("DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ?")
572 .bind(&project.workspace_id)
573 .bind(&project.id)
574 .execute(&mut *tx)
575 .await?;
576 let deleted = if task_refs > 0 || series_refs > 0 {
577 sqlx::query(
578 "UPDATE projects SET deleted = 1, updated_at = ? WHERE workspace_id = ? AND key = ?",
579 )
580 .bind(&deleted_at)
581 .bind(&project.workspace_id)
582 .bind(&project.key)
583 .execute(&mut *tx)
584 .await?
585 } else {
586 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
587 .bind(&project.workspace_id)
588 .bind(&project.key)
589 .execute(&mut *tx)
590 .await?
591 };
592 if deleted.rows_affected() != 1 {
593 bail!("error project-delete-race project={}", project.key);
594 }
595 append_change(
596 &mut tx,
597 ChangeEntity::Project,
598 project.id.as_str(),
599 None,
600 op_type::PROJECT_DELETE,
601 ChangePayload::workspace(workspace).set("deleted_at", deleted_at),
602 )
603 .await?;
604 tx.commit().await?;
605 info!(stopped_series_count = series_ids.len(), "project deleted");
606 Ok(ProjectDeleteOutcome {
607 project,
608 stopped_series_count: series_ids.len(),
609 })
610}
611
612pub async fn rename_project_operation(
613 conn: &mut SqliteConnection,
614 workspace: &Workspace,
615 project: &str,
616 new_name: &str,
617 prefix: Option<&str>,
618) -> Result<ProjectRenameOutcome> {
619 let (outcome, ()) = rename_project_operation_before_commit(
620 conn,
621 workspace,
622 project,
623 new_name,
624 prefix,
625 |_| Ok(()),
626 false,
627 )
628 .await?;
629 Ok(outcome)
630}
631
632async fn rename_project_operation_before_commit<T, F>(
633 conn: &mut SqliteConnection,
634 workspace: &Workspace,
635 project: &str,
636 new_name: &str,
637 prefix: Option<&str>,
638 before_commit: F,
639 tui_undo: bool,
640) -> Result<(ProjectRenameOutcome, T)>
641where
642 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
643{
644 let mut tx = begin_immediate(conn).await?;
645 let previous = resolve_existing_project_in_workspace(&mut tx, &workspace.id, project).await?;
646 let new_name = new_name.trim();
647 let key = normalize_key(new_name);
648 if key.is_empty() {
649 bail!("error invalid-project input={new_name:?}");
650 }
651 if let Some(existing) = resolve_project_key(&mut tx, &workspace.id, &key).await?
652 && existing.id != previous.id
653 {
654 bail!("error project-exists project={key}");
655 }
656 let prefix = match prefix {
657 Some(prefix) => {
658 let prefix = normalize_prefix(prefix)?;
659 if project_prefix_exists(&mut tx, &workspace.id, &prefix, Some(&previous.id)).await? {
660 bail!("error project-prefix-exists prefix={prefix}");
661 }
662 prefix
663 }
664 None => {
665 unique_project_prefix_excluding(&mut tx, &workspace.id, &key, Some(&previous.id))
666 .await?
667 }
668 };
669 let changed = previous.key != key || previous.name != new_name || previous.prefix != prefix;
670 if !changed {
671 let outcome = ProjectRenameOutcome {
672 project: previous.clone(),
673 previous,
674 changed: false,
675 };
676 let value = before_commit(&outcome)?;
677 tx.commit().await?;
678 return Ok((outcome, value));
679 }
680 let project = set_project_metadata(
681 &mut tx,
682 workspace,
683 &previous.id,
684 ProjectMetadata {
685 key: &key,
686 name: new_name,
687 prefix: &prefix,
688 },
689 true,
690 )
691 .await?;
692 let outcome = ProjectRenameOutcome {
693 previous,
694 project,
695 changed: true,
696 };
697 let value = before_commit(&outcome)?;
698 if tui_undo {
699 record_tui_undo(
700 &mut tx,
701 &workspace.id,
702 &format!("project {}", outcome.project.key),
703 UndoPayload {
704 commands: vec![UndoCommand::SetProjectMetadata {
705 project_id: outcome.project.id.clone(),
706 before_key: outcome.previous.key.clone(),
707 before_name: outcome.previous.name.clone(),
708 before_prefix: outcome.previous.prefix.clone(),
709 after_key: outcome.project.key.clone(),
710 after_name: outcome.project.name.clone(),
711 after_prefix: outcome.project.prefix.clone(),
712 }],
713 },
714 )
715 .await?;
716 }
717 tx.commit().await?;
718 info!(project_id = %outcome.project.id, project_key = %outcome.project.key, "project renamed");
719 Ok((outcome, value))
720}
721
722pub async fn set_project_metadata(
723 conn: &mut SqliteConnection,
724 workspace: &Workspace,
725 project_id: &ProjectId,
726 metadata: ProjectMetadata<'_>,
727 record_change: bool,
728) -> Result<Project> {
729 let ts = now();
730 let updated = sqlx::query(
731 "UPDATE projects SET key = ?, name = ?, prefix = ?, updated_at = ?
732 WHERE workspace_id = ? AND id = ? AND deleted = 0",
733 )
734 .bind(metadata.key)
735 .bind(metadata.name)
736 .bind(metadata.prefix)
737 .bind(&ts)
738 .bind(&workspace.id)
739 .bind(project_id)
740 .execute(&mut *conn)
741 .await?;
742 if updated.rows_affected() != 1 {
743 bail!("error project-metadata-target-missing project_id={project_id}");
744 }
745 if record_change {
746 insert_project_metadata_change(conn, workspace, project_id, metadata, &ts).await?;
747 }
748 Ok(Project {
749 id: project_id.clone(),
750 workspace_id: workspace.id.clone(),
751 key: metadata.key.to_string(),
752 name: metadata.name.to_string(),
753 prefix: metadata.prefix.to_string(),
754 })
755}
756
757pub async fn insert_project_metadata_change(
758 conn: &mut SqliteConnection,
759 workspace: &Workspace,
760 project_id: &ProjectId,
761 metadata: ProjectMetadata<'_>,
762 updated_at: &str,
763) -> Result<String> {
764 append_change(
765 conn,
766 ChangeEntity::Project,
767 project_id.as_str(),
768 None,
769 op_type::SET_PROJECT_METADATA,
770 ChangePayload::workspace(workspace)
771 .set("key", metadata.key)
772 .set("name", metadata.name)
773 .set("prefix", metadata.prefix)
774 .set("updated_at", updated_at),
775 )
776 .await
777}
778
779async fn resolve_project_key(
780 conn: &mut SqliteConnection,
781 workspace_id: &WorkspaceId,
782 key: &str,
783) -> Result<Option<Project>> {
784 let row = sqlx::query(
785 "SELECT id, workspace_id, key, name, prefix
786 FROM projects
787 WHERE workspace_id = ? AND key = ?",
788 )
789 .bind(workspace_id)
790 .bind(key)
791 .fetch_optional(&mut *conn)
792 .await?;
793 Ok(row.map(|row| Project {
794 id: row.get("id"),
795 workspace_id: row.get("workspace_id"),
796 key: row.get("key"),
797 name: row.get("name"),
798 prefix: row.get("prefix"),
799 }))
800}
801
802async fn unique_project_prefix_excluding(
803 conn: &mut SqliteConnection,
804 workspace_id: &WorkspaceId,
805 key: &str,
806 exclude_project_id: Option<&ProjectId>,
807) -> Result<String> {
808 let base = crate::projects::prefix_base(key);
809 let mut candidate = base.clone();
810 let mut n = 2;
811 while project_prefix_exists(conn, workspace_id, &candidate, exclude_project_id).await? {
812 candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
813 n += 1;
814 }
815 Ok(candidate)
816}
817
818async fn project_prefix_exists(
819 conn: &mut SqliteConnection,
820 workspace_id: &WorkspaceId,
821 prefix: &str,
822 exclude_project_id: Option<&ProjectId>,
823) -> Result<bool> {
824 Ok(sqlx::query_scalar::<_, i64>(
825 "SELECT count(*) FROM projects
826 WHERE workspace_id = ? AND prefix = ? AND (? IS NULL OR id != ?)",
827 )
828 .bind(workspace_id)
829 .bind(prefix)
830 .bind(exclude_project_id)
831 .bind(exclude_project_id)
832 .fetch_one(&mut *conn)
833 .await?
834 > 0)
835}
836
837fn normalize_prefix(prefix: &str) -> Result<String> {
838 let prefix = prefix.trim().to_ascii_uppercase();
839 if (2..=crate::projects::MAX_EXPLICIT_PROJECT_PREFIX_LEN).contains(&prefix.len())
840 && prefix.chars().all(|ch| ch.is_ascii_alphanumeric())
841 {
842 Ok(prefix)
843 } else {
844 bail!("error invalid-project-prefix prefix={prefix:?}")
845 }
846}