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