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}