Skip to main content

winterbaume_codebuild/
handlers.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use serde_json::{Value, json};
6use winterbaume_core::{
7    BackendState, DEFAULT_ACCOUNT_ID, MockRequest, MockResponse, MockService, StateChangeNotifier,
8    StatefulService, json_error_response,
9};
10
11use crate::state::{CodeBuildError, CodeBuildState};
12use crate::views::CodeBuildStateView;
13use crate::wire;
14
15pub struct CodeBuildService {
16    pub(crate) state: Arc<BackendState<CodeBuildState>>,
17    pub(crate) notifier: StateChangeNotifier<CodeBuildStateView>,
18}
19
20impl CodeBuildService {
21    pub fn new() -> Self {
22        Self {
23            state: Arc::new(BackendState::new()),
24            notifier: StateChangeNotifier::new(),
25        }
26    }
27}
28
29impl Default for CodeBuildService {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl MockService for CodeBuildService {
36    fn service_name(&self) -> &str {
37        "codebuild"
38    }
39
40    fn url_patterns(&self) -> Vec<&str> {
41        vec![
42            r"https?://codebuild\..*\.amazonaws\.com",
43            r"https?://codebuild\.amazonaws\.com",
44        ]
45    }
46
47    fn handle(
48        &self,
49        request: MockRequest,
50    ) -> Pin<Box<dyn Future<Output = MockResponse> + Send + '_>> {
51        Box::pin(async move { self.dispatch(request).await })
52    }
53}
54
55impl CodeBuildService {
56    async fn dispatch(&self, request: MockRequest) -> MockResponse {
57        let region = winterbaume_core::auth::extract_region_from_uri(&request.uri);
58        let account_id = DEFAULT_ACCOUNT_ID;
59
60        let action = request
61            .headers
62            .get("x-amz-target")
63            .and_then(|v| v.to_str().ok())
64            .and_then(|v| v.split('.').next_back())
65            .map(|s| s.to_string());
66
67        let action = match action {
68            Some(a) => a,
69            None => {
70                return json_error_response(400, "MissingAction", "Missing X-Amz-Target header");
71            }
72        };
73
74        // Validate the body is well-formed JSON up-front; the typed deserialisers in
75        // `wire` re-parse the bytes per operation.
76        if serde_json::from_slice::<Value>(&request.body).is_err() {
77            return json_error_response(400, "SerializationException", "Invalid JSON body");
78        }
79        let body_bytes: &[u8] = &request.body;
80
81        let state = self.state.get(account_id, &region);
82
83        let response = match action.as_str() {
84            "CreateProject" => {
85                self.handle_create_project(&state, body_bytes, account_id, &region)
86                    .await
87            }
88            "BatchGetProjects" => self.handle_batch_get_projects(&state, body_bytes).await,
89            "DeleteProject" => self.handle_delete_project(&state, body_bytes).await,
90            "ListProjects" => self.handle_list_projects(&state).await,
91            "BatchDeleteBuilds" => self.handle_batch_delete_builds(&state, body_bytes).await,
92            "BatchGetBuilds" => self.handle_batch_get_builds(&state, body_bytes).await,
93            "UpdateProject" => {
94                self.handle_update_project(&state, body_bytes, account_id)
95                    .await
96            }
97            "RetryBuild" => {
98                self.handle_retry_build(&state, body_bytes, account_id, &region)
99                    .await
100            }
101            "CreateWebhook" => {
102                self.handle_create_webhook(&state, body_bytes, account_id, &region)
103                    .await
104            }
105            "UpdateWebhook" => self.handle_update_webhook(&state, body_bytes).await,
106            "DeleteWebhook" => self.handle_delete_webhook(&state, body_bytes).await,
107            "ImportSourceCredentials" => {
108                self.handle_import_source_credentials(&state, body_bytes, account_id, &region)
109                    .await
110            }
111            "ListSourceCredentials" => self.handle_list_source_credentials(&state).await,
112            "DeleteSourceCredentials" => {
113                self.handle_delete_source_credentials(&state, body_bytes)
114                    .await
115            }
116            "PutResourcePolicy" => self.handle_put_resource_policy(&state, body_bytes).await,
117            "GetResourcePolicy" => self.handle_get_resource_policy(&state, body_bytes).await,
118            "DeleteResourcePolicy" => self.handle_delete_resource_policy(&state, body_bytes).await,
119            "InvalidateProjectCache" => {
120                self.handle_invalidate_project_cache(&state, body_bytes)
121                    .await
122            }
123            // STUB[no-engine]: DescribeTestCases requires real build execution and test result
124            //   collection; the mock has no test-report engine to populate case results.
125            "DescribeTestCases" => {
126                wire::serialize_describe_test_cases_response(&wire::DescribeTestCasesOutput {
127                    ..Default::default()
128                })
129            }
130            "ListReportGroups" => self.handle_list_report_groups(&state).await,
131            // --- Unimplemented operations (auto-generated stubs) ---
132            "BatchGetBuildBatches" => json_error_response(
133                501,
134                "NotImplementedError",
135                "BatchGetBuildBatches is not yet implemented in winterbaume-codebuild",
136            ),
137            "BatchGetCommandExecutions" => json_error_response(
138                501,
139                "NotImplementedError",
140                "BatchGetCommandExecutions is not yet implemented in winterbaume-codebuild",
141            ),
142            "BatchGetFleets" => json_error_response(
143                501,
144                "NotImplementedError",
145                "BatchGetFleets is not yet implemented in winterbaume-codebuild",
146            ),
147            "BatchGetReportGroups" => {
148                self.handle_batch_get_report_groups(&state, body_bytes)
149                    .await
150            }
151            "BatchGetReports" => json_error_response(
152                501,
153                "NotImplementedError",
154                "BatchGetReports is not yet implemented in winterbaume-codebuild",
155            ),
156            "BatchGetSandboxes" => json_error_response(
157                501,
158                "NotImplementedError",
159                "BatchGetSandboxes is not yet implemented in winterbaume-codebuild",
160            ),
161            "CreateFleet" => json_error_response(
162                501,
163                "NotImplementedError",
164                "CreateFleet is not yet implemented in winterbaume-codebuild",
165            ),
166            "CreateReportGroup" => {
167                self.handle_create_report_group(&state, body_bytes, account_id, &region)
168                    .await
169            }
170            "DeleteBuildBatch" => json_error_response(
171                501,
172                "NotImplementedError",
173                "DeleteBuildBatch is not yet implemented in winterbaume-codebuild",
174            ),
175            "DeleteFleet" => json_error_response(
176                501,
177                "NotImplementedError",
178                "DeleteFleet is not yet implemented in winterbaume-codebuild",
179            ),
180            "DeleteReport" => json_error_response(
181                501,
182                "NotImplementedError",
183                "DeleteReport is not yet implemented in winterbaume-codebuild",
184            ),
185            "DeleteReportGroup" => self.handle_delete_report_group(&state, body_bytes).await,
186            "DescribeCodeCoverages" => json_error_response(
187                501,
188                "NotImplementedError",
189                "DescribeCodeCoverages is not yet implemented in winterbaume-codebuild",
190            ),
191            "GetReportGroupTrend" => json_error_response(
192                501,
193                "NotImplementedError",
194                "GetReportGroupTrend is not yet implemented in winterbaume-codebuild",
195            ),
196            "ListBuildBatches" => json_error_response(
197                501,
198                "NotImplementedError",
199                "ListBuildBatches is not yet implemented in winterbaume-codebuild",
200            ),
201            "ListBuildBatchesForProject" => json_error_response(
202                501,
203                "NotImplementedError",
204                "ListBuildBatchesForProject is not yet implemented in winterbaume-codebuild",
205            ),
206            "ListBuilds" => self.handle_list_builds(&state).await,
207            "ListBuildsForProject" => {
208                self.handle_list_builds_for_project(&state, body_bytes, account_id, &region)
209                    .await
210            }
211            "ListCommandExecutionsForSandbox" => json_error_response(
212                501,
213                "NotImplementedError",
214                "ListCommandExecutionsForSandbox is not yet implemented in winterbaume-codebuild",
215            ),
216            "ListCuratedEnvironmentImages" => json_error_response(
217                501,
218                "NotImplementedError",
219                "ListCuratedEnvironmentImages is not yet implemented in winterbaume-codebuild",
220            ),
221            "ListFleets" => json_error_response(
222                501,
223                "NotImplementedError",
224                "ListFleets is not yet implemented in winterbaume-codebuild",
225            ),
226            "ListReports" => json_error_response(
227                501,
228                "NotImplementedError",
229                "ListReports is not yet implemented in winterbaume-codebuild",
230            ),
231            "ListReportsForReportGroup" => {
232                self.handle_list_reports_for_report_group(&state, body_bytes)
233                    .await
234            }
235            "ListSandboxes" => json_error_response(
236                501,
237                "NotImplementedError",
238                "ListSandboxes is not yet implemented in winterbaume-codebuild",
239            ),
240            "ListSandboxesForProject" => json_error_response(
241                501,
242                "NotImplementedError",
243                "ListSandboxesForProject is not yet implemented in winterbaume-codebuild",
244            ),
245            "ListSharedProjects" => json_error_response(
246                501,
247                "NotImplementedError",
248                "ListSharedProjects is not yet implemented in winterbaume-codebuild",
249            ),
250            "ListSharedReportGroups" => json_error_response(
251                501,
252                "NotImplementedError",
253                "ListSharedReportGroups is not yet implemented in winterbaume-codebuild",
254            ),
255            "RetryBuildBatch" => json_error_response(
256                501,
257                "NotImplementedError",
258                "RetryBuildBatch is not yet implemented in winterbaume-codebuild",
259            ),
260            "StartBuild" => {
261                self.handle_start_build(&state, body_bytes, account_id, &region)
262                    .await
263            }
264            "StartBuildBatch" => json_error_response(
265                501,
266                "NotImplementedError",
267                "StartBuildBatch is not yet implemented in winterbaume-codebuild",
268            ),
269            "StartCommandExecution" => json_error_response(
270                501,
271                "NotImplementedError",
272                "StartCommandExecution is not yet implemented in winterbaume-codebuild",
273            ),
274            "StartSandbox" => json_error_response(
275                501,
276                "NotImplementedError",
277                "StartSandbox is not yet implemented in winterbaume-codebuild",
278            ),
279            "StartSandboxConnection" => json_error_response(
280                501,
281                "NotImplementedError",
282                "StartSandboxConnection is not yet implemented in winterbaume-codebuild",
283            ),
284            "StopBuild" => self.handle_stop_build(&state, body_bytes).await,
285            "StopBuildBatch" => json_error_response(
286                501,
287                "NotImplementedError",
288                "StopBuildBatch is not yet implemented in winterbaume-codebuild",
289            ),
290            "StopSandbox" => json_error_response(
291                501,
292                "NotImplementedError",
293                "StopSandbox is not yet implemented in winterbaume-codebuild",
294            ),
295            "UpdateFleet" => json_error_response(
296                501,
297                "NotImplementedError",
298                "UpdateFleet is not yet implemented in winterbaume-codebuild",
299            ),
300            "UpdateProjectVisibility" => json_error_response(
301                501,
302                "NotImplementedError",
303                "UpdateProjectVisibility is not yet implemented in winterbaume-codebuild",
304            ),
305            "UpdateReportGroup" => self.handle_update_report_group(&state, body_bytes).await,
306            _ => json_error_response(400, "InvalidAction", &format!("Unknown operation {action}")),
307        };
308
309        if response.status / 100 == 2 {
310            self.notify_state_changed(account_id, &region).await;
311        }
312        response
313    }
314
315    async fn handle_create_project(
316        &self,
317        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
318        body: &[u8],
319        account_id: &str,
320        region: &str,
321    ) -> MockResponse {
322        let input = match wire::deserialize_create_project_request(body) {
323            Ok(v) => v,
324            Err(e) => return json_error_response(400, "ValidationException", &e),
325        };
326        if input.name.is_empty() {
327            return json_error_response(400, "InvalidInputException", "name is required");
328        }
329
330        let description = input.description.unwrap_or_default();
331        let source_type = if input.source.r#type.is_empty() {
332            "NO_SOURCE".to_string()
333        } else {
334            input.source.r#type.clone()
335        };
336        let source_location = input.source.location.unwrap_or_default();
337        let artifact_type = if input.artifacts.r#type.is_empty() {
338            "NO_ARTIFACTS".to_string()
339        } else {
340            input.artifacts.r#type.clone()
341        };
342        let artifact_location = input.artifacts.location;
343        let env_type = if input.environment.r#type.is_empty() {
344            "LINUX_CONTAINER".to_string()
345        } else {
346            input.environment.r#type.clone()
347        };
348        let env_image = if input.environment.image.is_empty() {
349            "aws/codebuild/standard:5.0".to_string()
350        } else {
351            input.environment.image.clone()
352        };
353        let env_compute = if input.environment.compute_type.is_empty() {
354            "BUILD_GENERAL1_SMALL".to_string()
355        } else {
356            input.environment.compute_type.clone()
357        };
358        let service_role = input.service_role;
359        let tags = tags_from_wire(input.tags.unwrap_or_default());
360
361        let mut state = state.write().await;
362        match state.create_project(
363            &input.name,
364            &description,
365            &source_type,
366            &source_location,
367            &artifact_type,
368            artifact_location.as_deref(),
369            &env_type,
370            &env_image,
371            &env_compute,
372            &service_role,
373            tags,
374            account_id,
375            region,
376        ) {
377            Ok(project) => wire::serialize_create_project_response(&wire::CreateProjectOutput {
378                project: Some(project_to_wire(project)),
379            }),
380            Err(e) => codebuild_error_response(&e),
381        }
382    }
383
384    async fn handle_batch_get_projects(
385        &self,
386        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
387        body: &[u8],
388    ) -> MockResponse {
389        let input = match wire::deserialize_batch_get_projects_request(body) {
390            Ok(v) => v,
391            Err(e) => return json_error_response(400, "ValidationException", &e),
392        };
393        let names = input.names;
394
395        let state = state.read().await;
396        let projects = state.batch_get_projects(&names);
397        let found: Vec<wire::Project> = projects.iter().map(|p| project_to_wire(p)).collect();
398        let found_names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
399        let found_arns: Vec<&str> = projects.iter().map(|p| p.arn.as_str()).collect();
400        let not_found: Vec<String> = names
401            .iter()
402            .filter(|n| !found_names.contains(&n.as_str()) && !found_arns.contains(&n.as_str()))
403            .cloned()
404            .collect();
405
406        wire::serialize_batch_get_projects_response(&wire::BatchGetProjectsOutput {
407            projects: Some(found),
408            projects_not_found: Some(not_found),
409        })
410    }
411
412    async fn handle_delete_project(
413        &self,
414        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
415        body: &[u8],
416    ) -> MockResponse {
417        let input = match wire::deserialize_delete_project_request(body) {
418            Ok(v) => v,
419            Err(e) => return json_error_response(400, "ValidationException", &e),
420        };
421        if input.name.is_empty() {
422            return json_error_response(400, "InvalidInputException", "name is required");
423        }
424
425        let mut state = state.write().await;
426        match state.delete_project(&input.name) {
427            Ok(()) => wire::serialize_delete_project_response(&wire::DeleteProjectOutput {}),
428            Err(e) => codebuild_error_response(&e),
429        }
430    }
431
432    async fn handle_batch_get_builds(
433        &self,
434        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
435        body: &[u8],
436    ) -> MockResponse {
437        let input = match wire::deserialize_batch_get_builds_request(body) {
438            Ok(v) => v,
439            Err(e) => return json_error_response(400, "ValidationException", &e),
440        };
441        let ids = input.ids;
442
443        let state = state.read().await;
444        let builds = match state.batch_get_builds(&ids) {
445            Ok(b) => b,
446            Err(e) => return codebuild_error_response(&e),
447        };
448        let found_ids: Vec<&str> = builds.iter().map(|b| b.id.as_str()).collect();
449        let not_found: Vec<String> = ids
450            .iter()
451            .filter(|id| !found_ids.contains(&id.as_str()))
452            .cloned()
453            .collect();
454
455        let found: Vec<wire::Build> = builds.iter().map(build_to_wire).collect();
456
457        wire::serialize_batch_get_builds_response(&wire::BatchGetBuildsOutput {
458            builds: Some(found),
459            builds_not_found: Some(not_found),
460        })
461    }
462
463    async fn handle_list_projects(
464        &self,
465        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
466    ) -> MockResponse {
467        let state = state.read().await;
468        let names: Vec<String> = state
469            .list_projects()
470            .into_iter()
471            .map(|s| s.to_string())
472            .collect();
473
474        wire::serialize_list_projects_response(&wire::ListProjectsOutput {
475            next_token: None,
476            projects: Some(names),
477        })
478    }
479
480    async fn handle_start_build(
481        &self,
482        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
483        body: &[u8],
484        account_id: &str,
485        region: &str,
486    ) -> MockResponse {
487        let input = match wire::deserialize_start_build_request(body) {
488            Ok(v) => v,
489            Err(e) => return json_error_response(400, "ValidationException", &e),
490        };
491        if input.project_name.is_empty() {
492            return json_error_response(400, "InvalidInputException", "projectName is required");
493        }
494
495        let mut state = state.write().await;
496        match state.start_build(
497            &input.project_name,
498            input.source_version.as_deref(),
499            account_id,
500            region,
501        ) {
502            Ok(build) => wire::serialize_start_build_response(&wire::StartBuildOutput {
503                build: Some(build_to_wire(build)),
504            }),
505            Err(e) => codebuild_error_response(&e),
506        }
507    }
508
509    async fn handle_stop_build(
510        &self,
511        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
512        body: &[u8],
513    ) -> MockResponse {
514        let input = match wire::deserialize_stop_build_request(body) {
515            Ok(v) => v,
516            Err(e) => return json_error_response(400, "ValidationException", &e),
517        };
518        if input.id.is_empty() {
519            return json_error_response(400, "InvalidInputException", "id is required");
520        }
521
522        let mut state = state.write().await;
523        match state.stop_build(&input.id) {
524            Ok(build) => wire::serialize_stop_build_response(&wire::StopBuildOutput {
525                build: Some(build_to_wire(build)),
526            }),
527            Err(e) => codebuild_error_response(&e),
528        }
529    }
530
531    async fn handle_list_builds(
532        &self,
533        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
534    ) -> MockResponse {
535        let state = state.read().await;
536        let ids: Vec<String> = state
537            .list_builds()
538            .into_iter()
539            .map(|s| s.to_string())
540            .collect();
541
542        wire::serialize_list_builds_response(&wire::ListBuildsOutput {
543            ids: Some(ids),
544            next_token: None,
545        })
546    }
547
548    async fn handle_list_builds_for_project(
549        &self,
550        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
551        body: &[u8],
552        account_id: &str,
553        region: &str,
554    ) -> MockResponse {
555        let input = match wire::deserialize_list_builds_for_project_request(body) {
556            Ok(v) => v,
557            Err(e) => return json_error_response(400, "ValidationException", &e),
558        };
559        if input.project_name.is_empty() {
560            return json_error_response(400, "InvalidInputException", "projectName is required");
561        }
562
563        let state = state.read().await;
564        if !state.projects.contains_key(&input.project_name) {
565            let project_name = &input.project_name;
566            return json_error_response(
567                400,
568                "ResourceNotFoundException",
569                &format!(
570                    "The provided project arn:aws:codebuild:{region}:{account_id}:project/{project_name} does not exist"
571                ),
572            );
573        }
574        let ids: Vec<String> = state
575            .list_builds_for_project(&input.project_name)
576            .into_iter()
577            .map(|s| s.to_string())
578            .collect();
579
580        wire::serialize_list_builds_for_project_response(&wire::ListBuildsForProjectOutput {
581            ids: Some(ids),
582            next_token: None,
583        })
584    }
585
586    async fn handle_batch_delete_builds(
587        &self,
588        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
589        body: &[u8],
590    ) -> MockResponse {
591        let input = match wire::deserialize_batch_delete_builds_request(body) {
592            Ok(v) => v,
593            Err(e) => return json_error_response(400, "ValidationException", &e),
594        };
595
596        let mut state = state.write().await;
597        let deleted = state.batch_delete_builds(&input.ids);
598
599        wire::serialize_batch_delete_builds_response(&wire::BatchDeleteBuildsOutput {
600            builds_deleted: Some(deleted),
601            builds_not_deleted: None,
602        })
603    }
604
605    async fn handle_update_project(
606        &self,
607        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
608        body: &[u8],
609        account_id: &str,
610    ) -> MockResponse {
611        let input = match wire::deserialize_update_project_request(body) {
612            Ok(v) => v,
613            Err(e) => return json_error_response(400, "ValidationException", &e),
614        };
615        if input.name.is_empty() {
616            return json_error_response(400, "InvalidInputException", "name is required");
617        }
618
619        let description = input.description;
620        let source_type = input
621            .source
622            .as_ref()
623            .map(|s| s.r#type.clone())
624            .filter(|t| !t.is_empty());
625        let source_location = input.source.as_ref().and_then(|s| s.location.clone());
626        let artifact_type = input
627            .artifacts
628            .as_ref()
629            .map(|a| a.r#type.clone())
630            .filter(|t| !t.is_empty());
631        // Detect "artifacts key was sent" via outer Option of artifacts field.
632        let artifact_location: Option<Option<String>> =
633            input.artifacts.as_ref().map(|a| a.location.clone());
634        let env_type = input
635            .environment
636            .as_ref()
637            .map(|e| e.r#type.clone())
638            .filter(|t| !t.is_empty());
639        let env_image = input
640            .environment
641            .as_ref()
642            .map(|e| e.image.clone())
643            .filter(|t| !t.is_empty());
644        let env_compute = input
645            .environment
646            .as_ref()
647            .map(|e| e.compute_type.clone())
648            .filter(|t| !t.is_empty());
649        let service_role = input.service_role;
650        let tags = input.tags.map(tags_from_wire);
651
652        let mut state = state.write().await;
653        match state.update_project(
654            &input.name,
655            description.as_deref(),
656            source_type.as_deref(),
657            source_location.as_deref(),
658            artifact_type.as_deref(),
659            artifact_location.as_ref().map(|loc| loc.as_deref()),
660            env_type.as_deref(),
661            env_image.as_deref(),
662            env_compute.as_deref(),
663            service_role.as_deref(),
664            tags,
665            account_id,
666        ) {
667            Ok(project) => wire::serialize_update_project_response(&wire::UpdateProjectOutput {
668                project: Some(project_to_wire(project)),
669            }),
670            Err(e) => codebuild_error_response(&e),
671        }
672    }
673
674    async fn handle_retry_build(
675        &self,
676        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
677        body: &[u8],
678        account_id: &str,
679        region: &str,
680    ) -> MockResponse {
681        let input = match wire::deserialize_retry_build_request(body) {
682            Ok(v) => v,
683            Err(e) => return json_error_response(400, "ValidationException", &e),
684        };
685        let build_id = match input.id.as_deref() {
686            Some(id) if !id.is_empty() => id,
687            _ => return json_error_response(400, "InvalidInputException", "id is required"),
688        };
689
690        let mut state = state.write().await;
691        match state.retry_build(build_id, account_id, region) {
692            Ok(build) => wire::serialize_retry_build_response(&wire::RetryBuildOutput {
693                build: Some(build_to_wire(build)),
694            }),
695            Err(e) => codebuild_error_response(&e),
696        }
697    }
698
699    async fn handle_create_webhook(
700        &self,
701        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
702        body: &[u8],
703        account_id: &str,
704        region: &str,
705    ) -> MockResponse {
706        let input = match wire::deserialize_create_webhook_request(body) {
707            Ok(v) => v,
708            Err(e) => return json_error_response(400, "ValidationException", &e),
709        };
710        if input.project_name.is_empty() {
711            return json_error_response(400, "InvalidInputException", "projectName is required");
712        }
713
714        let mut state = state.write().await;
715        match state.create_webhook(
716            &input.project_name,
717            input.branch_filter.as_deref(),
718            input.build_type.as_deref(),
719            account_id,
720            region,
721        ) {
722            Ok(wh) => wire::serialize_create_webhook_response(&wire::CreateWebhookOutput {
723                webhook: Some(webhook_to_wire(wh)),
724            }),
725            Err(e) => codebuild_error_response(&e),
726        }
727    }
728
729    async fn handle_update_webhook(
730        &self,
731        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
732        body: &[u8],
733    ) -> MockResponse {
734        let input = match wire::deserialize_update_webhook_request(body) {
735            Ok(v) => v,
736            Err(e) => return json_error_response(400, "ValidationException", &e),
737        };
738        if input.project_name.is_empty() {
739            return json_error_response(400, "InvalidInputException", "projectName is required");
740        }
741
742        let mut state = state.write().await;
743        match state.update_webhook(
744            &input.project_name,
745            input.branch_filter.as_deref(),
746            input.build_type.as_deref(),
747        ) {
748            Ok(wh) => wire::serialize_update_webhook_response(&wire::UpdateWebhookOutput {
749                webhook: Some(webhook_to_wire(wh)),
750            }),
751            Err(e) => codebuild_error_response(&e),
752        }
753    }
754
755    async fn handle_delete_webhook(
756        &self,
757        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
758        body: &[u8],
759    ) -> MockResponse {
760        let input = match wire::deserialize_delete_webhook_request(body) {
761            Ok(v) => v,
762            Err(e) => return json_error_response(400, "ValidationException", &e),
763        };
764        if input.project_name.is_empty() {
765            return json_error_response(400, "InvalidInputException", "projectName is required");
766        }
767
768        let mut state = state.write().await;
769        match state.delete_webhook(&input.project_name) {
770            Ok(()) => wire::serialize_delete_webhook_response(&wire::DeleteWebhookOutput {}),
771            Err(e) => codebuild_error_response(&e),
772        }
773    }
774
775    async fn handle_import_source_credentials(
776        &self,
777        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
778        body: &[u8],
779        account_id: &str,
780        region: &str,
781    ) -> MockResponse {
782        let input = match wire::deserialize_import_source_credentials_request(body) {
783            Ok(v) => v,
784            Err(e) => return json_error_response(400, "ValidationException", &e),
785        };
786        if input.token.is_empty() {
787            return json_error_response(400, "InvalidInputException", "token is required");
788        }
789        if input.server_type.is_empty() {
790            return json_error_response(400, "InvalidInputException", "serverType is required");
791        }
792        if input.auth_type.is_empty() {
793            return json_error_response(400, "InvalidInputException", "authType is required");
794        }
795
796        let mut state = state.write().await;
797        match state.import_source_credentials(
798            &input.token,
799            &input.server_type,
800            &input.auth_type,
801            input.username.as_deref(),
802            account_id,
803            region,
804        ) {
805            Ok(cred) => wire::serialize_import_source_credentials_response(
806                &wire::ImportSourceCredentialsOutput {
807                    arn: Some(cred.arn.clone()),
808                },
809            ),
810            Err(e) => codebuild_error_response(&e),
811        }
812    }
813
814    async fn handle_list_source_credentials(
815        &self,
816        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
817    ) -> MockResponse {
818        let state = state.read().await;
819        let creds: Vec<wire::SourceCredentialsInfo> = state
820            .list_source_credentials()
821            .iter()
822            .map(|c| wire::SourceCredentialsInfo {
823                arn: Some(c.arn.clone()),
824                server_type: Some(c.server_type.clone()),
825                auth_type: Some(c.auth_type.clone()),
826                resource: c.resource.clone(),
827            })
828            .collect();
829
830        wire::serialize_list_source_credentials_response(&wire::ListSourceCredentialsOutput {
831            source_credentials_infos: Some(creds),
832        })
833    }
834
835    async fn handle_delete_source_credentials(
836        &self,
837        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
838        body: &[u8],
839    ) -> MockResponse {
840        let input = match wire::deserialize_delete_source_credentials_request(body) {
841            Ok(v) => v,
842            Err(e) => return json_error_response(400, "ValidationException", &e),
843        };
844        if input.arn.is_empty() {
845            return json_error_response(400, "InvalidInputException", "arn is required");
846        }
847
848        let mut state = state.write().await;
849        match state.delete_source_credentials(&input.arn) {
850            Ok(()) => wire::serialize_delete_source_credentials_response(
851                &wire::DeleteSourceCredentialsOutput {
852                    arn: Some(input.arn.clone()),
853                },
854            ),
855            Err(e) => codebuild_error_response(&e),
856        }
857    }
858
859    async fn handle_put_resource_policy(
860        &self,
861        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
862        body: &[u8],
863    ) -> MockResponse {
864        let input = match wire::deserialize_put_resource_policy_request(body) {
865            Ok(v) => v,
866            Err(e) => return json_error_response(400, "ValidationException", &e),
867        };
868        if input.resource_arn.is_empty() {
869            return json_error_response(400, "InvalidInputException", "resourceArn is required");
870        }
871        if input.policy.is_empty() {
872            return json_error_response(400, "InvalidInputException", "policy is required");
873        }
874
875        let mut state = state.write().await;
876        match state.put_resource_policy(&input.resource_arn, &input.policy) {
877            Ok(arn) => {
878                wire::serialize_put_resource_policy_response(&wire::PutResourcePolicyOutput {
879                    resource_arn: Some(arn.to_string()),
880                })
881            }
882            Err(e) => codebuild_error_response(&e),
883        }
884    }
885
886    async fn handle_get_resource_policy(
887        &self,
888        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
889        body: &[u8],
890    ) -> MockResponse {
891        let input = match wire::deserialize_get_resource_policy_request(body) {
892            Ok(v) => v,
893            Err(e) => return json_error_response(400, "ValidationException", &e),
894        };
895        if input.resource_arn.is_empty() {
896            return json_error_response(400, "InvalidInputException", "resourceArn is required");
897        }
898
899        let state = state.read().await;
900        match state.get_resource_policy(&input.resource_arn) {
901            Ok(policy) => {
902                wire::serialize_get_resource_policy_response(&wire::GetResourcePolicyOutput {
903                    policy: Some(policy),
904                })
905            }
906            Err(e) => codebuild_error_response(&e),
907        }
908    }
909
910    async fn handle_delete_resource_policy(
911        &self,
912        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
913        body: &[u8],
914    ) -> MockResponse {
915        let input = match wire::deserialize_delete_resource_policy_request(body) {
916            Ok(v) => v,
917            Err(e) => return json_error_response(400, "ValidationException", &e),
918        };
919        if input.resource_arn.is_empty() {
920            return json_error_response(400, "InvalidInputException", "resourceArn is required");
921        }
922
923        let mut state = state.write().await;
924        match state.delete_resource_policy(&input.resource_arn) {
925            Ok(()) => wire::serialize_delete_resource_policy_response(
926                &wire::DeleteResourcePolicyOutput {},
927            ),
928            Err(e) => codebuild_error_response(&e),
929        }
930    }
931
932    async fn handle_invalidate_project_cache(
933        &self,
934        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
935        body: &[u8],
936    ) -> MockResponse {
937        let input = match wire::deserialize_invalidate_project_cache_request(body) {
938            Ok(v) => v,
939            Err(e) => return json_error_response(400, "ValidationException", &e),
940        };
941        // Cache invalidation is a no-op in the mock but validates the project exists.
942        if !input.project_name.is_empty() {
943            let s = state.read().await;
944            if !s.projects.contains_key(&input.project_name) {
945                let project_name = input.project_name;
946                return json_error_response(
947                    400,
948                    "ResourceNotFoundException",
949                    &format!("Project {project_name} not found"),
950                );
951            }
952        }
953        wire::serialize_invalidate_project_cache_response(&wire::InvalidateProjectCacheOutput {})
954    }
955
956    // ── Report Group handlers ──
957
958    async fn handle_create_report_group(
959        &self,
960        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
961        body: &[u8],
962        account_id: &str,
963        region: &str,
964    ) -> MockResponse {
965        let input = match wire::deserialize_create_report_group_request(body) {
966            Ok(v) => v,
967            Err(e) => return json_error_response(400, "ValidationException", &e),
968        };
969        if input.name.is_empty() {
970            return json_error_response(400, "InvalidInputException", "name is required");
971        }
972        let report_type = if input.r#type.is_empty() {
973            "TEST"
974        } else {
975            input.r#type.as_str()
976        };
977        let export_config_type = input.export_config.export_config_type.as_deref();
978        let tags = tags_from_wire(input.tags.unwrap_or_default());
979
980        let mut state = state.write().await;
981        match state.create_report_group(
982            &input.name,
983            report_type,
984            export_config_type,
985            tags,
986            account_id,
987            region,
988        ) {
989            Ok(rg) => {
990                wire::serialize_create_report_group_response(&wire::CreateReportGroupOutput {
991                    report_group: Some(report_group_to_wire(rg)),
992                })
993            }
994            Err(e) => codebuild_error_response(&e),
995        }
996    }
997
998    async fn handle_batch_get_report_groups(
999        &self,
1000        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
1001        body: &[u8],
1002    ) -> MockResponse {
1003        let input = match wire::deserialize_batch_get_report_groups_request(body) {
1004            Ok(v) => v,
1005            Err(e) => return json_error_response(400, "ValidationException", &e),
1006        };
1007        let arns = input.report_group_arns;
1008
1009        let state = state.read().await;
1010        let found = state.batch_get_report_groups(&arns);
1011        let found_arns: Vec<&str> = found.iter().map(|rg| rg.arn.as_str()).collect();
1012        let not_found: Vec<String> = arns
1013            .iter()
1014            .filter(|arn| !found_arns.contains(&arn.as_str()))
1015            .cloned()
1016            .collect();
1017
1018        wire::serialize_batch_get_report_groups_response(&wire::BatchGetReportGroupsOutput {
1019            report_groups: Some(found.iter().map(|rg| report_group_to_wire(rg)).collect()),
1020            report_groups_not_found: Some(not_found),
1021        })
1022    }
1023
1024    async fn handle_list_report_groups(
1025        &self,
1026        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
1027    ) -> MockResponse {
1028        let state = state.read().await;
1029        let arns: Vec<String> = state
1030            .list_report_groups()
1031            .into_iter()
1032            .map(|s| s.to_string())
1033            .collect();
1034
1035        wire::serialize_list_report_groups_response(&wire::ListReportGroupsOutput {
1036            report_groups: Some(arns),
1037            next_token: None,
1038        })
1039    }
1040
1041    async fn handle_delete_report_group(
1042        &self,
1043        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
1044        body: &[u8],
1045    ) -> MockResponse {
1046        let input = match wire::deserialize_delete_report_group_request(body) {
1047            Ok(v) => v,
1048            Err(e) => return json_error_response(400, "ValidationException", &e),
1049        };
1050        if input.arn.is_empty() {
1051            return json_error_response(400, "InvalidInputException", "arn is required");
1052        }
1053
1054        let mut state = state.write().await;
1055        match state.delete_report_group(&input.arn) {
1056            Ok(()) => {
1057                wire::serialize_delete_report_group_response(&wire::DeleteReportGroupOutput {})
1058            }
1059            Err(e) => codebuild_error_response(&e),
1060        }
1061    }
1062
1063    async fn handle_update_report_group(
1064        &self,
1065        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
1066        body: &[u8],
1067    ) -> MockResponse {
1068        let input = match wire::deserialize_update_report_group_request(body) {
1069            Ok(v) => v,
1070            Err(e) => return json_error_response(400, "ValidationException", &e),
1071        };
1072        if input.arn.is_empty() {
1073            return json_error_response(400, "InvalidInputException", "arn is required");
1074        }
1075        let export_config_type = input
1076            .export_config
1077            .as_ref()
1078            .and_then(|c| c.export_config_type.as_deref());
1079        let tags = input.tags.map(tags_from_wire);
1080
1081        let mut state = state.write().await;
1082        match state.update_report_group(&input.arn, export_config_type, tags) {
1083            Ok(rg) => {
1084                wire::serialize_update_report_group_response(&wire::UpdateReportGroupOutput {
1085                    report_group: Some(report_group_to_wire(rg)),
1086                })
1087            }
1088            Err(e) => codebuild_error_response(&e),
1089        }
1090    }
1091
1092    async fn handle_list_reports_for_report_group(
1093        &self,
1094        state: &Arc<tokio::sync::RwLock<CodeBuildState>>,
1095        body: &[u8],
1096    ) -> MockResponse {
1097        let input = match wire::deserialize_list_reports_for_report_group_request(body) {
1098            Ok(v) => v,
1099            Err(e) => return json_error_response(400, "ValidationException", &e),
1100        };
1101        if input.report_group_arn.is_empty() {
1102            return json_error_response(400, "InvalidInputException", "reportGroupArn is required");
1103        }
1104
1105        let state = state.read().await;
1106        if !state.report_groups.contains_key(&input.report_group_arn) {
1107            let arn = input.report_group_arn;
1108            return json_error_response(
1109                400,
1110                "ResourceNotFoundException",
1111                &format!("Report group {arn} does not exist"),
1112            );
1113        }
1114
1115        wire::serialize_list_reports_for_report_group_response(
1116            &wire::ListReportsForReportGroupOutput {
1117                reports: Some(vec![]),
1118                next_token: None,
1119            },
1120        )
1121    }
1122}
1123
1124fn tags_from_wire(tags: Vec<wire::Tag>) -> Vec<crate::types::Tag> {
1125    tags.into_iter()
1126        .map(|t| crate::types::Tag {
1127            key: t.key.unwrap_or_default(),
1128            value: t.value.unwrap_or_default(),
1129        })
1130        .collect()
1131}
1132
1133fn report_group_to_wire(rg: &crate::types::ReportGroup) -> wire::ReportGroup {
1134    let tags: Option<Vec<wire::Tag>> = if rg.tags.is_empty() {
1135        None
1136    } else {
1137        Some(
1138            rg.tags
1139                .iter()
1140                .map(|t| wire::Tag {
1141                    key: Some(t.key.clone()),
1142                    value: Some(t.value.clone()),
1143                })
1144                .collect(),
1145        )
1146    };
1147
1148    wire::ReportGroup {
1149        arn: Some(rg.arn.clone()),
1150        name: Some(rg.name.clone()),
1151        r#type: Some(rg.r#type.clone()),
1152        status: Some(rg.status.clone()),
1153        created: Some(rg.created.timestamp() as f64),
1154        last_modified: Some(rg.last_modified.timestamp() as f64),
1155        tags,
1156        export_config: rg
1157            .export_config_type
1158            .as_ref()
1159            .map(|ect| wire::ReportExportConfig {
1160                export_config_type: Some(ect.clone()),
1161                ..Default::default()
1162            }),
1163    }
1164}
1165
1166fn webhook_to_wire(wh: &crate::types::Webhook) -> wire::Webhook {
1167    wire::Webhook {
1168        url: Some(wh.url.clone()),
1169        branch_filter: wh.branch_filter.clone(),
1170        build_type: wh.build_type.clone(),
1171        secret: wh.secret.clone(),
1172        ..Default::default()
1173    }
1174}
1175
1176fn project_to_wire(p: &crate::types::Project) -> wire::Project {
1177    let tags: Option<Vec<wire::Tag>> = if p.tags.is_empty() {
1178        None
1179    } else {
1180        Some(
1181            p.tags
1182                .iter()
1183                .map(|t| wire::Tag {
1184                    key: Some(t.key.clone()),
1185                    value: Some(t.value.clone()),
1186                })
1187                .collect(),
1188        )
1189    };
1190
1191    let artifacts = Some(wire::ProjectArtifacts {
1192        r#type: p.artifact_type.clone(),
1193        location: p.artifact_location.clone(),
1194        ..Default::default()
1195    });
1196
1197    wire::Project {
1198        name: Some(p.name.clone()),
1199        arn: Some(p.arn.clone()),
1200        description: if p.description.is_empty() {
1201            None
1202        } else {
1203            Some(p.description.clone())
1204        },
1205        source: Some(wire::ProjectSource {
1206            r#type: p.source_type.clone(),
1207            location: if p.source_location.is_empty() {
1208                None
1209            } else {
1210                Some(p.source_location.clone())
1211            },
1212            ..Default::default()
1213        }),
1214        artifacts,
1215        environment: Some(wire::ProjectEnvironment {
1216            r#type: p.environment_type.clone(),
1217            image: p.environment_image.clone(),
1218            compute_type: p.environment_compute_type.clone(),
1219            ..Default::default()
1220        }),
1221        service_role: Some(p.service_role.clone()),
1222        tags,
1223        created: Some(p.created.timestamp() as f64),
1224        last_modified: Some(p.last_modified.timestamp() as f64),
1225        ..Default::default()
1226    }
1227}
1228
1229fn build_to_wire(b: &crate::types::Build) -> wire::Build {
1230    let phases: Vec<wire::BuildPhase> = b
1231        .phases
1232        .iter()
1233        .map(|p| wire::BuildPhase {
1234            phase_type: Some(p.phase_type.clone()),
1235            phase_status: p.phase_status.clone(),
1236            start_time: Some(p.start_time),
1237            end_time: p.end_time,
1238            duration_in_seconds: p.duration_in_seconds,
1239            ..Default::default()
1240        })
1241        .collect();
1242
1243    wire::Build {
1244        id: Some(b.id.clone()),
1245        arn: Some(b.arn.clone()),
1246        project_name: Some(b.project_name.clone()),
1247        build_status: Some(b.build_status.clone()),
1248        current_phase: Some(b.current_phase.clone()),
1249        source_version: Some(b.source_version.clone()),
1250        source: Some(wire::ProjectSource {
1251            r#type: b.source_type.clone(),
1252            location: if b.source_location.is_empty() {
1253                None
1254            } else {
1255                Some(b.source_location.clone())
1256            },
1257            ..Default::default()
1258        }),
1259        artifacts: if b.artifact_type == "NO_ARTIFACTS" {
1260            None
1261        } else {
1262            Some(wire::BuildArtifacts {
1263                location: b.artifact_location.clone(),
1264                ..Default::default()
1265            })
1266        },
1267        environment: Some(wire::ProjectEnvironment {
1268            r#type: b.environment_type.clone(),
1269            image: b.environment_image.clone(),
1270            compute_type: b.environment_compute_type.clone(),
1271            ..Default::default()
1272        }),
1273        service_role: Some(b.service_role.clone()),
1274        start_time: Some(b.start_time.timestamp() as f64),
1275        end_time: b.end_time.map(|t| t.timestamp() as f64),
1276        build_number: Some(b.build_number),
1277        phases: Some(phases),
1278        ..Default::default()
1279    }
1280}
1281
1282fn codebuild_error_response(err: &CodeBuildError) -> MockResponse {
1283    use CodeBuildError::*;
1284    let (status, error_type) = match err {
1285        InvalidProjectName => (400, "InvalidInputException"),
1286        InvalidServiceRole => (400, "InvalidInputException"),
1287        InvalidBuildId => (400, "InvalidInputException"),
1288        ProjectAlreadyExists { .. } => (400, "ResourceAlreadyExistsException"),
1289        ProjectNotFound { .. } => (400, "ResourceNotFoundException"),
1290        BuildNotFound { .. } => (400, "ResourceNotFoundException"),
1291        ProjectDoesNotExist { .. } => (400, "ResourceNotFoundException"),
1292        WebhookAlreadyExists { .. } => (400, "ResourceAlreadyExistsException"),
1293        WebhookNotFound { .. } => (400, "ResourceNotFoundException"),
1294        SourceCredentialsNotFound { .. } => (400, "ResourceNotFoundException"),
1295        ResourcePolicyNotFound { .. } => (400, "ResourceNotFoundException"),
1296        ReportGroupAlreadyExists { .. } => (400, "ResourceAlreadyExistsException"),
1297        ReportGroupNotFound { .. } => (400, "ResourceNotFoundException"),
1298    };
1299    MockResponse::json(
1300        status,
1301        json!({"__type": error_type, "message": err.to_string()}).to_string(),
1302    )
1303}