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