Skip to main content

aven_core/operations/
projects.rs

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}