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}