1use crate::ids::{ProjectId, WorkspaceId};
2
3use anyhow::{Result, bail};
4use sqlx::{Row, SqliteConnection};
5use tracing::info;
6
7use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
8use crate::db::{Database, begin_immediate};
9use crate::ids::now;
10use crate::labels::normalize_label;
11use crate::projects::{
12 create_project_in_workspace, normalize_key, resolve_existing_project_in_workspace,
13};
14use crate::types::Project;
15use crate::workspaces::Workspace;
16
17pub struct LabelDeleteOutcome {
18 pub name: String,
19 pub changed: bool,
20}
21
22pub struct LabelOutcome {
23 pub name: String,
24 pub created: bool,
25 pub change_id: Option<String>,
26}
27
28pub struct ProjectOutcome {
29 pub project: Project,
30 pub created: bool,
31 pub change_id: Option<String>,
32}
33
34pub struct ProjectDeleteOutcome {
35 pub project: Project,
36}
37
38pub struct ProjectRenameOutcome {
39 pub previous: Project,
40 pub project: Project,
41 pub changed: bool,
42}
43
44impl Database {
45 pub async fn create_label(&self, workspace: &Workspace, name: &str) -> Result<LabelOutcome> {
46 let mut conn = self.acquire().await?;
47 create_label_operation(&mut conn, workspace, name).await
48 }
49
50 pub async fn delete_label(
51 &self,
52 workspace: &Workspace,
53 name: &str,
54 ) -> Result<LabelDeleteOutcome> {
55 let mut conn = self.acquire().await?;
56 delete_label_operation(&mut conn, workspace, name).await
57 }
58
59 pub async fn create_project(
60 &self,
61 workspace: &Workspace,
62 name: &str,
63 ) -> Result<ProjectOutcome> {
64 let mut conn = self.acquire().await?;
65 create_project_operation(&mut conn, workspace, name).await
66 }
67
68 pub async fn delete_project(
69 &self,
70 workspace: &Workspace,
71 project: &str,
72 ) -> Result<ProjectDeleteOutcome> {
73 let mut conn = self.acquire().await?;
74 delete_project_operation(&mut conn, workspace, project).await
75 }
76
77 pub async fn rename_project(
78 &self,
79 workspace: &Workspace,
80 project: &str,
81 new_name: &str,
82 prefix: Option<&str>,
83 ) -> Result<ProjectRenameOutcome> {
84 let mut conn = self.acquire().await?;
85 rename_project_operation(&mut conn, workspace, project, new_name, prefix).await
86 }
87
88 pub async fn rename_project_before_commit<T, F>(
89 &self,
90 workspace: &Workspace,
91 project: &str,
92 new_name: &str,
93 prefix: Option<&str>,
94 before_commit: F,
95 ) -> Result<(ProjectRenameOutcome, T)>
96 where
97 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
98 {
99 let mut conn = self.acquire().await?;
100 rename_project_operation_before_commit(
101 &mut conn,
102 workspace,
103 project,
104 new_name,
105 prefix,
106 before_commit,
107 )
108 .await
109 }
110}
111
112#[derive(Clone, Copy)]
113pub struct ProjectMetadata<'a> {
114 pub key: &'a str,
115 pub name: &'a str,
116 pub prefix: &'a str,
117}
118
119pub async fn create_label_operation(
120 conn: &mut SqliteConnection,
121 workspace: &Workspace,
122 name: &str,
123) -> Result<LabelOutcome> {
124 let name = normalize_label(name);
125 if name.is_empty() {
126 bail!("error invalid-label");
127 }
128 let existed = sqlx::query_scalar::<_, i64>(
129 "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
130 )
131 .bind(&workspace.id)
132 .bind(&name)
133 .fetch_one(&mut *conn)
134 .await?
135 > 0;
136 let created_at = now();
137 sqlx::query("INSERT OR IGNORE INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
138 .bind(&workspace.id)
139 .bind(&name)
140 .bind(&created_at)
141 .execute(&mut *conn)
142 .await?;
143 let created = !existed;
144 let change_id = if created {
145 Some(
146 append_change(
147 conn,
148 ChangeEntity::Label,
149 &name,
150 None,
151 op_type::CREATE_LABEL,
152 ChangePayload::workspace(workspace)
153 .set("name", name.clone())
154 .set("created_at", created_at),
155 )
156 .await?,
157 )
158 } else {
159 None
160 };
161 if created {
162 info!("label created");
163 }
164 Ok(LabelOutcome {
165 name,
166 created,
167 change_id,
168 })
169}
170
171pub async fn delete_label_operation(
172 conn: &mut SqliteConnection,
173 workspace: &Workspace,
174 name: &str,
175) -> Result<LabelDeleteOutcome> {
176 let name = normalize_label(name);
177 if name.is_empty() {
178 bail!("error invalid-label");
179 }
180 let mut tx = begin_immediate(conn).await?;
181 let deleted_at = now();
182 let task_labels = sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND label = ?")
183 .bind(&workspace.id)
184 .bind(&name)
185 .execute(&mut *tx)
186 .await?
187 .rows_affected();
188 let labels = sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
189 .bind(&workspace.id)
190 .bind(&name)
191 .execute(&mut *tx)
192 .await?
193 .rows_affected();
194 let changed = task_labels > 0 || labels > 0;
195 if changed {
196 append_change(
197 &mut tx,
198 ChangeEntity::Label,
199 &name,
200 None,
201 op_type::LABEL_DELETE,
202 ChangePayload::workspace(workspace)
203 .set("name", name.clone())
204 .set("deleted_at", deleted_at),
205 )
206 .await?;
207 }
208 tx.commit().await?;
209 if changed {
210 info!("label deleted");
211 }
212 Ok(LabelDeleteOutcome { name, changed })
213}
214
215pub async fn create_project_operation(
216 conn: &mut SqliteConnection,
217 workspace: &Workspace,
218 name: &str,
219) -> Result<ProjectOutcome> {
220 let outcome = create_project_in_workspace(conn, &workspace.id, name).await?;
221 if outcome.created {
222 info!(project_key = %outcome.project.key, "project created");
223 }
224 Ok(ProjectOutcome {
225 project: outcome.project,
226 created: outcome.created,
227 change_id: outcome.change_id,
228 })
229}
230
231pub async fn delete_project_operation(
232 conn: &mut SqliteConnection,
233 workspace: &Workspace,
234 project: &str,
235) -> Result<ProjectDeleteOutcome> {
236 let project = resolve_existing_project_in_workspace(conn, &workspace.id, project).await?;
237 let mut tx = begin_immediate(conn).await?;
238 let deleted_at = now();
239 let task_refs: i64 =
240 sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
241 .bind(&project.workspace_id)
242 .bind(&project.id)
243 .fetch_one(&mut *tx)
244 .await?;
245 sqlx::query("DELETE FROM project_paths WHERE workspace_id = ? AND project_id = ?")
246 .bind(&project.workspace_id)
247 .bind(&project.id)
248 .execute(&mut *tx)
249 .await?;
250 let deleted = if task_refs > 0 {
251 sqlx::query(
252 "UPDATE projects SET deleted = 1, updated_at = ? WHERE workspace_id = ? AND key = ?",
253 )
254 .bind(&deleted_at)
255 .bind(&project.workspace_id)
256 .bind(&project.key)
257 .execute(&mut *tx)
258 .await?
259 } else {
260 sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
261 .bind(&project.workspace_id)
262 .bind(&project.key)
263 .execute(&mut *tx)
264 .await?
265 };
266 if deleted.rows_affected() != 1 {
267 bail!("error project-delete-race project={}", project.key);
268 }
269 append_change(
270 &mut tx,
271 ChangeEntity::Project,
272 project.id.as_str(),
273 None,
274 op_type::PROJECT_DELETE,
275 ChangePayload::workspace(workspace).set("deleted_at", deleted_at),
276 )
277 .await?;
278 tx.commit().await?;
279 info!("project deleted");
280 Ok(ProjectDeleteOutcome { project })
281}
282
283pub async fn rename_project_operation(
284 conn: &mut SqliteConnection,
285 workspace: &Workspace,
286 project: &str,
287 new_name: &str,
288 prefix: Option<&str>,
289) -> Result<ProjectRenameOutcome> {
290 let (outcome, ()) =
291 rename_project_operation_before_commit(conn, workspace, project, new_name, prefix, |_| {
292 Ok(())
293 })
294 .await?;
295 Ok(outcome)
296}
297
298async fn rename_project_operation_before_commit<T, F>(
299 conn: &mut SqliteConnection,
300 workspace: &Workspace,
301 project: &str,
302 new_name: &str,
303 prefix: Option<&str>,
304 before_commit: F,
305) -> Result<(ProjectRenameOutcome, T)>
306where
307 F: FnOnce(&ProjectRenameOutcome) -> Result<T>,
308{
309 let previous = resolve_existing_project_in_workspace(conn, &workspace.id, project).await?;
310 let new_name = new_name.trim();
311 let key = normalize_key(new_name);
312 if key.is_empty() {
313 bail!("error invalid-project input={new_name:?}");
314 }
315 if let Some(existing) = resolve_project_key(conn, &workspace.id, &key).await?
316 && existing.id != previous.id
317 {
318 bail!("error project-exists project={key}");
319 }
320 let prefix = match prefix {
321 Some(prefix) => {
322 let prefix = normalize_prefix(prefix)?;
323 if project_prefix_exists(conn, &workspace.id, &prefix, Some(&previous.id)).await? {
324 bail!("error project-prefix-exists prefix={prefix}");
325 }
326 prefix
327 }
328 None => {
329 unique_project_prefix_excluding(conn, &workspace.id, &key, Some(&previous.id)).await?
330 }
331 };
332 let changed = previous.key != key || previous.name != new_name || previous.prefix != prefix;
333 if !changed {
334 let outcome = ProjectRenameOutcome {
335 project: previous.clone(),
336 previous,
337 changed: false,
338 };
339 let value = before_commit(&outcome)?;
340 return Ok((outcome, value));
341 }
342 let mut tx = begin_immediate(conn).await?;
343 let project = set_project_metadata(
344 &mut tx,
345 workspace,
346 &previous.id,
347 ProjectMetadata {
348 key: &key,
349 name: new_name,
350 prefix: &prefix,
351 },
352 true,
353 )
354 .await?;
355 let outcome = ProjectRenameOutcome {
356 previous,
357 project,
358 changed: true,
359 };
360 let value = before_commit(&outcome)?;
361 tx.commit().await?;
362 info!(project_id = %outcome.project.id, project_key = %outcome.project.key, "project renamed");
363 Ok((outcome, value))
364}
365
366pub async fn set_project_metadata(
367 conn: &mut SqliteConnection,
368 workspace: &Workspace,
369 project_id: &ProjectId,
370 metadata: ProjectMetadata<'_>,
371 record_change: bool,
372) -> Result<Project> {
373 let ts = now();
374 let updated = sqlx::query(
375 "UPDATE projects SET key = ?, name = ?, prefix = ?, updated_at = ?
376 WHERE workspace_id = ? AND id = ? AND deleted = 0",
377 )
378 .bind(metadata.key)
379 .bind(metadata.name)
380 .bind(metadata.prefix)
381 .bind(&ts)
382 .bind(&workspace.id)
383 .bind(project_id)
384 .execute(&mut *conn)
385 .await?;
386 if updated.rows_affected() != 1 {
387 bail!("error project-metadata-target-missing project_id={project_id}");
388 }
389 if record_change {
390 insert_project_metadata_change(conn, workspace, project_id, metadata, &ts).await?;
391 }
392 Ok(Project {
393 id: project_id.clone(),
394 workspace_id: workspace.id.clone(),
395 key: metadata.key.to_string(),
396 name: metadata.name.to_string(),
397 prefix: metadata.prefix.to_string(),
398 })
399}
400
401pub async fn insert_project_metadata_change(
402 conn: &mut SqliteConnection,
403 workspace: &Workspace,
404 project_id: &ProjectId,
405 metadata: ProjectMetadata<'_>,
406 updated_at: &str,
407) -> Result<String> {
408 append_change(
409 conn,
410 ChangeEntity::Project,
411 project_id.as_str(),
412 None,
413 op_type::SET_PROJECT_METADATA,
414 ChangePayload::workspace(workspace)
415 .set("key", metadata.key)
416 .set("name", metadata.name)
417 .set("prefix", metadata.prefix)
418 .set("updated_at", updated_at),
419 )
420 .await
421}
422
423async fn resolve_project_key(
424 conn: &mut SqliteConnection,
425 workspace_id: &WorkspaceId,
426 key: &str,
427) -> Result<Option<Project>> {
428 let row = sqlx::query(
429 "SELECT id, workspace_id, key, name, prefix
430 FROM projects
431 WHERE workspace_id = ? AND key = ?",
432 )
433 .bind(workspace_id)
434 .bind(key)
435 .fetch_optional(&mut *conn)
436 .await?;
437 Ok(row.map(|row| Project {
438 id: row.get("id"),
439 workspace_id: row.get("workspace_id"),
440 key: row.get("key"),
441 name: row.get("name"),
442 prefix: row.get("prefix"),
443 }))
444}
445
446async fn unique_project_prefix_excluding(
447 conn: &mut SqliteConnection,
448 workspace_id: &WorkspaceId,
449 key: &str,
450 exclude_project_id: Option<&ProjectId>,
451) -> Result<String> {
452 let base = crate::projects::prefix_base(key);
453 let mut candidate = base.clone();
454 let mut n = 2;
455 while project_prefix_exists(conn, workspace_id, &candidate, exclude_project_id).await? {
456 candidate = format!("{}{}", base.chars().take(2).collect::<String>(), n);
457 n += 1;
458 }
459 Ok(candidate)
460}
461
462async fn project_prefix_exists(
463 conn: &mut SqliteConnection,
464 workspace_id: &WorkspaceId,
465 prefix: &str,
466 exclude_project_id: Option<&ProjectId>,
467) -> Result<bool> {
468 Ok(sqlx::query_scalar::<_, i64>(
469 "SELECT count(*) FROM projects
470 WHERE workspace_id = ? AND prefix = ? AND (? IS NULL OR id != ?)",
471 )
472 .bind(workspace_id)
473 .bind(prefix)
474 .bind(exclude_project_id)
475 .bind(exclude_project_id)
476 .fetch_one(&mut *conn)
477 .await?
478 > 0)
479}
480
481fn normalize_prefix(prefix: &str) -> Result<String> {
482 let prefix = prefix.trim().to_ascii_uppercase();
483 if (2..=8).contains(&prefix.len()) && prefix.chars().all(|ch| ch.is_ascii_alphanumeric()) {
484 Ok(prefix)
485 } else {
486 bail!("error invalid-project-prefix prefix={prefix:?}")
487 }
488}