Skip to main content

aven_core/
workspaces.rs

1use crate::ids::WorkspaceId;
2use anyhow::{Result, bail};
3use serde_json::json;
4use sqlx::{Row, SqliteConnection};
5
6use crate::db::{Database, begin_immediate, insert_change};
7use crate::ids::now;
8use crate::projects::normalize_key;
9
10pub const DEFAULT_WORKSPACE_ID: &str = "0000000000000000";
11
12pub fn default_workspace_id() -> WorkspaceId {
13    DEFAULT_WORKSPACE_ID
14        .parse()
15        .expect("valid default workspace ID")
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Workspace {
20    pub id: WorkspaceId,
21    pub key: String,
22    pub name: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct WorkspaceOpenSummary {
27    pub workspace: Workspace,
28    pub open_task_count: u32,
29}
30
31impl Default for Workspace {
32    fn default() -> Self {
33        Self {
34            id: DEFAULT_WORKSPACE_ID
35                .parse()
36                .expect("valid default workspace ID"),
37            key: "default".to_string(),
38            name: "default".to_string(),
39        }
40    }
41}
42
43impl Database {
44    pub async fn list_workspaces(&self) -> Result<Vec<Workspace>> {
45        let mut conn = self.acquire_reader().await?;
46        list_workspaces(&mut conn).await
47    }
48
49    pub async fn list_workspace_open_summaries(&self) -> Result<Vec<WorkspaceOpenSummary>> {
50        let mut conn = self.acquire_reader().await?;
51        let rows = sqlx::query(
52            "SELECT w.id, w.key, w.name,
53                    COALESCE(SUM(CASE WHEN t.deleted = 0
54                        AND t.status NOT IN ('done', 'canceled') THEN 1 ELSE 0 END), 0)
55                        AS open_task_count
56             FROM workspaces w
57             LEFT JOIN tasks t ON t.workspace_id = w.id
58             WHERE w.archived = 0
59             GROUP BY w.id, w.key, w.name
60             ORDER BY w.key",
61        )
62        .fetch_all(&mut *conn)
63        .await?;
64        Ok(rows
65            .into_iter()
66            .map(|row| WorkspaceOpenSummary {
67                workspace: Workspace {
68                    id: row.get("id"),
69                    key: row.get("key"),
70                    name: row.get("name"),
71                },
72                open_task_count: u32::try_from(row.get::<i64, _>("open_task_count"))
73                    .unwrap_or(u32::MAX),
74            })
75            .collect())
76    }
77
78    pub async fn find_workspace(&self, name_or_key: &str) -> Result<Option<Workspace>> {
79        let mut conn = self.acquire_reader().await?;
80        find_workspace(&mut conn, name_or_key).await
81    }
82
83    pub async fn workspace_for_id(&self, workspace_id: &WorkspaceId) -> Result<Workspace> {
84        let mut conn = self.acquire_reader().await?;
85        workspace_for_id(&mut conn, workspace_id).await
86    }
87
88    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<Workspace> {
89        self.resolve_required_workspace(name_or_key, "workspace")
90            .await
91    }
92
93    pub async fn resolve_required_workspace(
94        &self,
95        name_or_key: &str,
96        source: &str,
97    ) -> Result<Workspace> {
98        let mut conn = self.acquire_reader().await?;
99        resolve_required_workspace(&mut conn, name_or_key, source).await
100    }
101
102    pub async fn create_workspace(&self, name: &str) -> Result<Workspace> {
103        let mut conn = self.acquire_writer().await?;
104        let mut tx = begin_immediate(&mut conn).await?;
105        let workspace = create_workspace(&mut tx, name).await?;
106        tx.commit().await?;
107        Ok(workspace)
108    }
109
110    pub async fn rename_workspace(&self, workspace_ref: &str, new_name: &str) -> Result<Workspace> {
111        let mut conn = self.acquire_writer().await?;
112        let mut tx = begin_immediate(&mut conn).await?;
113        let workspace = rename_workspace(&mut tx, workspace_ref, new_name).await?;
114        tx.commit().await?;
115        Ok(workspace)
116    }
117}
118
119pub(crate) async fn ensure_default_workspace(conn: &mut SqliteConnection) -> Result<Workspace> {
120    if let Some(row) = sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ?")
121        .bind(DEFAULT_WORKSPACE_ID)
122        .fetch_optional(&mut *conn)
123        .await?
124    {
125        return Ok(Workspace {
126            id: row.get("id"),
127            key: row.get("key"),
128            name: row.get("name"),
129        });
130    }
131    let id: WorkspaceId = DEFAULT_WORKSPACE_ID
132        .parse()
133        .expect("valid default workspace ID");
134    let ts = now();
135    sqlx::query("INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, 'default', 'default', ?, ?)")
136        .bind(&id)
137        .bind(&ts)
138        .bind(&ts)
139        .execute(&mut *conn)
140        .await?;
141    Ok(Workspace {
142        id,
143        key: "default".to_string(),
144        name: "default".to_string(),
145    })
146}
147
148pub(crate) async fn list_workspaces(conn: &mut SqliteConnection) -> Result<Vec<Workspace>> {
149    let rows = sqlx::query("SELECT id, key, name FROM workspaces WHERE archived = 0 ORDER BY key")
150        .fetch_all(&mut *conn)
151        .await?;
152    Ok(rows
153        .into_iter()
154        .map(|row| Workspace {
155            id: row.get("id"),
156            key: row.get("key"),
157            name: row.get("name"),
158        })
159        .collect())
160}
161
162pub(crate) async fn find_workspace(
163    conn: &mut SqliteConnection,
164    name_or_key: &str,
165) -> Result<Option<Workspace>> {
166    let key = normalize_key(name_or_key);
167    let row = sqlx::query(
168        "SELECT id, key, name FROM workspaces
169         WHERE archived = 0 AND (key = ? OR lower(name) = lower(?))",
170    )
171    .bind(key)
172    .bind(name_or_key)
173    .fetch_optional(&mut *conn)
174    .await?;
175    Ok(row.map(workspace_from_row))
176}
177
178pub(crate) async fn workspace_for_id(
179    conn: &mut SqliteConnection,
180    workspace_id: &WorkspaceId,
181) -> Result<Workspace> {
182    let row = sqlx::query("SELECT id, key, name FROM workspaces WHERE id = ? AND archived = 0")
183        .bind(workspace_id)
184        .fetch_optional(&mut *conn)
185        .await?;
186    match row {
187        Some(row) => Ok(workspace_from_row(row)),
188        None => Err(crate::error::CoreError::not_found(format!(
189            "error unknown-workspace-id id={workspace_id}"
190        ))
191        .into()),
192    }
193}
194
195pub(crate) async fn workspace_key_for_id(
196    conn: &mut SqliteConnection,
197    workspace_id: &WorkspaceId,
198) -> Result<String> {
199    Ok(workspace_for_id(conn, workspace_id).await?.key)
200}
201
202fn workspace_from_row(row: sqlx::sqlite::SqliteRow) -> Workspace {
203    Workspace {
204        id: row.get("id"),
205        key: row.get("key"),
206        name: row.get("name"),
207    }
208}
209
210pub(crate) async fn resolve_workspace(
211    conn: &mut SqliteConnection,
212    name_or_key: &str,
213) -> Result<Workspace> {
214    resolve_required_workspace(conn, name_or_key, "workspace").await
215}
216
217pub(crate) async fn resolve_required_workspace(
218    conn: &mut SqliteConnection,
219    name_or_key: &str,
220    source: &str,
221) -> Result<Workspace> {
222    if let Some(workspace) = find_workspace(conn, name_or_key).await? {
223        return Ok(workspace);
224    }
225    bail!(
226        "error unknown-workspace input={} source={} hint=\"create the workspace with aven workspace create\"",
227        name_or_key,
228        source
229    );
230}
231
232pub(crate) async fn create_workspace(conn: &mut SqliteConnection, name: &str) -> Result<Workspace> {
233    let key = normalize_key(name);
234    if key.is_empty() {
235        bail!("error invalid-workspace input={name}");
236    }
237    if find_workspace(conn, &key).await?.is_some() {
238        bail!("error workspace-exists key={key}");
239    }
240    let id = WorkspaceId::new();
241    let ts = now();
242    sqlx::query(
243        "INSERT INTO workspaces(id, name, key, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
244    )
245    .bind(&id)
246    .bind(name)
247    .bind(&key)
248    .bind(&ts)
249    .bind(&ts)
250    .execute(&mut *conn)
251    .await?;
252    insert_change(
253        conn,
254        "workspace",
255        id.as_str(),
256        None,
257        "create_workspace",
258        json!({ "key": key, "name": name, "created_at": ts }),
259        None,
260    )
261    .await?;
262    Ok(Workspace {
263        id,
264        key,
265        name: name.to_string(),
266    })
267}
268
269pub(crate) async fn rename_workspace(
270    conn: &mut SqliteConnection,
271    workspace_ref: &str,
272    new_name: &str,
273) -> Result<Workspace> {
274    let workspace = resolve_workspace(conn, workspace_ref).await?;
275    let new_key = normalize_key(new_name);
276    if new_key.is_empty() {
277        bail!("error invalid-workspace input={new_name}");
278    }
279    if new_key != workspace.key && find_workspace(conn, &new_key).await?.is_some() {
280        bail!("error workspace-exists key={new_key}");
281    }
282    let ts = now();
283    sqlx::query("UPDATE workspaces SET name = ?, key = ?, updated_at = ? WHERE id = ?")
284        .bind(new_name)
285        .bind(&new_key)
286        .bind(&ts)
287        .bind(&workspace.id)
288        .execute(&mut *conn)
289        .await?;
290    if workspace.name != new_name {
291        insert_change(
292            conn,
293            "workspace",
294            workspace.id.as_str(),
295            Some("name"),
296            "set_workspace_field",
297            json!({ "value": new_name }),
298            None,
299        )
300        .await?;
301    }
302    if workspace.key != new_key {
303        insert_change(
304            conn,
305            "workspace",
306            workspace.id.as_str(),
307            Some("key"),
308            "set_workspace_field",
309            json!({ "value": new_key }),
310            None,
311        )
312        .await?;
313    }
314    Ok(Workspace {
315        id: workspace.id,
316        key: new_key,
317        name: new_name.to_string(),
318    })
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::db::open_db;
325
326    #[tokio::test]
327    async fn fresh_database_has_default_workspace() {
328        let dir = tempfile::tempdir().unwrap();
329        let pool = open_db(&dir.path().join("test.db")).await.unwrap();
330        let mut conn = pool.acquire().await.unwrap();
331        let workspaces = list_workspaces(&mut conn).await.unwrap();
332        assert_eq!(workspaces.len(), 1);
333        assert_eq!(workspaces[0].key, "default");
334    }
335}