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