Skip to main content

aven_core/operations/
projects.rs

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}