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().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().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().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().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().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().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().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().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().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
437async fn unique_project_prefix(
438 conn: &mut SqliteConnection,
439 workspace_id: &WorkspaceId,
440 key: &str,
441) -> Result<String> {
442 let base = prefix_base(key);
443 let mut candidate = base.clone();
444 let mut n = 2;
445 while sqlx::query_scalar::<_, i64>(
446 "SELECT count(*) FROM projects WHERE workspace_id = ? AND prefix = ?",
447 )
448 .bind(workspace_id)
449 .bind(&candidate)
450 .fetch_one(&mut *conn)
451 .await?
452 > 0
453 {
454 candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
455 n += 1;
456 }
457 Ok(candidate)
458}
459
460pub fn prefix_base(key: &str) -> String {
461 let words: Vec<&str> = key.split('-').filter(|word| !word.is_empty()).collect();
462 if words.len() >= 2 {
463 return words
464 .iter()
465 .filter_map(|word| word.chars().next())
466 .take(3)
467 .collect::<String>()
468 .to_ascii_uppercase();
469 }
470 let key = words.first().copied().unwrap_or(key);
471 let mut out = String::new();
472 let mut chars = key.chars();
473 if let Some(first) = chars.next() {
474 out.push(first);
475 }
476 for ch in chars {
477 if !"aeiou".contains(ch) {
478 out.push(ch);
479 }
480 if out.len() >= 3 {
481 break;
482 }
483 }
484 for ch in key.chars() {
485 if out.len() >= 3 {
486 break;
487 }
488 if !out.contains(ch) {
489 out.push(ch);
490 }
491 }
492 while out.len() < 3 {
493 out.push('X');
494 }
495 out.to_ascii_uppercase()
496}
497
498pub(crate) async fn list_projects_in_workspace(
499 conn: &mut SqliteConnection,
500 workspace_id: &WorkspaceId,
501 search: Option<&str>,
502) -> Result<Vec<Project>> {
503 let search = search.map(normalize_key);
504 let rows = sqlx::query(
505 "SELECT id, workspace_id, key, name, prefix
506 FROM projects
507 WHERE workspace_id = ? AND deleted = 0
508 ORDER BY key",
509 )
510 .bind(workspace_id)
511 .fetch_all(&mut *conn)
512 .await?;
513 let projects = rows.into_iter().map(project_from_row).collect::<Vec<_>>();
514 Ok(projects
515 .into_iter()
516 .filter(|project| {
517 search.as_deref().is_none_or(|search| {
518 project.key.contains(search) || project.name.to_lowercase().contains(search)
519 })
520 })
521 .collect())
522}
523
524pub(crate) async fn resolve_or_create_project_in_workspace(
525 conn: &mut SqliteConnection,
526 workspace_id: &WorkspaceId,
527 project: &str,
528) -> Result<Project> {
529 if let Some(existing) = find_project_in_workspace(conn, workspace_id, project).await? {
530 return Ok(existing);
531 }
532 create_project_in_workspace(conn, workspace_id, project)
533 .await
534 .map(|outcome| outcome.project)
535}
536
537pub(crate) async fn resolve_project_for_stored_value(
538 conn: &mut SqliteConnection,
539 workspace_id: &WorkspaceId,
540 value: &str,
541) -> Result<Project> {
542 if let Ok(project_id) = value.parse::<ProjectId>()
543 && let Some(row) = sqlx::query(
544 "SELECT id, workspace_id, key, name, prefix
545 FROM projects WHERE workspace_id = ? AND id = ? AND deleted = 0",
546 )
547 .bind(workspace_id)
548 .bind(&project_id)
549 .fetch_optional(&mut *conn)
550 .await?
551 {
552 return Ok(project_from_row(row));
553 }
554 resolve_or_create_project_in_workspace(conn, workspace_id, value).await
555}
556
557pub(crate) fn project_from_row(row: sqlx::sqlite::SqliteRow) -> Project {
558 Project {
559 id: row.get("id"),
560 workspace_id: row.get("workspace_id"),
561 key: row.get("key"),
562 name: row.get("name"),
563 prefix: row.get("prefix"),
564 }
565}