/home/runner/work/tenet/tenet/tenet-aws/src/services/spec.rs
Line | Count | Source |
1 | | use std::{collections::HashMap, sync::Arc}; |
2 | | |
3 | | use crate::{ |
4 | | ApiError, |
5 | | db::{ |
6 | | TenetDatabase, |
7 | | graph::GraphRepository, |
8 | | project::ProjectRepository, |
9 | | spec::SpecRepository, |
10 | | traits::{ProjectRepositoryTrait, SpecRepositoryTrait}, |
11 | | }, |
12 | | models::{ |
13 | | graph::{NodeId, NodeRef}, |
14 | | organisation::OrganisationId, |
15 | | project::ProjectId, |
16 | | spec::{ |
17 | | ActiveSpecContextEntry, ActiveSpecTaskContext, CreateSpecLinkRequest, |
18 | | CreateSpecRequest, CreateTaskLinkRequest, CreateTaskRequest, ProjectSpecSummary, Spec, |
19 | | SpecDetailView, SpecId, SpecLinkRelation, SpecLinkView, SpecOverlapWarning, SpecStatus, |
20 | | SpecSummaryView, Task, TaskDetailView, TaskId, TaskLinkRelation, TaskLinkView, |
21 | | TaskStatus, TaskSummary, UpdateSpecRequest, UpdateTaskRequest, |
22 | | }, |
23 | | }, |
24 | | }; |
25 | | |
26 | | #[derive(Clone)] |
27 | | pub struct SpecService { |
28 | | spec_repo: Arc<dyn SpecRepositoryTrait>, |
29 | | project_repo: Arc<dyn ProjectRepositoryTrait>, |
30 | | graph_repo: GraphRepository, |
31 | | } |
32 | | |
33 | | impl SpecService { |
34 | 6 | pub fn new(database: TenetDatabase) -> Self { |
35 | 6 | Self { |
36 | 6 | spec_repo: Arc::new(SpecRepository::new(database.clone())), |
37 | 6 | project_repo: Arc::new(ProjectRepository::new(database.clone())), |
38 | 6 | graph_repo: GraphRepository::new(database), |
39 | 6 | } |
40 | 6 | } |
41 | | |
42 | | #[cfg(test)] |
43 | 26 | pub fn new_with_repos( |
44 | 26 | spec_repo: Arc<dyn SpecRepositoryTrait>, |
45 | 26 | project_repo: Arc<dyn ProjectRepositoryTrait>, |
46 | 26 | graph_repo: GraphRepository, |
47 | 26 | ) -> Self { |
48 | 26 | Self { |
49 | 26 | spec_repo, |
50 | 26 | project_repo, |
51 | 26 | graph_repo, |
52 | 26 | } |
53 | 26 | } |
54 | | |
55 | 8 | async fn verify_project_exists( |
56 | 8 | &self, |
57 | 8 | org_id: &OrganisationId, |
58 | 8 | project_id: &ProjectId, |
59 | 8 | ) -> Result<(), ApiError> { |
60 | 8 | if !self.project_repo.project_exists(org_id, project_id).await?0 { |
61 | 0 | return Err(ApiError::NotFound(format!( |
62 | 0 | "Project {} not found", |
63 | 0 | project_id |
64 | 0 | ))); |
65 | 8 | } |
66 | 8 | Ok(()) |
67 | 8 | } |
68 | | |
69 | 0 | fn validate_name(label: &str, name: &str) -> Result<(), ApiError> { |
70 | 0 | let trimmed = name.trim(); |
71 | 0 | if trimmed.is_empty() { |
72 | 0 | return Err(ApiError::InvalidRequest(format!( |
73 | 0 | "{label} name cannot be empty" |
74 | 0 | ))); |
75 | 0 | } |
76 | 0 | if trimmed |
77 | 0 | .split('/') |
78 | 0 | .any(|segment| segment.is_empty() || !is_kebab_case_segment(segment)) |
79 | | { |
80 | 0 | return Err(ApiError::InvalidRequest(format!( |
81 | 0 | "{label} name must use kebab-case path segments separated by '/'" |
82 | 0 | ))); |
83 | 0 | } |
84 | 0 | Ok(()) |
85 | 0 | } |
86 | | |
87 | 0 | fn normalize_optional_text(value: Option<String>) -> Option<String> { |
88 | 0 | value.and_then(|value| { |
89 | 0 | let trimmed = value.trim(); |
90 | 0 | if trimmed.is_empty() { |
91 | 0 | None |
92 | | } else { |
93 | 0 | Some(trimmed.to_string()) |
94 | | } |
95 | 0 | }) |
96 | 0 | } |
97 | | |
98 | 0 | pub async fn create_spec( |
99 | 0 | &self, |
100 | 0 | org_id: &OrganisationId, |
101 | 0 | project_id: &ProjectId, |
102 | 0 | request: &CreateSpecRequest, |
103 | 0 | ) -> Result<Spec, ApiError> { |
104 | 0 | self.verify_project_exists(org_id, project_id).await?; |
105 | 0 | Self::validate_name("Spec", &request.name)?; |
106 | 0 | if request.title.trim().is_empty() { |
107 | 0 | return Err(ApiError::InvalidRequest( |
108 | 0 | "Spec title cannot be empty".to_string(), |
109 | 0 | )); |
110 | 0 | } |
111 | 0 | if request.owner.trim().is_empty() { |
112 | 0 | return Err(ApiError::InvalidRequest( |
113 | 0 | "Spec owner cannot be empty".to_string(), |
114 | 0 | )); |
115 | 0 | } |
116 | 0 | if self |
117 | 0 | .spec_repo |
118 | 0 | .get_spec_by_name(project_id, &request.name) |
119 | 0 | .await? |
120 | 0 | .is_some() |
121 | | { |
122 | 0 | return Err(ApiError::Conflict(format!( |
123 | 0 | "Spec '{}' already exists", |
124 | 0 | request.name |
125 | 0 | ))); |
126 | 0 | } |
127 | | |
128 | 0 | let spec = Spec::new( |
129 | 0 | org_id.clone(), |
130 | 0 | project_id.clone(), |
131 | 0 | request.name.trim().to_string(), |
132 | 0 | request.title.trim().to_string(), |
133 | 0 | request.spec_type, |
134 | 0 | request.status.unwrap_or(SpecStatus::Draft), |
135 | 0 | request.owner.trim().to_string(), |
136 | 0 | request.description.trim().to_string(), |
137 | 0 | request.content.clone(), |
138 | | ); |
139 | 0 | self.spec_repo.create_spec(&spec).await?; |
140 | 0 | Ok(spec) |
141 | 0 | } |
142 | | |
143 | 4 | pub async fn list_specs( |
144 | 4 | &self, |
145 | 4 | org_id: &OrganisationId, |
146 | 4 | project_id: &ProjectId, |
147 | 4 | status: Option<SpecStatus>, |
148 | 4 | ) -> Result<Vec<SpecSummaryView>, ApiError> { |
149 | 4 | self.verify_project_exists(org_id, project_id).await?0 ; |
150 | 4 | let specs = self.spec_repo.list_specs(project_id).await?0 ; |
151 | 4 | let mut filtered = Vec::new(); |
152 | 4 | for spec0 in specs { |
153 | 0 | if status.is_some_and(|expected| spec.status != expected) { |
154 | 0 | continue; |
155 | 0 | } |
156 | 0 | let tasks = self.spec_repo.list_tasks(&spec.id).await?; |
157 | 0 | filtered.push(SpecSummaryView { |
158 | 0 | task_summary: TaskSummary::from_tasks(&tasks), |
159 | 0 | spec, |
160 | 0 | }); |
161 | | } |
162 | 4 | Ok(filtered) |
163 | 4 | } |
164 | | |
165 | 0 | pub async fn get_spec( |
166 | 0 | &self, |
167 | 0 | org_id: &OrganisationId, |
168 | 0 | project_id: &ProjectId, |
169 | 0 | spec_id: &SpecId, |
170 | 0 | ) -> Result<SpecDetailView, ApiError> { |
171 | 0 | let spec = self |
172 | 0 | .spec_repo |
173 | 0 | .get_spec(org_id, project_id, spec_id) |
174 | 0 | .await? |
175 | 0 | .ok_or_else(|| ApiError::NotFound(format!("Spec {} not found", spec_id)))?; |
176 | 0 | let tasks = self.spec_repo.list_tasks(spec_id).await?; |
177 | 0 | let links = self.spec_repo.list_spec_links(org_id, spec_id).await?; |
178 | 0 | let overlap_warnings = self |
179 | 0 | .build_overlap_warnings(org_id, project_id, &spec, &links) |
180 | 0 | .await?; |
181 | 0 | Ok(SpecDetailView { |
182 | 0 | task_summary: TaskSummary::from_tasks(&tasks), |
183 | 0 | spec, |
184 | 0 | tasks, |
185 | 0 | links, |
186 | 0 | overlap_warnings, |
187 | 0 | }) |
188 | 0 | } |
189 | | |
190 | 0 | pub async fn update_spec( |
191 | 0 | &self, |
192 | 0 | org_id: &OrganisationId, |
193 | 0 | project_id: &ProjectId, |
194 | 0 | spec_id: &SpecId, |
195 | 0 | request: UpdateSpecRequest, |
196 | 0 | ) -> Result<Spec, ApiError> { |
197 | 0 | self.verify_project_exists(org_id, project_id).await?; |
198 | 0 | if let Some(ref name) = request.new_name { |
199 | 0 | Self::validate_name("Spec", name)?; |
200 | 0 | if let Some(existing) = self.spec_repo.get_spec_by_name(project_id, name).await? |
201 | 0 | && existing.id != *spec_id |
202 | | { |
203 | 0 | return Err(ApiError::Conflict(format!( |
204 | 0 | "Spec '{}' already exists", |
205 | 0 | name |
206 | 0 | ))); |
207 | 0 | } |
208 | 0 | } |
209 | 0 | self.spec_repo |
210 | 0 | .update_spec(org_id, project_id, spec_id, request) |
211 | 0 | .await? |
212 | 0 | .ok_or_else(|| ApiError::NotFound(format!("Spec {} not found", spec_id))) |
213 | 0 | } |
214 | | |
215 | 0 | pub async fn delete_spec( |
216 | 0 | &self, |
217 | 0 | org_id: &OrganisationId, |
218 | 0 | project_id: &ProjectId, |
219 | 0 | spec_id: &SpecId, |
220 | 0 | ) -> Result<(), ApiError> { |
221 | 0 | self.verify_project_exists(org_id, project_id).await?; |
222 | 0 | self.spec_repo |
223 | 0 | .delete_spec(org_id, project_id, spec_id) |
224 | 0 | .await |
225 | 0 | } |
226 | | |
227 | 0 | pub async fn create_task( |
228 | 0 | &self, |
229 | 0 | org_id: &OrganisationId, |
230 | 0 | project_id: &ProjectId, |
231 | 0 | spec_id: &SpecId, |
232 | 0 | request: &CreateTaskRequest, |
233 | 0 | ) -> Result<Task, ApiError> { |
234 | 0 | self.verify_project_exists(org_id, project_id).await?; |
235 | 0 | let Some(_spec) = self.spec_repo.get_spec(org_id, project_id, spec_id).await? else { |
236 | 0 | return Err(ApiError::NotFound(format!("Spec {} not found", spec_id))); |
237 | | }; |
238 | 0 | Self::validate_name("Task", &request.name)?; |
239 | 0 | if request.title.trim().is_empty() { |
240 | 0 | return Err(ApiError::InvalidRequest( |
241 | 0 | "Task title cannot be empty".to_string(), |
242 | 0 | )); |
243 | 0 | } |
244 | 0 | if self |
245 | 0 | .spec_repo |
246 | 0 | .get_task_by_name(spec_id, &request.name) |
247 | 0 | .await? |
248 | 0 | .is_some() |
249 | | { |
250 | 0 | return Err(ApiError::Conflict(format!( |
251 | 0 | "Task '{}' already exists in this spec", |
252 | 0 | request.name |
253 | 0 | ))); |
254 | 0 | } |
255 | | |
256 | 0 | let sort_order = match request.sort_order { |
257 | 0 | Some(value) => value, |
258 | | None => { |
259 | 0 | self.spec_repo |
260 | 0 | .list_tasks(spec_id) |
261 | 0 | .await? |
262 | 0 | .into_iter() |
263 | 0 | .map(|task| task.sort_order) |
264 | 0 | .max() |
265 | 0 | .unwrap_or(-1) |
266 | | + 1 |
267 | | } |
268 | | }; |
269 | | |
270 | 0 | let task = Task::new( |
271 | 0 | org_id.clone(), |
272 | 0 | project_id.clone(), |
273 | 0 | spec_id.clone(), |
274 | 0 | request.name.trim().to_string(), |
275 | 0 | request.title.trim().to_string(), |
276 | 0 | request.executor_type, |
277 | 0 | request.status.unwrap_or(TaskStatus::Todo), |
278 | 0 | sort_order, |
279 | 0 | request.description.trim().to_string(), |
280 | 0 | Self::normalize_optional_text(request.acceptance_criteria.clone()), |
281 | 0 | Self::normalize_optional_text(request.effort_estimate.clone()), |
282 | | ); |
283 | 0 | self.spec_repo.create_task(&task).await?; |
284 | 0 | Ok(task) |
285 | 0 | } |
286 | | |
287 | 0 | pub async fn list_tasks( |
288 | 0 | &self, |
289 | 0 | org_id: &OrganisationId, |
290 | 0 | project_id: &ProjectId, |
291 | 0 | spec_id: &SpecId, |
292 | 0 | ) -> Result<Vec<Task>, ApiError> { |
293 | 0 | self.verify_project_exists(org_id, project_id).await?; |
294 | 0 | if self |
295 | 0 | .spec_repo |
296 | 0 | .get_spec(org_id, project_id, spec_id) |
297 | 0 | .await? |
298 | 0 | .is_none() |
299 | | { |
300 | 0 | return Err(ApiError::NotFound(format!("Spec {} not found", spec_id))); |
301 | 0 | } |
302 | 0 | self.spec_repo.list_tasks(spec_id).await |
303 | 0 | } |
304 | | |
305 | 0 | pub async fn get_task( |
306 | 0 | &self, |
307 | 0 | org_id: &OrganisationId, |
308 | 0 | project_id: &ProjectId, |
309 | 0 | spec_id: &SpecId, |
310 | 0 | task_id: &TaskId, |
311 | 0 | ) -> Result<TaskDetailView, ApiError> { |
312 | 0 | let task = self |
313 | 0 | .spec_repo |
314 | 0 | .get_task(org_id, project_id, spec_id, task_id) |
315 | 0 | .await? |
316 | 0 | .ok_or_else(|| ApiError::NotFound(format!("Task {} not found", task_id)))?; |
317 | 0 | let links = self.spec_repo.list_task_links(org_id, task_id).await?; |
318 | 0 | Ok(TaskDetailView { task, links }) |
319 | 0 | } |
320 | | |
321 | 0 | pub async fn update_task( |
322 | 0 | &self, |
323 | 0 | org_id: &OrganisationId, |
324 | 0 | project_id: &ProjectId, |
325 | 0 | spec_id: &SpecId, |
326 | 0 | task_id: &TaskId, |
327 | 0 | request: UpdateTaskRequest, |
328 | 0 | ) -> Result<Task, ApiError> { |
329 | 0 | self.verify_project_exists(org_id, project_id).await?; |
330 | 0 | if let Some(ref name) = request.new_name { |
331 | 0 | Self::validate_name("Task", name)?; |
332 | 0 | if let Some(existing) = self.spec_repo.get_task_by_name(spec_id, name).await? |
333 | 0 | && existing.id != *task_id |
334 | | { |
335 | 0 | return Err(ApiError::Conflict(format!( |
336 | 0 | "Task '{}' already exists in this spec", |
337 | 0 | name |
338 | 0 | ))); |
339 | 0 | } |
340 | 0 | } |
341 | 0 | self.spec_repo |
342 | 0 | .update_task(org_id, project_id, spec_id, task_id, request) |
343 | 0 | .await? |
344 | 0 | .ok_or_else(|| ApiError::NotFound(format!("Task {} not found", task_id))) |
345 | 0 | } |
346 | | |
347 | 0 | pub async fn delete_task( |
348 | 0 | &self, |
349 | 0 | org_id: &OrganisationId, |
350 | 0 | project_id: &ProjectId, |
351 | 0 | spec_id: &SpecId, |
352 | 0 | task_id: &TaskId, |
353 | 0 | ) -> Result<(), ApiError> { |
354 | 0 | self.verify_project_exists(org_id, project_id).await?; |
355 | 0 | self.spec_repo |
356 | 0 | .delete_task(org_id, project_id, spec_id, task_id) |
357 | 0 | .await |
358 | 0 | } |
359 | | |
360 | 0 | pub async fn link_spec( |
361 | 0 | &self, |
362 | 0 | org_id: &OrganisationId, |
363 | 0 | project_id: &ProjectId, |
364 | 0 | spec_id: &SpecId, |
365 | 0 | request: &CreateSpecLinkRequest, |
366 | 0 | ) -> Result<SpecLinkView, ApiError> { |
367 | 0 | self.verify_project_exists(org_id, project_id).await?; |
368 | 0 | self.assert_target_exists(org_id, &request.target_id) |
369 | 0 | .await?; |
370 | 0 | self.spec_repo |
371 | 0 | .link_spec_to_target( |
372 | 0 | org_id, |
373 | 0 | project_id, |
374 | 0 | spec_id, |
375 | 0 | &request.target_id, |
376 | 0 | request.target_kind, |
377 | 0 | request.relation, |
378 | 0 | ) |
379 | 0 | .await |
380 | 0 | } |
381 | | |
382 | 0 | pub async fn unlink_spec( |
383 | 0 | &self, |
384 | 0 | org_id: &OrganisationId, |
385 | 0 | project_id: &ProjectId, |
386 | 0 | spec_id: &SpecId, |
387 | 0 | edge_id: &str, |
388 | 0 | ) -> Result<(), ApiError> { |
389 | 0 | self.verify_project_exists(org_id, project_id).await?; |
390 | 0 | self.spec_repo |
391 | 0 | .unlink_spec_from_target(org_id, spec_id, edge_id) |
392 | 0 | .await |
393 | 0 | } |
394 | | |
395 | 0 | pub async fn link_task( |
396 | 0 | &self, |
397 | 0 | org_id: &OrganisationId, |
398 | 0 | project_id: &ProjectId, |
399 | 0 | task_id: &TaskId, |
400 | 0 | request: &CreateTaskLinkRequest, |
401 | 0 | ) -> Result<TaskLinkView, ApiError> { |
402 | 0 | self.verify_project_exists(org_id, project_id).await?; |
403 | 0 | self.assert_target_exists(org_id, &request.target_id) |
404 | 0 | .await?; |
405 | 0 | if matches!(request.relation, TaskLinkRelation::DependsOn) |
406 | 0 | && request.target_kind != crate::models::spec::SpecTaskTargetKind::Task |
407 | | { |
408 | 0 | return Err(ApiError::InvalidRequest( |
409 | 0 | "Task dependency links must target another task".to_string(), |
410 | 0 | )); |
411 | 0 | } |
412 | 0 | self.spec_repo |
413 | 0 | .link_task_to_target(org_id, project_id, task_id, request) |
414 | 0 | .await |
415 | 0 | } |
416 | | |
417 | 0 | pub async fn unlink_task( |
418 | 0 | &self, |
419 | 0 | org_id: &OrganisationId, |
420 | 0 | project_id: &ProjectId, |
421 | 0 | task_id: &TaskId, |
422 | 0 | edge_id: &str, |
423 | 0 | ) -> Result<(), ApiError> { |
424 | 0 | self.verify_project_exists(org_id, project_id).await?; |
425 | 0 | self.spec_repo |
426 | 0 | .unlink_task_from_target(org_id, task_id, edge_id) |
427 | 0 | .await |
428 | 0 | } |
429 | | |
430 | 4 | pub async fn project_summary( |
431 | 4 | &self, |
432 | 4 | org_id: &OrganisationId, |
433 | 4 | project_id: &ProjectId, |
434 | 4 | ) -> Result<ProjectSpecSummary, ApiError> { |
435 | 4 | self.verify_project_exists(org_id, project_id).await?0 ; |
436 | 4 | let specs = self.spec_repo.list_specs(project_id).await?0 ; |
437 | 4 | let mut summary = ProjectSpecSummary { |
438 | 4 | total: specs.len() as i32, |
439 | 4 | draft: 0, |
440 | 4 | active: 0, |
441 | 4 | done: 0, |
442 | 4 | }; |
443 | 4 | for spec0 in specs { |
444 | 0 | match spec.status { |
445 | 0 | SpecStatus::Draft => summary.draft += 1, |
446 | 0 | SpecStatus::Active => summary.active += 1, |
447 | 0 | SpecStatus::Done => summary.done += 1, |
448 | | } |
449 | | } |
450 | 4 | Ok(summary) |
451 | 4 | } |
452 | | |
453 | 0 | pub async fn active_specs_for_targets( |
454 | 0 | &self, |
455 | 0 | org_id: &OrganisationId, |
456 | 0 | project_id: &ProjectId, |
457 | 0 | target_ids: &[String], |
458 | 0 | ) -> Result<Vec<ActiveSpecContextEntry>, ApiError> { |
459 | 0 | let specs = self |
460 | 0 | .spec_repo |
461 | 0 | .list_specs(project_id) |
462 | 0 | .await? |
463 | 0 | .into_iter() |
464 | 0 | .filter(|spec| spec.status == SpecStatus::Active) |
465 | 0 | .collect::<Vec<_>>(); |
466 | 0 | let spec_by_id = specs |
467 | 0 | .into_iter() |
468 | 0 | .map(|spec| (spec.id.clone(), spec)) |
469 | 0 | .collect::<HashMap<_, _>>(); |
470 | 0 | let links = self |
471 | 0 | .spec_repo |
472 | 0 | .list_spec_links_by_project(project_id) |
473 | 0 | .await?; |
474 | 0 | let task_links = self |
475 | 0 | .spec_repo |
476 | 0 | .list_task_links_by_project(project_id) |
477 | 0 | .await?; |
478 | 0 | let mut tasks_by_spec: HashMap<SpecId, Vec<ActiveSpecTaskContext>> = HashMap::new(); |
479 | 0 | let tasks = |
480 | 0 | spec_by_id |
481 | 0 | .keys() |
482 | 0 | .map(|spec_id| async move { |
483 | 0 | (spec_id.clone(), self.spec_repo.list_tasks(spec_id).await) |
484 | 0 | }) |
485 | 0 | .collect::<Vec<_>>(); |
486 | 0 | for future in tasks { |
487 | 0 | let (spec_id, result) = future.await; |
488 | 0 | for task in result? { |
489 | 0 | tasks_by_spec |
490 | 0 | .entry(spec_id.clone()) |
491 | 0 | .or_default() |
492 | 0 | .push(ActiveSpecTaskContext { |
493 | 0 | id: task.id, |
494 | 0 | name: task.name, |
495 | 0 | title: task.title, |
496 | 0 | executor_type: task.executor_type, |
497 | 0 | status: task.status, |
498 | 0 | sort_order: task.sort_order, |
499 | 0 | }); |
500 | 0 | } |
501 | | } |
502 | | |
503 | 0 | let mut task_ids_by_target: HashMap<String, Vec<TaskId>> = HashMap::new(); |
504 | 0 | for (task_id, link) in task_links { |
505 | 0 | if matches!(link.relation, TaskLinkRelation::Modifies) |
506 | 0 | && target_ids.contains(&link.target_id) |
507 | 0 | { |
508 | 0 | task_ids_by_target |
509 | 0 | .entry(link.target_id) |
510 | 0 | .or_default() |
511 | 0 | .push(task_id); |
512 | 0 | } |
513 | | } |
514 | | |
515 | 0 | let mut entries = Vec::new(); |
516 | 0 | for (spec_id, link) in links { |
517 | 0 | if !target_ids.contains(&link.target_id) { |
518 | 0 | continue; |
519 | 0 | } |
520 | 0 | if !matches!( |
521 | 0 | link.relation, |
522 | | SpecLinkRelation::Affects | SpecLinkRelation::ConstrainedBy |
523 | | ) { |
524 | 0 | continue; |
525 | 0 | } |
526 | 0 | let Some(spec) = spec_by_id.get(&spec_id) else { |
527 | 0 | continue; |
528 | | }; |
529 | 0 | let all_tasks = tasks_by_spec.get(&spec_id).cloned().unwrap_or_default(); |
530 | 0 | let linked_task_ids = task_ids_by_target |
531 | 0 | .get(&link.target_id) |
532 | 0 | .cloned() |
533 | 0 | .unwrap_or_default(); |
534 | 0 | let mut relevant_tasks = all_tasks |
535 | 0 | .into_iter() |
536 | 0 | .filter(|task| linked_task_ids.iter().any(|task_id| task.id == *task_id)) |
537 | 0 | .collect::<Vec<_>>(); |
538 | 0 | relevant_tasks.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); |
539 | 0 | entries.push(ActiveSpecContextEntry { |
540 | 0 | spec_id: spec.id.clone(), |
541 | 0 | name: spec.name.clone(), |
542 | 0 | title: spec.title.clone(), |
543 | 0 | spec_type: spec.spec_type, |
544 | 0 | status: spec.status, |
545 | 0 | relation: link.relation, |
546 | 0 | tasks: relevant_tasks, |
547 | 0 | }); |
548 | | } |
549 | | |
550 | 0 | entries.sort_by(|a, b| a.name.cmp(&b.name)); |
551 | 0 | entries.dedup_by(|a, b| a.spec_id == b.spec_id && a.relation == b.relation); |
552 | 0 | let _ = org_id; |
553 | 0 | Ok(entries) |
554 | 0 | } |
555 | | |
556 | 0 | async fn assert_target_exists( |
557 | 0 | &self, |
558 | 0 | org_id: &OrganisationId, |
559 | 0 | target_id: &str, |
560 | 0 | ) -> Result<(), ApiError> { |
561 | 0 | let node_id = NodeId::try_from(target_id.to_string())?; |
562 | 0 | let found = self |
563 | 0 | .graph_repo |
564 | 0 | .get_node(&NodeRef { |
565 | 0 | organisation_id: org_id.clone(), |
566 | 0 | node_id, |
567 | 0 | }) |
568 | 0 | .await?; |
569 | 0 | if found.is_none() { |
570 | 0 | return Err(ApiError::NotFound(format!( |
571 | 0 | "Target node {} not found", |
572 | 0 | target_id |
573 | 0 | ))); |
574 | 0 | } |
575 | 0 | Ok(()) |
576 | 0 | } |
577 | | |
578 | 0 | async fn build_overlap_warnings( |
579 | 0 | &self, |
580 | 0 | org_id: &OrganisationId, |
581 | 0 | project_id: &ProjectId, |
582 | 0 | current_spec: &Spec, |
583 | 0 | links: &[SpecLinkView], |
584 | 0 | ) -> Result<Vec<SpecOverlapWarning>, ApiError> { |
585 | 0 | let other_specs = self |
586 | 0 | .spec_repo |
587 | 0 | .list_specs(project_id) |
588 | 0 | .await? |
589 | 0 | .into_iter() |
590 | 0 | .filter(|spec| spec.status == SpecStatus::Active && spec.id != current_spec.id) |
591 | 0 | .collect::<Vec<_>>(); |
592 | 0 | if other_specs.is_empty() { |
593 | 0 | return Ok(vec![]); |
594 | 0 | } |
595 | | |
596 | 0 | let other_spec_by_id = other_specs |
597 | 0 | .into_iter() |
598 | 0 | .map(|spec| (spec.id.clone(), spec)) |
599 | 0 | .collect::<HashMap<_, _>>(); |
600 | 0 | let other_links = self |
601 | 0 | .spec_repo |
602 | 0 | .list_spec_links_by_project(project_id) |
603 | 0 | .await?; |
604 | 0 | let mut warnings = Vec::new(); |
605 | 0 | for current_link in links |
606 | 0 | .iter() |
607 | 0 | .filter(|link| matches!(link.relation, SpecLinkRelation::Affects)) |
608 | | { |
609 | 0 | for (spec_id, link) in &other_links { |
610 | 0 | if current_link.target_id != link.target_id |
611 | 0 | || !matches!(link.relation, SpecLinkRelation::Affects) |
612 | | { |
613 | 0 | continue; |
614 | 0 | } |
615 | 0 | let Some(overlapping_spec) = other_spec_by_id.get(spec_id) else { |
616 | 0 | continue; |
617 | | }; |
618 | 0 | warnings.push(SpecOverlapWarning { |
619 | 0 | target_id: current_link.target_id.clone(), |
620 | 0 | target_name: current_link.target_name.clone(), |
621 | 0 | overlapping_spec_id: overlapping_spec.id.clone(), |
622 | 0 | overlapping_spec_name: overlapping_spec.name.clone(), |
623 | 0 | overlapping_spec_title: overlapping_spec.title.clone(), |
624 | 0 | }); |
625 | | } |
626 | | } |
627 | 0 | let _ = org_id; |
628 | 0 | Ok(warnings) |
629 | 0 | } |
630 | | } |
631 | | |
632 | 0 | fn is_kebab_case_segment(segment: &str) -> bool { |
633 | 0 | segment |
634 | 0 | .chars() |
635 | 0 | .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') |
636 | 0 | } |