veracode_platform/
sandbox.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::{VeracodeClient, VeracodeError};
6
7/// API error response structure
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ApiErrorResponse {
10    #[serde(rename = "_embedded")]
11    pub embedded: Option<ApiErrorEmbedded>,
12    pub fallback_type: Option<String>,
13    pub full_type: Option<String>,
14}
15
16/// Embedded API errors
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ApiErrorEmbedded {
19    pub api_errors: Vec<ApiError>,
20}
21
22/// Individual API error
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ApiError {
25    pub id: String,
26    pub code: String,
27    pub title: String,
28    pub status: String,
29    pub source: Option<ApiErrorSource>,
30}
31
32/// API error source information
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ApiErrorSource {
35    pub pointer: String,
36    pub parameter: String,
37}
38
39/// Represents a Veracode development sandbox
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Sandbox {
42    pub id: Option<u64>,
43    pub guid: String,
44    pub name: String,
45    pub description: Option<String>,
46    pub created: DateTime<Utc>,
47    pub modified: DateTime<Utc>,
48    pub auto_recreate: bool,
49    pub custom_fields: Option<HashMap<String, String>>,
50    pub owner: Option<String>,
51    pub owner_username: Option<String>,
52    pub organization_id: Option<u64>,
53    pub application_guid: Option<String>,
54    pub team_identifiers: Option<Vec<String>>,
55    pub scan_url: Option<String>,
56    pub last_scan_date: Option<DateTime<Utc>>,
57    pub status: Option<String>,
58    #[serde(rename = "_links")]
59    pub links: Option<serde_json::Value>,
60}
61
62/// Request payload for creating a new sandbox
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CreateSandboxRequest {
65    pub name: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub auto_recreate: Option<bool>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub custom_fields: Option<HashMap<String, String>>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub team_identifiers: Option<Vec<String>>,
74}
75
76/// Request payload for updating an existing sandbox
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct UpdateSandboxRequest {
79    pub name: Option<String>,
80    pub description: Option<String>,
81    pub auto_recreate: Option<bool>,
82    pub custom_fields: Option<HashMap<String, String>>,
83    pub team_identifiers: Option<Vec<String>>,
84}
85
86/// Response wrapper for sandbox list operations
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SandboxListResponse {
89    #[serde(rename = "_embedded")]
90    pub embedded: Option<SandboxEmbedded>,
91    pub page: Option<PageInfo>,
92    pub total: Option<u64>,
93}
94
95/// Embedded sandboxes in the list response
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SandboxEmbedded {
98    pub sandboxes: Vec<Sandbox>,
99}
100
101/// Page information for paginated responses
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PageInfo {
104    pub size: u64,
105    pub number: u64,
106    pub total_elements: u64,
107    pub total_pages: u64,
108}
109
110/// Represents a scan within a sandbox
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SandboxScan {
113    pub scan_id: u64,
114    pub scan_type: String,
115    pub status: String,
116    pub created: DateTime<Utc>,
117    pub modified: DateTime<Utc>,
118    pub scan_url: Option<String>,
119    pub results_ready: bool,
120    pub engine_version: Option<String>,
121}
122
123/// Query parameters for listing sandboxes
124#[derive(Debug, Clone, Default)]
125pub struct SandboxListParams {
126    pub name: Option<String>,
127    pub owner: Option<String>,
128    pub team: Option<String>,
129    pub page: Option<u64>,
130    pub size: Option<u64>,
131    pub modified_after: Option<DateTime<Utc>>,
132    pub modified_before: Option<DateTime<Utc>>,
133}
134
135impl SandboxListParams {
136    /// Convert to query parameters for HTTP requests
137    #[must_use]
138    pub fn to_query_params(&self) -> Vec<(String, String)> {
139        Vec::from(self) // Delegate to trait
140    }
141}
142
143// Trait implementations for memory optimization
144impl From<&SandboxListParams> for Vec<(String, String)> {
145    fn from(query: &SandboxListParams) -> Self {
146        let mut params = Vec::new();
147
148        if let Some(ref name) = query.name {
149            params.push(("name".to_string(), name.clone())); // Still clone for borrowing
150        }
151        if let Some(ref owner) = query.owner {
152            params.push(("owner".to_string(), owner.clone()));
153        }
154        if let Some(ref team) = query.team {
155            params.push(("team".to_string(), team.clone()));
156        }
157        if let Some(page) = query.page {
158            params.push(("page".to_string(), page.to_string()));
159        }
160        if let Some(size) = query.size {
161            params.push(("size".to_string(), size.to_string()));
162        }
163        if let Some(modified_after) = query.modified_after {
164            params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
165        }
166        if let Some(modified_before) = query.modified_before {
167            params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
168        }
169
170        params
171    }
172}
173
174impl From<SandboxListParams> for Vec<(String, String)> {
175    fn from(query: SandboxListParams) -> Self {
176        let mut params = Vec::new();
177
178        if let Some(name) = query.name {
179            params.push(("name".to_string(), name)); // MOVE - no clone!
180        }
181        if let Some(owner) = query.owner {
182            params.push(("owner".to_string(), owner)); // MOVE - no clone!
183        }
184        if let Some(team) = query.team {
185            params.push(("team".to_string(), team)); // MOVE - no clone!
186        }
187        if let Some(page) = query.page {
188            params.push(("page".to_string(), page.to_string()));
189        }
190        if let Some(size) = query.size {
191            params.push(("size".to_string(), size.to_string()));
192        }
193        if let Some(modified_after) = query.modified_after {
194            params.push(("modified_after".to_string(), modified_after.to_rfc3339()));
195        }
196        if let Some(modified_before) = query.modified_before {
197            params.push(("modified_before".to_string(), modified_before.to_rfc3339()));
198        }
199
200        params
201    }
202}
203
204/// Sandbox-specific error types that extend the base VeracodeError
205#[derive(Debug)]
206pub enum SandboxError {
207    /// Veracode API error
208    Api(VeracodeError),
209    /// Sandbox not found
210    NotFound,
211    /// Invalid sandbox name or configuration
212    InvalidInput(String),
213    /// Maximum number of sandboxes reached
214    LimitExceeded,
215    /// Sandbox operation not allowed
216    OperationNotAllowed(String),
217    /// Sandbox already exists
218    AlreadyExists(String),
219}
220
221impl std::fmt::Display for SandboxError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            SandboxError::Api(err) => write!(f, "API error: {err}"),
225            SandboxError::NotFound => write!(f, "Sandbox not found"),
226            SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
227            SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
228            SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
229            SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
230        }
231    }
232}
233
234impl std::error::Error for SandboxError {}
235
236impl From<VeracodeError> for SandboxError {
237    fn from(err: VeracodeError) -> Self {
238        SandboxError::Api(err)
239    }
240}
241
242impl From<reqwest::Error> for SandboxError {
243    fn from(err: reqwest::Error) -> Self {
244        SandboxError::Api(VeracodeError::Http(err))
245    }
246}
247
248impl From<serde_json::Error> for SandboxError {
249    fn from(err: serde_json::Error) -> Self {
250        SandboxError::Api(VeracodeError::Serialization(err))
251    }
252}
253
254/// Veracode Sandbox API operations
255pub struct SandboxApi<'a> {
256    client: &'a VeracodeClient,
257}
258
259impl<'a> SandboxApi<'a> {
260    /// Create a new SandboxApi instance
261    #[must_use]
262    pub fn new(client: &'a VeracodeClient) -> Self {
263        Self { client }
264    }
265
266    /// List all sandboxes for a given application
267    ///
268    /// # Arguments
269    ///
270    /// * `application_guid` - The GUID of the application
271    /// * `params` - Optional query parameters for filtering
272    ///
273    /// # Returns
274    ///
275    /// A `Result` containing a list of sandboxes or an error.
276    pub async fn list_sandboxes(
277        &self,
278        application_guid: &str,
279        params: Option<SandboxListParams>,
280    ) -> Result<Vec<Sandbox>, SandboxError> {
281        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
282
283        let query_params = params.as_ref().map(Vec::from);
284
285        let response = self.client.get(&endpoint, query_params.as_deref()).await?;
286
287        let status = response.status().as_u16();
288        match status {
289            200 => {
290                let sandbox_response: SandboxListResponse = response.json().await?;
291                Ok(sandbox_response
292                    .embedded
293                    .map(|e| e.sandboxes)
294                    .unwrap_or_default())
295            }
296            404 => Err(SandboxError::NotFound),
297            _ => {
298                let error_text = response.text().await.unwrap_or_default();
299                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
300                    "HTTP {status}: {error_text}"
301                ))))
302            }
303        }
304    }
305
306    /// Get a specific sandbox by GUID
307    ///
308    /// # Arguments
309    ///
310    /// * `application_guid` - The GUID of the application
311    /// * `sandbox_guid` - The GUID of the sandbox
312    ///
313    /// # Returns
314    ///
315    /// A `Result` containing the sandbox or an error.
316    pub async fn get_sandbox(
317        &self,
318        application_guid: &str,
319        sandbox_guid: &str,
320    ) -> Result<Sandbox, SandboxError> {
321        let endpoint =
322            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
323
324        let response = self.client.get(&endpoint, None).await?;
325
326        let status = response.status().as_u16();
327        match status {
328            200 => {
329                let sandbox: Sandbox = response.json().await?;
330                Ok(sandbox)
331            }
332            404 => Err(SandboxError::NotFound),
333            _ => {
334                let error_text = response.text().await.unwrap_or_default();
335                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
336                    "HTTP {status}: {error_text}"
337                ))))
338            }
339        }
340    }
341
342    /// Create a new sandbox
343    ///
344    /// # Arguments
345    ///
346    /// * `application_guid` - The GUID of the application
347    /// * `request` - The sandbox creation request
348    ///
349    /// # Returns
350    ///
351    /// A `Result` containing the created sandbox or an error.
352    pub async fn create_sandbox(
353        &self,
354        application_guid: &str,
355        request: CreateSandboxRequest,
356    ) -> Result<Sandbox, SandboxError> {
357        // Validate the request
358        Self::validate_create_request(&request)?;
359
360        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
361
362        let response = self.client.post(&endpoint, Some(&request)).await?;
363
364        let status = response.status().as_u16();
365        match status {
366            200 | 201 => {
367                let sandbox: Sandbox = response.json().await?;
368                Ok(sandbox)
369            }
370            400 => {
371                let error_text = response.text().await.unwrap_or_default();
372
373                // Try to parse the structured error response
374                if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text)
375                    && let Some(embedded) = error_response.embedded
376                {
377                    for api_error in embedded.api_errors {
378                        if api_error.title.contains("already exists") {
379                            return Err(SandboxError::AlreadyExists(api_error.title));
380                        }
381                        if api_error.title.contains("limit") || api_error.title.contains("maximum")
382                        {
383                            return Err(SandboxError::LimitExceeded);
384                        }
385                        if api_error.title.contains("Json Parse Error")
386                            || api_error.title.contains("Cannot deserialize")
387                        {
388                            return Err(SandboxError::InvalidInput(format!(
389                                "JSON parsing error: {}",
390                                api_error.title
391                            )));
392                        }
393                    }
394                }
395
396                // Fallback to string matching for backwards compatibility
397                if error_text.contains("limit") || error_text.contains("maximum") {
398                    Err(SandboxError::LimitExceeded)
399                } else if error_text.contains("already exists") {
400                    Err(SandboxError::AlreadyExists(error_text))
401                } else {
402                    Err(SandboxError::InvalidInput(error_text))
403                }
404            }
405            404 => Err(SandboxError::NotFound),
406            _ => {
407                let error_text = response.text().await.unwrap_or_default();
408                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
409                    "HTTP {status}: {error_text}"
410                ))))
411            }
412        }
413    }
414
415    /// Update an existing sandbox
416    ///
417    /// # Arguments
418    ///
419    /// * `application_guid` - The GUID of the application
420    /// * `sandbox_guid` - The GUID of the sandbox to update
421    /// * `request` - The sandbox update request
422    ///
423    /// # Returns
424    ///
425    /// A `Result` containing the updated sandbox or an error.
426    pub async fn update_sandbox(
427        &self,
428        application_guid: &str,
429        sandbox_guid: &str,
430        request: UpdateSandboxRequest,
431    ) -> Result<Sandbox, SandboxError> {
432        // Validate the request
433        Self::validate_update_request(&request)?;
434
435        let endpoint =
436            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
437
438        let response = self.client.put(&endpoint, Some(&request)).await?;
439
440        let status = response.status().as_u16();
441        match status {
442            200 => {
443                let sandbox: Sandbox = response.json().await?;
444                Ok(sandbox)
445            }
446            400 => {
447                let error_text = response.text().await.unwrap_or_default();
448                Err(SandboxError::InvalidInput(error_text))
449            }
450            404 => Err(SandboxError::NotFound),
451            _ => {
452                let error_text = response.text().await.unwrap_or_default();
453                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
454                    "HTTP {status}: {error_text}"
455                ))))
456            }
457        }
458    }
459
460    /// Delete a sandbox
461    ///
462    /// # Arguments
463    ///
464    /// * `application_guid` - The GUID of the application
465    /// * `sandbox_guid` - The GUID of the sandbox to delete
466    ///
467    /// # Returns
468    ///
469    /// A `Result` indicating success or failure.
470    pub async fn delete_sandbox(
471        &self,
472        application_guid: &str,
473        sandbox_guid: &str,
474    ) -> Result<(), SandboxError> {
475        let endpoint =
476            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
477
478        let response = self.client.delete(&endpoint).await?;
479
480        let status = response.status().as_u16();
481        match status {
482            204 => Ok(()),
483            404 => Err(SandboxError::NotFound),
484            409 => {
485                let error_text = response.text().await.unwrap_or_default();
486                Err(SandboxError::OperationNotAllowed(error_text))
487            }
488            _ => {
489                let error_text = response.text().await.unwrap_or_default();
490                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
491                    "HTTP {status}: {error_text}"
492                ))))
493            }
494        }
495    }
496
497    /// Promote a sandbox scan to the policy sandbox
498    ///
499    /// # Arguments
500    ///
501    /// * `application_guid` - The GUID of the application
502    /// * `sandbox_guid` - The GUID of the sandbox to promote
503    /// * `delete_on_promote` - Whether to delete the sandbox after promotion
504    ///
505    /// # Returns
506    ///
507    /// A `Result` indicating success or failure.
508    pub async fn promote_sandbox_scan(
509        &self,
510        application_guid: &str,
511        sandbox_guid: &str,
512        delete_on_promote: bool,
513    ) -> Result<(), SandboxError> {
514        let endpoint = if delete_on_promote {
515            format!(
516                "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
517            )
518        } else {
519            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
520        };
521
522        let response = self.client.post(&endpoint, None::<&()>).await?;
523
524        let status = response.status().as_u16();
525        match status {
526            200 | 204 => Ok(()),
527            404 => Err(SandboxError::NotFound),
528            409 => {
529                let error_text = response.text().await.unwrap_or_default();
530                Err(SandboxError::OperationNotAllowed(error_text))
531            }
532            _ => {
533                let error_text = response.text().await.unwrap_or_default();
534                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
535                    "HTTP {status}: {error_text}"
536                ))))
537            }
538        }
539    }
540
541    /// Get sandbox scan information
542    ///
543    /// # Arguments
544    ///
545    /// * `application_guid` - The GUID of the application
546    /// * `sandbox_guid` - The GUID of the sandbox
547    ///
548    /// # Returns
549    ///
550    /// A `Result` containing a list of scans or an error.
551    pub async fn get_sandbox_scans(
552        &self,
553        application_guid: &str,
554        sandbox_guid: &str,
555    ) -> Result<Vec<SandboxScan>, SandboxError> {
556        let endpoint =
557            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
558
559        let response = self.client.get(&endpoint, None).await?;
560
561        let status = response.status().as_u16();
562        match status {
563            200 => {
564                let scans: Vec<SandboxScan> = response.json().await?;
565                Ok(scans)
566            }
567            404 => Err(SandboxError::NotFound),
568            _ => {
569                let error_text = response.text().await.unwrap_or_default();
570                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
571                    "HTTP {status}: {error_text}"
572                ))))
573            }
574        }
575    }
576
577    /// Check if a sandbox exists
578    ///
579    /// # Arguments
580    ///
581    /// * `application_guid` - The GUID of the application
582    /// * `sandbox_guid` - The GUID of the sandbox
583    ///
584    /// # Returns
585    ///
586    /// A `Result` containing a boolean indicating if the sandbox exists.
587    pub async fn sandbox_exists(
588        &self,
589        application_guid: &str,
590        sandbox_guid: &str,
591    ) -> Result<bool, SandboxError> {
592        match self.get_sandbox(application_guid, sandbox_guid).await {
593            Ok(_) => Ok(true),
594            Err(SandboxError::NotFound) => Ok(false),
595            Err(e) => Err(e),
596        }
597    }
598
599    /// Get sandbox by name
600    ///
601    /// # Arguments
602    ///
603    /// * `application_guid` - The GUID of the application
604    /// * `name` - The name of the sandbox to find
605    ///
606    /// # Returns
607    ///
608    /// A `Result` containing the sandbox if found, or None if not found.
609    pub async fn get_sandbox_by_name(
610        &self,
611        application_guid: &str,
612        name: &str,
613    ) -> Result<Option<Sandbox>, SandboxError> {
614        let params = SandboxListParams {
615            name: Some(name.to_string()),
616            ..Default::default()
617        };
618
619        let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
620        Ok(sandboxes.into_iter().find(|s| s.name == name))
621    }
622
623    /// Validate sandbox creation request
624    fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
625        if request.name.is_empty() {
626            return Err(SandboxError::InvalidInput(
627                "Sandbox name cannot be empty".to_string(),
628            ));
629        }
630        if request.name.len() > 256 {
631            return Err(SandboxError::InvalidInput(
632                "Sandbox name too long (max 256 characters)".to_string(),
633            ));
634        }
635
636        // Check for invalid characters in name
637        if request.name.contains(['<', '>', '"', '&', '\'']) {
638            return Err(SandboxError::InvalidInput(
639                "Sandbox name contains invalid characters".to_string(),
640            ));
641        }
642
643        Ok(())
644    }
645
646    /// Validate sandbox update request
647    fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
648        if let Some(name) = &request.name {
649            if name.is_empty() {
650                return Err(SandboxError::InvalidInput(
651                    "Sandbox name cannot be empty".to_string(),
652                ));
653            }
654            if name.len() > 256 {
655                return Err(SandboxError::InvalidInput(
656                    "Sandbox name too long (max 256 characters)".to_string(),
657                ));
658            }
659
660            // Check for invalid characters in name
661            if name.contains(['<', '>', '"', '&', '\'']) {
662                return Err(SandboxError::InvalidInput(
663                    "Sandbox name contains invalid characters".to_string(),
664                ));
665            }
666        }
667
668        Ok(())
669    }
670}
671
672/// Convenience methods for common sandbox operations
673impl<'a> SandboxApi<'a> {
674    /// Create a simple sandbox with just a name
675    ///
676    /// # Arguments
677    ///
678    /// * `application_guid` - The GUID of the application
679    /// * `name` - The name of the sandbox
680    ///
681    /// # Returns
682    ///
683    /// A `Result` containing the created sandbox or an error.
684    pub async fn create_simple_sandbox(
685        &self,
686        application_guid: &str,
687        name: &str,
688    ) -> Result<Sandbox, SandboxError> {
689        let request = CreateSandboxRequest {
690            name: name.to_string(),
691            description: None,
692            auto_recreate: None,
693            custom_fields: None,
694            team_identifiers: None,
695        };
696
697        self.create_sandbox(application_guid, request).await
698    }
699
700    /// Create a sandbox with auto-recreate enabled
701    ///
702    /// # Arguments
703    ///
704    /// * `application_guid` - The GUID of the application
705    /// * `name` - The name of the sandbox
706    /// * `description` - Optional description
707    ///
708    /// # Returns
709    ///
710    /// A `Result` containing the created sandbox or an error.
711    pub async fn create_auto_recreate_sandbox(
712        &self,
713        application_guid: &str,
714        name: &str,
715        description: Option<String>,
716    ) -> Result<Sandbox, SandboxError> {
717        let request = CreateSandboxRequest {
718            name: name.to_string(),
719            description,
720            auto_recreate: Some(true),
721            custom_fields: None,
722            team_identifiers: None,
723        };
724
725        self.create_sandbox(application_guid, request).await
726    }
727
728    /// Update sandbox name
729    ///
730    /// # Arguments
731    ///
732    /// * `application_guid` - The GUID of the application
733    /// * `sandbox_guid` - The GUID of the sandbox
734    /// * `new_name` - The new name for the sandbox
735    ///
736    /// # Returns
737    ///
738    /// A `Result` containing the updated sandbox or an error.
739    pub async fn update_sandbox_name(
740        &self,
741        application_guid: &str,
742        sandbox_guid: &str,
743        new_name: &str,
744    ) -> Result<Sandbox, SandboxError> {
745        let request = UpdateSandboxRequest {
746            name: Some(new_name.to_string()),
747            description: None,
748            auto_recreate: None,
749            custom_fields: None,
750            team_identifiers: None,
751        };
752
753        self.update_sandbox(application_guid, sandbox_guid, request)
754            .await
755    }
756
757    /// Count sandboxes for an application
758    ///
759    /// # Arguments
760    ///
761    /// * `application_guid` - The GUID of the application
762    ///
763    /// # Returns
764    ///
765    /// A `Result` containing the count of sandboxes or an error.
766    pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
767        let sandboxes = self.list_sandboxes(application_guid, None).await?;
768        Ok(sandboxes.len())
769    }
770
771    /// Get numeric sandbox_id from sandbox GUID.
772    ///
773    /// This is needed for XML API operations that require numeric IDs.
774    ///
775    /// # Arguments
776    ///
777    /// * `application_guid` - The GUID of the application
778    /// * `sandbox_guid` - The sandbox GUID
779    ///
780    /// # Returns
781    ///
782    /// A `Result` containing the numeric sandbox_id as a string.
783    pub async fn get_sandbox_id_from_guid(
784        &self,
785        application_guid: &str,
786        sandbox_guid: &str,
787    ) -> Result<String, SandboxError> {
788        let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
789        match sandbox.id {
790            Some(id) => Ok(id.to_string()),
791            None => Err(SandboxError::InvalidInput(
792                "Sandbox has no numeric ID".to_string(),
793            )),
794        }
795    }
796
797    /// Create sandbox if it doesn't exist, or return existing sandbox.
798    ///
799    /// This method implements the "check and create" pattern commonly needed
800    /// for automated workflows.
801    ///
802    /// # Arguments
803    ///
804    /// * `application_guid` - The GUID of the application
805    /// * `name` - The name of the sandbox
806    /// * `description` - Optional description for new sandboxes
807    ///
808    /// # Returns
809    ///
810    /// A `Result` containing the sandbox (existing or newly created).
811    pub async fn create_sandbox_if_not_exists(
812        &self,
813        application_guid: &str,
814        name: &str,
815        description: Option<String>,
816    ) -> Result<Sandbox, SandboxError> {
817        // First, check if sandbox already exists
818        if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
819            return Ok(existing_sandbox);
820        }
821
822        // Sandbox doesn't exist, create it
823        let create_request = CreateSandboxRequest {
824            name: name.to_string(),
825            description,
826            auto_recreate: Some(true), // Enable auto-recreate by default for CI/CD
827            custom_fields: None,
828            team_identifiers: None,
829        };
830
831        self.create_sandbox(application_guid, create_request).await
832    }
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    #[test]
840    fn test_validate_create_request() {
841        // Valid request
842        let valid_request = CreateSandboxRequest {
843            name: "valid-sandbox".to_string(),
844            description: None,
845            auto_recreate: None,
846            custom_fields: None,
847            team_identifiers: None,
848        };
849        assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
850
851        // Empty name
852        let empty_name_request = CreateSandboxRequest {
853            name: String::new(),
854            description: None,
855            auto_recreate: None,
856            custom_fields: None,
857            team_identifiers: None,
858        };
859        assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
860
861        // Long name
862        let long_name_request = CreateSandboxRequest {
863            name: "x".repeat(300),
864            description: None,
865            auto_recreate: None,
866            custom_fields: None,
867            team_identifiers: None,
868        };
869        assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
870
871        // Invalid characters
872        let invalid_char_request = CreateSandboxRequest {
873            name: "invalid<name>".to_string(),
874            description: None,
875            auto_recreate: None,
876            custom_fields: None,
877            team_identifiers: None,
878        };
879        assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
880    }
881
882    #[test]
883    fn test_sandbox_list_params_to_query() {
884        let params = SandboxListParams {
885            name: Some("test".to_string()),
886            page: Some(1),
887            size: Some(10),
888            ..Default::default()
889        };
890
891        let query_params: Vec<_> = params.into();
892        assert_eq!(query_params.len(), 3);
893        assert!(query_params.contains(&("name".to_string(), "test".to_string())));
894        assert!(query_params.contains(&("page".to_string(), "1".to_string())));
895        assert!(query_params.contains(&("size".to_string(), "10".to_string())));
896    }
897
898    #[test]
899    fn test_sandbox_error_display() {
900        let error = SandboxError::NotFound;
901        assert_eq!(error.to_string(), "Sandbox not found");
902
903        let error = SandboxError::InvalidInput("test".to_string());
904        assert_eq!(error.to_string(), "Invalid input: test");
905
906        let error = SandboxError::LimitExceeded;
907        assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
908    }
909}