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