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