Skip to main content

aven_core/
projects.rs

1use anyhow::{Result, bail};
2use serde_json::json;
3use sqlx::{Row, SqliteConnection};
4use std::path::Path;
5
6use crate::db::{Database, begin_immediate, insert_change};
7use crate::ids::{ProjectId, WorkspaceId, now};
8use crate::types::Project;
9use crate::workspaces::Workspace;
10
11impl Database {
12    pub async fn find_project(
13        &self,
14        workspace_id: &WorkspaceId,
15        input: &str,
16    ) -> Result<Option<Project>> {
17        let mut conn = self.acquire().await?;
18        find_project_in_workspace(&mut conn, workspace_id, input).await
19    }
20
21    pub async fn resolve_existing_project(
22        &self,
23        workspace_id: &WorkspaceId,
24        project: &str,
25    ) -> Result<Project> {
26        let mut conn = self.acquire().await?;
27        resolve_existing_project_in_workspace(&mut conn, workspace_id, project).await
28    }
29
30    pub async fn list_projects(
31        &self,
32        workspace_id: &WorkspaceId,
33        search: Option<&str>,
34    ) -> Result<Vec<Project>> {
35        let mut conn = self.acquire().await?;
36        list_projects_in_workspace(&mut conn, workspace_id, search).await
37    }
38
39    pub async fn inferred_project_key_for_add(
40        &self,
41        workspace_id: &WorkspaceId,
42        config_candidate: Option<&str>,
43        cwd: &Path,
44        git_root: Option<&Path>,
45    ) -> Result<Option<String>> {
46        let mut conn = self.acquire().await?;
47        if let Some(project) = config_candidate {
48            return Ok(Some(normalize_key(project)));
49        }
50        if let Some(project) =
51            project_from_path_mapping_in_workspace(&mut conn, workspace_id, cwd, git_root).await?
52        {
53            return Ok(Some(project.key));
54        }
55        Ok(git_root
56            .and_then(Path::file_name)
57            .map(|name| normalize_key(&name.to_string_lossy())))
58    }
59
60    pub async fn inferred_existing_project_key(
61        &self,
62        workspace_id: &WorkspaceId,
63        config_candidate: Option<&str>,
64        cwd: &Path,
65        git_root: Option<&Path>,
66    ) -> Result<Option<String>> {
67        let mut conn = self.acquire().await?;
68        inferred_existing_project_key_in_workspace(
69            &mut conn,
70            workspace_id,
71            config_candidate,
72            cwd,
73            git_root,
74        )
75        .await
76    }
77
78    pub async fn remove_project_path(
79        &self,
80        workspace_id: &WorkspaceId,
81        project_id: &ProjectId,
82        path: &Path,
83    ) -> Result<()> {
84        let mut conn = self.acquire().await?;
85        let mut tx = begin_immediate(&mut conn).await?;
86        sqlx::query(
87            "DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ? AND path = ?",
88        )
89        .bind(workspace_id)
90        .bind(project_id)
91        .bind(path.display().to_string())
92        .execute(&mut *tx)
93        .await?;
94        tx.commit().await?;
95        Ok(())
96    }
97
98    pub async fn resolve_or_create_project(
99        &self,
100        workspace_id: &WorkspaceId,
101        project: &str,
102    ) -> Result<Project> {
103        let mut conn = self.acquire().await?;
104        let mut tx = begin_immediate(&mut conn).await?;
105        let project =
106            resolve_or_create_project_in_workspace(&mut tx, workspace_id, project).await?;
107        tx.commit().await?;
108        Ok(project)
109    }
110
111    pub async fn resolve_project_for_stored_value(
112        &self,
113        workspace_id: &WorkspaceId,
114        value: &str,
115    ) -> Result<Project> {
116        let mut conn = self.acquire().await?;
117        let mut tx = begin_immediate(&mut conn).await?;
118        let project = resolve_project_for_stored_value(&mut tx, workspace_id, value).await?;
119        tx.commit().await?;
120        Ok(project)
121    }
122}
123
124async fn inferred_existing_project_key_in_workspace(
125    conn: &mut SqliteConnection,
126    workspace_id: &WorkspaceId,
127    config_candidate: Option<&str>,
128    cwd: &Path,
129    git_root: Option<&Path>,
130) -> Result<Option<String>> {
131    if let Some(project) = config_candidate
132        && let Some(project) = find_project_in_workspace(conn, workspace_id, project).await?
133    {
134        return Ok(Some(project.key));
135    }
136
137    if let Some(project) =
138        project_from_path_mapping_in_workspace(conn, workspace_id, cwd, git_root).await?
139    {
140        return Ok(Some(project.key));
141    }
142
143    let Some(root_name) = git_root.and_then(Path::file_name) else {
144        return Ok(None);
145    };
146    find_project_in_workspace(conn, workspace_id, &root_name.to_string_lossy())
147        .await
148        .map(|project| project.map(|project| project.key))
149}
150
151async fn project_from_path_mapping_in_workspace(
152    conn: &mut SqliteConnection,
153    workspace_id: &WorkspaceId,
154    cwd: &Path,
155    git_root: Option<&Path>,
156) -> Result<Option<Project>> {
157    let root = git_root.unwrap_or(cwd);
158    let rows = sqlx::query(
159        "SELECT p.id, p.workspace_id, p.key, p.name, p.prefix, pp.path
160         FROM project_paths pp
161         JOIN projects p ON p.workspace_id = pp.workspace_id AND p.id = pp.project_id
162         WHERE pp.workspace_id = ? AND p.deleted = 0
163         ORDER BY length(pp.path) DESC",
164    )
165    .bind(workspace_id)
166    .fetch_all(&mut *conn)
167    .await?;
168    let mut best: Option<(ProjectPathMatch, Project)> = None;
169    for row in rows {
170        let path: String = row.get("path");
171        let project = project_from_row(row);
172        if let Some(path_match) = matching_project_path(cwd, root, Path::new(&path))
173            && best
174                .as_ref()
175                .is_none_or(|(best_match, _)| path_match.is_better_than(*best_match))
176        {
177            best = Some((path_match, project));
178        }
179    }
180    Ok(best.map(|(_, project)| project))
181}
182
183#[derive(Clone, Copy, PartialEq, Eq)]
184struct ProjectPathMatch {
185    kind: ProjectPathMatchKind,
186    len: usize,
187}
188
189#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
190enum ProjectPathMatchKind {
191    Root,
192    Cwd,
193}
194
195impl ProjectPathMatch {
196    fn is_better_than(self, other: Self) -> bool {
197        self.kind > other.kind || self.kind == other.kind && self.len > other.len
198    }
199}
200
201fn matching_project_path(cwd: &Path, root: &Path, path: &Path) -> Option<ProjectPathMatch> {
202    let len = path.components().count();
203    if cwd.starts_with(path) {
204        return Some(ProjectPathMatch {
205            kind: ProjectPathMatchKind::Cwd,
206            len,
207        });
208    }
209    if root == path || root.starts_with(path) {
210        return Some(ProjectPathMatch {
211            kind: ProjectPathMatchKind::Root,
212            len,
213        });
214    }
215    None
216}
217
218pub fn normalize_key(input: &str) -> String {
219    let mut out = String::new();
220    let mut last_dash = false;
221    for ch in input.chars().flat_map(char::to_lowercase) {
222        if ch.is_ascii_alphanumeric() {
223            out.push(ch);
224            last_dash = false;
225        } else if !last_dash && !out.is_empty() {
226            out.push('-');
227            last_dash = true;
228        }
229    }
230    out.trim_matches('-').to_string()
231}
232
233pub(crate) async fn find_project_in_workspace(
234    conn: &mut SqliteConnection,
235    workspace_id: &WorkspaceId,
236    input: &str,
237) -> Result<Option<Project>> {
238    let key = normalize_key(input);
239    let row = sqlx::query(
240        "SELECT id, workspace_id, key, name, prefix
241         FROM projects
242         WHERE workspace_id = ? AND deleted = 0 AND (key = ? OR lower(name) = lower(?))",
243    )
244    .bind(workspace_id)
245    .bind(key)
246    .bind(input)
247    .fetch_optional(&mut *conn)
248    .await?;
249    Ok(row.map(project_from_row))
250}
251
252pub(crate) async fn resolve_existing_project_in_workspace(
253    conn: &mut SqliteConnection,
254    workspace_id: &WorkspaceId,
255    project: &str,
256) -> Result<Project> {
257    find_project_in_workspace(conn, workspace_id, project)
258        .await?
259        .ok_or_else(|| anyhow::anyhow!("error unknown-project input={project}"))
260}
261
262#[allow(dead_code)]
263pub(crate) async fn create_project(
264    conn: &mut SqliteConnection,
265    workspace: &Workspace,
266    name: &str,
267) -> Result<Project> {
268    create_project_in_workspace(conn, &workspace.id, name)
269        .await
270        .map(|outcome| outcome.project)
271}
272
273#[derive(Debug, Clone)]
274pub struct ProjectCreateOutcome {
275    pub project: Project,
276    pub created: bool,
277    pub change_id: Option<String>,
278}
279
280pub(crate) async fn create_project_in_workspace(
281    conn: &mut SqliteConnection,
282    workspace_id: &WorkspaceId,
283    name: &str,
284) -> Result<ProjectCreateOutcome> {
285    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
286    let key = normalize_key(name);
287    if key.is_empty() {
288        bail!("error invalid-project input={name:?}");
289    }
290    if let Some(project) = find_project_in_workspace(conn, &workspace.id, &key).await? {
291        return Ok(ProjectCreateOutcome {
292            project,
293            created: false,
294            change_id: None,
295        });
296    }
297    if let Some((project, change_id)) =
298        restore_deleted_project(conn, &workspace, &key, name).await?
299    {
300        return Ok(ProjectCreateOutcome {
301            project,
302            created: true,
303            change_id: Some(change_id),
304        });
305    }
306    let prefix = unique_project_prefix(conn, &workspace.id, &key).await?;
307    let id = ProjectId::new();
308    let ts = now();
309    sqlx::query(
310        "INSERT INTO projects(id, workspace_id, key, name, prefix, created_at, updated_at)
311         VALUES (?, ?, ?, ?, ?, ?, ?)",
312    )
313    .bind(&id)
314    .bind(&workspace.id)
315    .bind(&key)
316    .bind(name)
317    .bind(&prefix)
318    .bind(&ts)
319    .bind(&ts)
320    .execute(&mut *conn)
321    .await?;
322    let change_id = insert_change(
323        conn,
324        "project",
325        id.as_str(),
326        None,
327        "create_project",
328        json!({
329            "workspace_id": &workspace.id,
330            "workspace_key": &workspace.key,
331            "key": key,
332            "name": name,
333            "prefix": prefix,
334            "created_at": ts
335        }),
336        None,
337    )
338    .await?;
339    Ok(ProjectCreateOutcome {
340        project: Project {
341            id,
342            workspace_id: workspace.id,
343            key,
344            name: name.to_string(),
345            prefix,
346        },
347        created: true,
348        change_id: Some(change_id),
349    })
350}
351
352async fn restore_deleted_project(
353    conn: &mut SqliteConnection,
354    workspace: &Workspace,
355    key: &str,
356    name: &str,
357) -> Result<Option<(Project, String)>> {
358    let Some(row) = sqlx::query(
359        "SELECT id, workspace_id, key, prefix
360         FROM projects
361         WHERE workspace_id = ? AND key = ? AND deleted = 1",
362    )
363    .bind(&workspace.id)
364    .bind(key)
365    .fetch_optional(&mut *conn)
366    .await?
367    else {
368        return Ok(None);
369    };
370    let id: ProjectId = row.get("id");
371    let workspace_id: WorkspaceId = row.get("workspace_id");
372    let key: String = row.get("key");
373    let prefix: String = row.get("prefix");
374    let prefix = if prefix == id.as_str() {
375        unique_project_prefix(conn, &workspace_id, &key).await?
376    } else {
377        prefix
378    };
379    let ts = now();
380    sqlx::query(
381        "UPDATE projects SET name = ?, prefix = ?, updated_at = ?, deleted = 0
382         WHERE workspace_id = ? AND id = ?",
383    )
384    .bind(name)
385    .bind(&prefix)
386    .bind(&ts)
387    .bind(&workspace_id)
388    .bind(&id)
389    .execute(&mut *conn)
390    .await?;
391    let change_id = insert_change(
392        conn,
393        "project",
394        id.as_str(),
395        None,
396        "create_project",
397        json!({
398            "workspace_id": &workspace.id,
399            "workspace_key": &workspace.key,
400            "key": &key,
401            "name": name,
402            "prefix": &prefix,
403            "created_at": ts
404        }),
405        None,
406    )
407    .await?;
408    Ok(Some((
409        Project {
410            id,
411            workspace_id,
412            key,
413            name: name.to_string(),
414            prefix,
415        },
416        change_id,
417    )))
418}
419
420async fn unique_project_prefix(
421    conn: &mut SqliteConnection,
422    workspace_id: &WorkspaceId,
423    key: &str,
424) -> Result<String> {
425    let base = prefix_base(key);
426    let mut candidate = base.clone();
427    let mut n = 2;
428    while sqlx::query_scalar::<_, i64>(
429        "SELECT count(*) FROM projects WHERE workspace_id = ? AND prefix = ?",
430    )
431    .bind(workspace_id)
432    .bind(&candidate)
433    .fetch_one(&mut *conn)
434    .await?
435        > 0
436    {
437        candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
438        n += 1;
439    }
440    Ok(candidate)
441}
442
443pub fn prefix_base(key: &str) -> String {
444    let words: Vec<&str> = key.split('-').filter(|word| !word.is_empty()).collect();
445    if words.len() >= 2 {
446        return words
447            .iter()
448            .filter_map(|word| word.chars().next())
449            .take(3)
450            .collect::<String>()
451            .to_ascii_uppercase();
452    }
453    let key = words.first().copied().unwrap_or(key);
454    let mut out = String::new();
455    let mut chars = key.chars();
456    if let Some(first) = chars.next() {
457        out.push(first);
458    }
459    for ch in chars {
460        if !"aeiou".contains(ch) {
461            out.push(ch);
462        }
463        if out.len() >= 3 {
464            break;
465        }
466    }
467    for ch in key.chars() {
468        if out.len() >= 3 {
469            break;
470        }
471        if !out.contains(ch) {
472            out.push(ch);
473        }
474    }
475    while out.len() < 3 {
476        out.push('X');
477    }
478    out.to_ascii_uppercase()
479}
480
481pub(crate) async fn list_projects_in_workspace(
482    conn: &mut SqliteConnection,
483    workspace_id: &WorkspaceId,
484    search: Option<&str>,
485) -> Result<Vec<Project>> {
486    let search = search.map(normalize_key);
487    let rows = sqlx::query(
488        "SELECT id, workspace_id, key, name, prefix
489         FROM projects
490         WHERE workspace_id = ? AND deleted = 0
491         ORDER BY key",
492    )
493    .bind(workspace_id)
494    .fetch_all(&mut *conn)
495    .await?;
496    let projects = rows.into_iter().map(project_from_row).collect::<Vec<_>>();
497    Ok(projects
498        .into_iter()
499        .filter(|project| {
500            search.as_deref().is_none_or(|search| {
501                project.key.contains(search) || project.name.to_lowercase().contains(search)
502            })
503        })
504        .collect())
505}
506
507pub(crate) async fn resolve_or_create_project_in_workspace(
508    conn: &mut SqliteConnection,
509    workspace_id: &WorkspaceId,
510    project: &str,
511) -> Result<Project> {
512    if let Some(existing) = find_project_in_workspace(conn, workspace_id, project).await? {
513        return Ok(existing);
514    }
515    create_project_in_workspace(conn, workspace_id, project)
516        .await
517        .map(|outcome| outcome.project)
518}
519
520pub(crate) async fn resolve_project_for_stored_value(
521    conn: &mut SqliteConnection,
522    workspace_id: &WorkspaceId,
523    value: &str,
524) -> Result<Project> {
525    if let Ok(project_id) = value.parse::<ProjectId>()
526        && let Some(row) = sqlx::query(
527            "SELECT id, workspace_id, key, name, prefix
528             FROM projects WHERE workspace_id = ? AND id = ? AND deleted = 0",
529        )
530        .bind(workspace_id)
531        .bind(&project_id)
532        .fetch_optional(&mut *conn)
533        .await?
534    {
535        return Ok(project_from_row(row));
536    }
537    resolve_or_create_project_in_workspace(conn, workspace_id, value).await
538}
539
540pub(crate) fn project_from_row(row: sqlx::sqlite::SqliteRow) -> Project {
541    Project {
542        id: row.get("id"),
543        workspace_id: row.get("workspace_id"),
544        key: row.get("key"),
545        name: row.get("name"),
546        prefix: row.get("prefix"),
547    }
548}