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