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