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