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