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///
205/// # Errors
206///
207/// Returns an error if the API request fails, the resource is not found,
208/// or authentication/authorization fails.
209/// Sandbox-specific error types that extend the base `VeracodeError`
210#[derive(Debug)]
211#[must_use = "Need to handle all error enum types."]
212pub enum SandboxError {
213    /// Veracode API error
214    Api(VeracodeError),
215    /// Sandbox not found
216    NotFound,
217    /// Invalid sandbox name or configuration
218    InvalidInput(String),
219    /// Maximum number of sandboxes reached
220    LimitExceeded,
221    /// Sandbox operation not allowed
222    OperationNotAllowed(String),
223    /// Sandbox already exists
224    AlreadyExists(String),
225}
226
227impl std::fmt::Display for SandboxError {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            SandboxError::Api(err) => write!(f, "API error: {err}"),
231            SandboxError::NotFound => write!(f, "Sandbox not found"),
232            SandboxError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
233            SandboxError::LimitExceeded => write!(f, "Maximum number of sandboxes reached"),
234            SandboxError::OperationNotAllowed(msg) => write!(f, "Operation not allowed: {msg}"),
235            SandboxError::AlreadyExists(msg) => write!(f, "Sandbox already exists: {msg}"),
236        }
237    }
238}
239
240impl std::error::Error for SandboxError {}
241
242impl From<VeracodeError> for SandboxError {
243    fn from(err: VeracodeError) -> Self {
244        SandboxError::Api(err)
245    }
246}
247
248impl From<reqwest::Error> for SandboxError {
249    fn from(err: reqwest::Error) -> Self {
250        SandboxError::Api(VeracodeError::Http(err))
251    }
252}
253
254impl From<serde_json::Error> for SandboxError {
255    fn from(err: serde_json::Error) -> Self {
256        SandboxError::Api(VeracodeError::Serialization(err))
257    }
258}
259
260/// Veracode Sandbox API operations
261pub struct SandboxApi<'a> {
262    client: &'a VeracodeClient,
263}
264
265impl<'a> SandboxApi<'a> {
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the API request fails, the resource is not found,
270    /// or authentication/authorization fails.
271    /// Create a new `SandboxApi` instance
272    #[must_use]
273    pub fn new(client: &'a VeracodeClient) -> Self {
274        Self { client }
275    }
276
277    /// List all sandboxes for a given application
278    ///
279    /// # Arguments
280    ///
281    /// * `application_guid` - The GUID of the application
282    /// * `params` - Optional query parameters for filtering
283    ///
284    /// # Returns
285    ///
286    /// A `Result` containing a list of sandboxes or an error.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the API request fails, the sandbox is not found,
291    /// or authentication/authorization fails.
292    pub async fn list_sandboxes(
293        &self,
294        application_guid: &str,
295        params: Option<SandboxListParams>,
296    ) -> Result<Vec<Sandbox>, SandboxError> {
297        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
298
299        let query_params = params.as_ref().map(Vec::from);
300
301        let response = self.client.get(&endpoint, query_params.as_deref()).await?;
302
303        let status = response.status().as_u16();
304        match status {
305            200 => {
306                let sandbox_response: SandboxListResponse = response.json().await?;
307                Ok(sandbox_response
308                    .embedded
309                    .map(|e| e.sandboxes)
310                    .unwrap_or_default())
311            }
312            404 => Err(SandboxError::NotFound),
313            _ => {
314                let error_text = response.text().await.unwrap_or_default();
315                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
316                    "HTTP {status}: {error_text}"
317                ))))
318            }
319        }
320    }
321
322    /// Get a specific sandbox by GUID
323    ///
324    /// # Arguments
325    ///
326    /// * `application_guid` - The GUID of the application
327    /// * `sandbox_guid` - The GUID of the sandbox
328    ///
329    /// # Returns
330    ///
331    /// A `Result` containing the sandbox or an error.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if the API request fails, the sandbox is not found,
336    /// or authentication/authorization fails.
337    pub async fn get_sandbox(
338        &self,
339        application_guid: &str,
340        sandbox_guid: &str,
341    ) -> Result<Sandbox, SandboxError> {
342        let endpoint =
343            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
344
345        let response = self.client.get(&endpoint, None).await?;
346
347        let status = response.status().as_u16();
348        match status {
349            200 => {
350                let sandbox: Sandbox = response.json().await?;
351                Ok(sandbox)
352            }
353            404 => Err(SandboxError::NotFound),
354            _ => {
355                let error_text = response.text().await.unwrap_or_default();
356                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
357                    "HTTP {status}: {error_text}"
358                ))))
359            }
360        }
361    }
362
363    /// Create a new sandbox
364    ///
365    /// # Arguments
366    ///
367    /// * `application_guid` - The GUID of the application
368    /// * `request` - The sandbox creation request
369    ///
370    /// # Returns
371    ///
372    /// A `Result` containing the created sandbox or an error.
373    ///
374    /// # Errors
375    ///
376    /// Returns an error if the API request fails, the sandbox is not found,
377    /// or authentication/authorization fails.
378    pub async fn create_sandbox(
379        &self,
380        application_guid: &str,
381        request: CreateSandboxRequest,
382    ) -> Result<Sandbox, SandboxError> {
383        // Validate the request
384        Self::validate_create_request(&request)?;
385
386        let endpoint = format!("/appsec/v1/applications/{application_guid}/sandboxes");
387
388        let response = self.client.post(&endpoint, Some(&request)).await?;
389
390        let status = response.status().as_u16();
391        match status {
392            200 | 201 => {
393                let sandbox: Sandbox = response.json().await?;
394                Ok(sandbox)
395            }
396            400 => {
397                let error_text = response.text().await.unwrap_or_default();
398
399                // Try to parse the structured error response
400                if let Ok(error_response) = serde_json::from_str::<ApiErrorResponse>(&error_text)
401                    && let Some(embedded) = error_response.embedded
402                {
403                    for api_error in embedded.api_errors {
404                        if api_error.title.contains("already exists") {
405                            return Err(SandboxError::AlreadyExists(api_error.title));
406                        }
407                        if api_error.title.contains("limit") || api_error.title.contains("maximum")
408                        {
409                            return Err(SandboxError::LimitExceeded);
410                        }
411                        if api_error.title.contains("Json Parse Error")
412                            || api_error.title.contains("Cannot deserialize")
413                        {
414                            return Err(SandboxError::InvalidInput(format!(
415                                "JSON parsing error: {}",
416                                api_error.title
417                            )));
418                        }
419                    }
420                }
421
422                // Fallback to string matching for backwards compatibility
423                if error_text.contains("limit") || error_text.contains("maximum") {
424                    Err(SandboxError::LimitExceeded)
425                } else if error_text.contains("already exists") {
426                    Err(SandboxError::AlreadyExists(error_text))
427                } else {
428                    Err(SandboxError::InvalidInput(error_text))
429                }
430            }
431            404 => Err(SandboxError::NotFound),
432            _ => {
433                let error_text = response.text().await.unwrap_or_default();
434                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
435                    "HTTP {status}: {error_text}"
436                ))))
437            }
438        }
439    }
440
441    /// Update an existing sandbox
442    ///
443    /// # Arguments
444    ///
445    /// * `application_guid` - The GUID of the application
446    /// * `sandbox_guid` - The GUID of the sandbox to update
447    /// * `request` - The sandbox update request
448    ///
449    /// # Returns
450    ///
451    /// A `Result` containing the updated sandbox or an error.
452    ///
453    /// # Errors
454    ///
455    /// Returns an error if the API request fails, the sandbox is not found,
456    /// or authentication/authorization fails.
457    pub async fn update_sandbox(
458        &self,
459        application_guid: &str,
460        sandbox_guid: &str,
461        request: UpdateSandboxRequest,
462    ) -> Result<Sandbox, SandboxError> {
463        // Validate the request
464        Self::validate_update_request(&request)?;
465
466        let endpoint =
467            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
468
469        let response = self.client.put(&endpoint, Some(&request)).await?;
470
471        let status = response.status().as_u16();
472        match status {
473            200 => {
474                let sandbox: Sandbox = response.json().await?;
475                Ok(sandbox)
476            }
477            400 => {
478                let error_text = response.text().await.unwrap_or_default();
479                Err(SandboxError::InvalidInput(error_text))
480            }
481            404 => Err(SandboxError::NotFound),
482            _ => {
483                let error_text = response.text().await.unwrap_or_default();
484                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
485                    "HTTP {status}: {error_text}"
486                ))))
487            }
488        }
489    }
490
491    /// Delete a sandbox
492    ///
493    /// # Arguments
494    ///
495    /// * `application_guid` - The GUID of the application
496    /// * `sandbox_guid` - The GUID of the sandbox to delete
497    ///
498    /// # Returns
499    ///
500    /// A `Result` indicating success or failure.
501    ///
502    /// # Errors
503    ///
504    /// Returns an error if the API request fails, the sandbox is not found,
505    /// or authentication/authorization fails.
506    pub async fn delete_sandbox(
507        &self,
508        application_guid: &str,
509        sandbox_guid: &str,
510    ) -> Result<(), SandboxError> {
511        let endpoint =
512            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}");
513
514        let response = self.client.delete(&endpoint).await?;
515
516        let status = response.status().as_u16();
517        match status {
518            204 => Ok(()),
519            404 => Err(SandboxError::NotFound),
520            409 => {
521                let error_text = response.text().await.unwrap_or_default();
522                Err(SandboxError::OperationNotAllowed(error_text))
523            }
524            _ => {
525                let error_text = response.text().await.unwrap_or_default();
526                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
527                    "HTTP {status}: {error_text}"
528                ))))
529            }
530        }
531    }
532
533    /// Promote a sandbox scan to the policy sandbox
534    ///
535    /// # Arguments
536    ///
537    /// * `application_guid` - The GUID of the application
538    /// * `sandbox_guid` - The GUID of the sandbox to promote
539    /// * `delete_on_promote` - Whether to delete the sandbox after promotion
540    ///
541    /// # Returns
542    ///
543    /// A `Result` indicating success or failure.
544    ///
545    /// # Errors
546    ///
547    /// Returns an error if the API request fails, the sandbox is not found,
548    /// or authentication/authorization fails.
549    pub async fn promote_sandbox_scan(
550        &self,
551        application_guid: &str,
552        sandbox_guid: &str,
553        delete_on_promote: bool,
554    ) -> Result<(), SandboxError> {
555        let endpoint = if delete_on_promote {
556            format!(
557                "/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote?delete_on_promote=true"
558            )
559        } else {
560            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/promote")
561        };
562
563        let response = self.client.post(&endpoint, None::<&()>).await?;
564
565        let status = response.status().as_u16();
566        match status {
567            200 | 204 => Ok(()),
568            404 => Err(SandboxError::NotFound),
569            409 => {
570                let error_text = response.text().await.unwrap_or_default();
571                Err(SandboxError::OperationNotAllowed(error_text))
572            }
573            _ => {
574                let error_text = response.text().await.unwrap_or_default();
575                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
576                    "HTTP {status}: {error_text}"
577                ))))
578            }
579        }
580    }
581
582    /// Get sandbox scan information
583    ///
584    /// # Arguments
585    ///
586    /// * `application_guid` - The GUID of the application
587    /// * `sandbox_guid` - The GUID of the sandbox
588    ///
589    /// # Returns
590    ///
591    /// A `Result` containing a list of scans or an error.
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if the API request fails, the sandbox is not found,
596    /// or authentication/authorization fails.
597    pub async fn get_sandbox_scans(
598        &self,
599        application_guid: &str,
600        sandbox_guid: &str,
601    ) -> Result<Vec<SandboxScan>, SandboxError> {
602        let endpoint =
603            format!("/appsec/v1/applications/{application_guid}/sandboxes/{sandbox_guid}/scans");
604
605        let response = self.client.get(&endpoint, None).await?;
606
607        let status = response.status().as_u16();
608        match status {
609            200 => {
610                let scans: Vec<SandboxScan> = response.json().await?;
611                Ok(scans)
612            }
613            404 => Err(SandboxError::NotFound),
614            _ => {
615                let error_text = response.text().await.unwrap_or_default();
616                Err(SandboxError::Api(VeracodeError::InvalidResponse(format!(
617                    "HTTP {status}: {error_text}"
618                ))))
619            }
620        }
621    }
622
623    /// Check if a sandbox exists
624    ///
625    /// # Arguments
626    ///
627    /// * `application_guid` - The GUID of the application
628    /// * `sandbox_guid` - The GUID of the sandbox
629    ///
630    /// # Returns
631    ///
632    /// A `Result` containing a boolean indicating if the sandbox exists.
633    ///
634    /// # Errors
635    ///
636    /// Returns an error if the API request fails, the sandbox is not found,
637    /// or authentication/authorization fails.
638    pub async fn sandbox_exists(
639        &self,
640        application_guid: &str,
641        sandbox_guid: &str,
642    ) -> Result<bool, SandboxError> {
643        match self.get_sandbox(application_guid, sandbox_guid).await {
644            Ok(_) => Ok(true),
645            Err(SandboxError::NotFound) => Ok(false),
646            Err(e) => Err(e),
647        }
648    }
649
650    /// Get sandbox by name
651    ///
652    /// # Arguments
653    ///
654    /// * `application_guid` - The GUID of the application
655    /// * `name` - The name of the sandbox to find
656    ///
657    /// # Returns
658    ///
659    /// A `Result` containing the sandbox if found, or None if not found.
660    ///
661    /// # Errors
662    ///
663    /// Returns an error if the API request fails, the sandbox is not found,
664    /// or authentication/authorization fails.
665    pub async fn get_sandbox_by_name(
666        &self,
667        application_guid: &str,
668        name: &str,
669    ) -> Result<Option<Sandbox>, SandboxError> {
670        let params = SandboxListParams {
671            name: Some(name.to_string()),
672            ..Default::default()
673        };
674
675        let sandboxes = self.list_sandboxes(application_guid, Some(params)).await?;
676        Ok(sandboxes.into_iter().find(|s| s.name == name))
677    }
678
679    /// Validate sandbox creation request
680    fn validate_create_request(request: &CreateSandboxRequest) -> Result<(), SandboxError> {
681        if request.name.is_empty() {
682            return Err(SandboxError::InvalidInput(
683                "Sandbox name cannot be empty".to_string(),
684            ));
685        }
686        if request.name.len() > 256 {
687            return Err(SandboxError::InvalidInput(
688                "Sandbox name too long (max 256 characters)".to_string(),
689            ));
690        }
691
692        // Check for invalid characters in name
693        if request.name.contains(['<', '>', '"', '&', '\'']) {
694            return Err(SandboxError::InvalidInput(
695                "Sandbox name contains invalid characters".to_string(),
696            ));
697        }
698
699        Ok(())
700    }
701
702    /// Validate sandbox update request
703    fn validate_update_request(request: &UpdateSandboxRequest) -> Result<(), SandboxError> {
704        if let Some(name) = &request.name {
705            if name.is_empty() {
706                return Err(SandboxError::InvalidInput(
707                    "Sandbox name cannot be empty".to_string(),
708                ));
709            }
710            if name.len() > 256 {
711                return Err(SandboxError::InvalidInput(
712                    "Sandbox name too long (max 256 characters)".to_string(),
713                ));
714            }
715
716            // Check for invalid characters in name
717            if name.contains(['<', '>', '"', '&', '\'']) {
718                return Err(SandboxError::InvalidInput(
719                    "Sandbox name contains invalid characters".to_string(),
720                ));
721            }
722        }
723
724        Ok(())
725    }
726}
727
728/// Convenience methods for common sandbox operations
729impl<'a> SandboxApi<'a> {
730    /// Create a simple sandbox with just a name
731    ///
732    /// # Arguments
733    ///
734    /// * `application_guid` - The GUID of the application
735    /// * `name` - The name of the sandbox
736    ///
737    /// # Returns
738    ///
739    /// A `Result` containing the created sandbox or an error.
740    ///
741    /// # Errors
742    ///
743    /// Returns an error if the API request fails, the sandbox is not found,
744    /// or authentication/authorization fails.
745    pub async fn create_simple_sandbox(
746        &self,
747        application_guid: &str,
748        name: &str,
749    ) -> Result<Sandbox, SandboxError> {
750        let request = CreateSandboxRequest {
751            name: name.to_string(),
752            description: None,
753            auto_recreate: None,
754            custom_fields: None,
755            team_identifiers: None,
756        };
757
758        self.create_sandbox(application_guid, request).await
759    }
760
761    /// Create a sandbox with auto-recreate enabled
762    ///
763    /// # Arguments
764    ///
765    /// * `application_guid` - The GUID of the application
766    /// * `name` - The name of the sandbox
767    /// * `description` - Optional description
768    ///
769    /// # Returns
770    ///
771    /// A `Result` containing the created sandbox or an error.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error if the API request fails, the sandbox is not found,
776    /// or authentication/authorization fails.
777    pub async fn create_auto_recreate_sandbox(
778        &self,
779        application_guid: &str,
780        name: &str,
781        description: Option<String>,
782    ) -> Result<Sandbox, SandboxError> {
783        let request = CreateSandboxRequest {
784            name: name.to_string(),
785            description,
786            auto_recreate: Some(true),
787            custom_fields: None,
788            team_identifiers: None,
789        };
790
791        self.create_sandbox(application_guid, request).await
792    }
793
794    /// Update sandbox name
795    ///
796    /// # Arguments
797    ///
798    /// * `application_guid` - The GUID of the application
799    /// * `sandbox_guid` - The GUID of the sandbox
800    /// * `new_name` - The new name for the sandbox
801    ///
802    /// # Returns
803    ///
804    /// A `Result` containing the updated sandbox or an error.
805    ///
806    /// # Errors
807    ///
808    /// Returns an error if the API request fails, the sandbox is not found,
809    /// or authentication/authorization fails.
810    pub async fn update_sandbox_name(
811        &self,
812        application_guid: &str,
813        sandbox_guid: &str,
814        new_name: &str,
815    ) -> Result<Sandbox, SandboxError> {
816        let request = UpdateSandboxRequest {
817            name: Some(new_name.to_string()),
818            description: None,
819            auto_recreate: None,
820            custom_fields: None,
821            team_identifiers: None,
822        };
823
824        self.update_sandbox(application_guid, sandbox_guid, request)
825            .await
826    }
827
828    /// Count sandboxes for an application
829    ///
830    /// # Arguments
831    ///
832    /// * `application_guid` - The GUID of the application
833    ///
834    /// # Returns
835    ///
836    /// A `Result` containing the count of sandboxes or an error.
837    ///
838    /// # Errors
839    ///
840    /// Returns an error if the API request fails, the sandbox is not found,
841    /// or authentication/authorization fails.
842    pub async fn count_sandboxes(&self, application_guid: &str) -> Result<usize, SandboxError> {
843        let sandboxes = self.list_sandboxes(application_guid, None).await?;
844        Ok(sandboxes.len())
845    }
846
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if the API request fails, the sandbox is not found,
851    /// or authentication/authorization fails.
852    /// Get numeric `sandbox_id` from sandbox GUID.
853    ///
854    /// This is needed for XML API operations that require numeric IDs.
855    ///
856    /// # Arguments
857    ///
858    /// * `application_guid` - The GUID of the application
859    /// * `sandbox_guid` - The sandbox GUID
860    ///
861    /// # Returns
862    ///
863    ///
864    /// # Errors
865    ///
866    /// Returns an error if the API request fails, the sandbox is not found,
867    /// or authentication/authorization fails.
868    /// A `Result` containing the numeric `sandbox_id` as a string.
869    ///
870    /// # Errors
871    ///
872    /// Returns an error if the API request fails, the sandbox is not found,
873    /// or authentication/authorization fails.
874    pub async fn get_sandbox_id_from_guid(
875        &self,
876        application_guid: &str,
877        sandbox_guid: &str,
878    ) -> Result<String, SandboxError> {
879        let sandbox = self.get_sandbox(application_guid, sandbox_guid).await?;
880        match sandbox.id {
881            Some(id) => Ok(id.to_string()),
882            None => Err(SandboxError::InvalidInput(
883                "Sandbox has no numeric ID".to_string(),
884            )),
885        }
886    }
887
888    /// Create sandbox if it doesn't exist, or return existing sandbox.
889    ///
890    /// This method implements the "check and create" pattern commonly needed
891    /// for automated workflows.
892    ///
893    /// # Arguments
894    ///
895    /// * `application_guid` - The GUID of the application
896    /// * `name` - The name of the sandbox
897    /// * `description` - Optional description for new sandboxes
898    ///
899    /// # Returns
900    ///
901    /// A `Result` containing the sandbox (existing or newly created).
902    ///
903    /// # Errors
904    ///
905    /// Returns an error if the API request fails, the sandbox is not found,
906    /// or authentication/authorization fails.
907    pub async fn create_sandbox_if_not_exists(
908        &self,
909        application_guid: &str,
910        name: &str,
911        description: Option<String>,
912    ) -> Result<Sandbox, SandboxError> {
913        // First, check if sandbox already exists
914        if let Some(existing_sandbox) = self.get_sandbox_by_name(application_guid, name).await? {
915            return Ok(existing_sandbox);
916        }
917
918        // Sandbox doesn't exist, create it
919        let create_request = CreateSandboxRequest {
920            name: name.to_string(),
921            description,
922            auto_recreate: Some(true), // Enable auto-recreate by default for CI/CD
923            custom_fields: None,
924            team_identifiers: None,
925        };
926
927        self.create_sandbox(application_guid, create_request).await
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    #[test]
936    fn test_validate_create_request() {
937        // Valid request
938        let valid_request = CreateSandboxRequest {
939            name: "valid-sandbox".to_string(),
940            description: None,
941            auto_recreate: None,
942            custom_fields: None,
943            team_identifiers: None,
944        };
945        assert!(SandboxApi::validate_create_request(&valid_request).is_ok());
946
947        // Empty name
948        let empty_name_request = CreateSandboxRequest {
949            name: String::new(),
950            description: None,
951            auto_recreate: None,
952            custom_fields: None,
953            team_identifiers: None,
954        };
955        assert!(SandboxApi::validate_create_request(&empty_name_request).is_err());
956
957        // Long name
958        let long_name_request = CreateSandboxRequest {
959            name: "x".repeat(300),
960            description: None,
961            auto_recreate: None,
962            custom_fields: None,
963            team_identifiers: None,
964        };
965        assert!(SandboxApi::validate_create_request(&long_name_request).is_err());
966
967        // Invalid characters
968        let invalid_char_request = CreateSandboxRequest {
969            name: "invalid<name>".to_string(),
970            description: None,
971            auto_recreate: None,
972            custom_fields: None,
973            team_identifiers: None,
974        };
975        assert!(SandboxApi::validate_create_request(&invalid_char_request).is_err());
976    }
977
978    #[test]
979    fn test_sandbox_list_params_to_query() {
980        let params = SandboxListParams {
981            name: Some("test".to_string()),
982            page: Some(1),
983            size: Some(10),
984            ..Default::default()
985        };
986
987        let query_params: Vec<_> = params.into();
988        assert_eq!(query_params.len(), 3);
989        assert!(query_params.contains(&("name".to_string(), "test".to_string())));
990        assert!(query_params.contains(&("page".to_string(), "1".to_string())));
991        assert!(query_params.contains(&("size".to_string(), "10".to_string())));
992    }
993
994    #[test]
995    fn test_sandbox_error_display() {
996        let error = SandboxError::NotFound;
997        assert_eq!(error.to_string(), "Sandbox not found");
998
999        let error = SandboxError::InvalidInput("test".to_string());
1000        assert_eq!(error.to_string(), "Invalid input: test");
1001
1002        let error = SandboxError::LimitExceeded;
1003        assert_eq!(error.to_string(), "Maximum number of sandboxes reached");
1004    }
1005}