Skip to main content

syncable_cli/wizard/
cloud_provider_data.rs

1//! Cloud provider regions and machine types for the deployment wizard
2//!
3//! For Hetzner: Uses DYNAMIC fetching from Hetzner API for real-time
4//! availability and pricing. No hardcoded fallback - ensures agent always
5//! uses current data for smart resource selection.
6//!
7//! For GCP: Uses static data (dynamic fetching not yet implemented).
8
9use crate::platform::api::client::PlatformApiClient;
10use crate::platform::api::types::{CloudProvider, LocationWithAvailability, ServerTypeSummary};
11
12/// A cloud region/location option (static data for non-Hetzner providers)
13#[derive(Debug, Clone)]
14pub struct CloudRegion {
15    /// Region ID (e.g., "us-central1")
16    pub id: &'static str,
17    /// Human-readable name (e.g., "Iowa")
18    pub name: &'static str,
19    /// Geographic location (e.g., "US Central")
20    pub location: &'static str,
21}
22
23/// A machine/instance type option (static data for non-Hetzner providers)
24#[derive(Debug, Clone)]
25pub struct MachineType {
26    /// Machine type ID (e.g., "e2-small")
27    pub id: &'static str,
28    /// Display name
29    pub name: &'static str,
30    /// Number of vCPUs (as string to handle fractional)
31    pub cpu: &'static str,
32    /// Memory amount (e.g., "4 GB")
33    pub memory: &'static str,
34    /// Optional description (e.g., "Shared-core")
35    pub description: Option<&'static str>,
36}
37
38// =============================================================================
39// Azure Container Apps - Resource Pairs
40// =============================================================================
41
42/// Azure Container Apps paired CPU/memory combo
43#[derive(Debug, Clone)]
44pub struct AcaResourcePair {
45    /// CPU allocation (e.g., "0.25")
46    pub cpu: &'static str,
47    /// Memory allocation (e.g., "0.5Gi")
48    pub memory: &'static str,
49    /// Display label (e.g., "0.25 vCPU, 0.5 GB")
50    pub label: &'static str,
51}
52
53/// Azure Container Apps resource pairs (fixed by Azure, 8 combos)
54pub static ACA_RESOURCE_PAIRS: &[AcaResourcePair] = &[
55    AcaResourcePair {
56        cpu: "0.25",
57        memory: "0.5Gi",
58        label: "0.25 vCPU, 0.5 GB",
59    },
60    AcaResourcePair {
61        cpu: "0.5",
62        memory: "1.0Gi",
63        label: "0.5 vCPU, 1 GB",
64    },
65    AcaResourcePair {
66        cpu: "0.75",
67        memory: "1.5Gi",
68        label: "0.75 vCPU, 1.5 GB",
69    },
70    AcaResourcePair {
71        cpu: "1.0",
72        memory: "2.0Gi",
73        label: "1 vCPU, 2 GB",
74    },
75    AcaResourcePair {
76        cpu: "1.25",
77        memory: "2.5Gi",
78        label: "1.25 vCPU, 2.5 GB",
79    },
80    AcaResourcePair {
81        cpu: "1.5",
82        memory: "3.0Gi",
83        label: "1.5 vCPU, 3 GB",
84    },
85    AcaResourcePair {
86        cpu: "1.75",
87        memory: "3.5Gi",
88        label: "1.75 vCPU, 3.5 GB",
89    },
90    AcaResourcePair {
91        cpu: "2.0",
92        memory: "4.0Gi",
93        label: "2 vCPU, 4 GB",
94    },
95];
96
97/// Azure regions (Container Apps supported regions)
98pub static AZURE_REGIONS: &[CloudRegion] = &[
99    // Americas
100    CloudRegion {
101        id: "eastus",
102        name: "East US",
103        location: "Virginia",
104    },
105    CloudRegion {
106        id: "eastus2",
107        name: "East US 2",
108        location: "Virginia",
109    },
110    CloudRegion {
111        id: "westus",
112        name: "West US",
113        location: "California",
114    },
115    CloudRegion {
116        id: "westus2",
117        name: "West US 2",
118        location: "Washington",
119    },
120    CloudRegion {
121        id: "westus3",
122        name: "West US 3",
123        location: "Arizona",
124    },
125    CloudRegion {
126        id: "centralus",
127        name: "Central US",
128        location: "Iowa",
129    },
130    CloudRegion {
131        id: "canadacentral",
132        name: "Canada Central",
133        location: "Toronto",
134    },
135    CloudRegion {
136        id: "brazilsouth",
137        name: "Brazil South",
138        location: "São Paulo",
139    },
140    // Europe
141    CloudRegion {
142        id: "westeurope",
143        name: "West Europe",
144        location: "Netherlands",
145    },
146    CloudRegion {
147        id: "northeurope",
148        name: "North Europe",
149        location: "Ireland",
150    },
151    CloudRegion {
152        id: "uksouth",
153        name: "UK South",
154        location: "London",
155    },
156    CloudRegion {
157        id: "ukwest",
158        name: "UK West",
159        location: "Cardiff",
160    },
161    CloudRegion {
162        id: "germanywestcentral",
163        name: "Germany West Central",
164        location: "Frankfurt",
165    },
166    CloudRegion {
167        id: "francecentral",
168        name: "France Central",
169        location: "Paris",
170    },
171    CloudRegion {
172        id: "swedencentral",
173        name: "Sweden Central",
174        location: "Gävle",
175    },
176    // Asia Pacific
177    CloudRegion {
178        id: "eastasia",
179        name: "East Asia",
180        location: "Hong Kong",
181    },
182    CloudRegion {
183        id: "southeastasia",
184        name: "Southeast Asia",
185        location: "Singapore",
186    },
187    CloudRegion {
188        id: "japaneast",
189        name: "Japan East",
190        location: "Tokyo",
191    },
192    CloudRegion {
193        id: "japanwest",
194        name: "Japan West",
195        location: "Osaka",
196    },
197    CloudRegion {
198        id: "koreacentral",
199        name: "Korea Central",
200        location: "Seoul",
201    },
202    CloudRegion {
203        id: "australiaeast",
204        name: "Australia East",
205        location: "Sydney",
206    },
207    CloudRegion {
208        id: "centralindia",
209        name: "Central India",
210        location: "Pune",
211    },
212];
213
214// =============================================================================
215// GCP Cloud Run - CPU/Memory Constraints
216// =============================================================================
217
218/// GCP Cloud Run CPU/memory constraint
219#[derive(Debug, Clone)]
220pub struct CloudRunCpuMemory {
221    /// CPU allocation (e.g., "1")
222    pub cpu: &'static str,
223    /// Available memory options for this CPU level
224    pub memory_options: &'static [&'static str],
225    /// Default memory for this CPU level
226    pub default_memory: &'static str,
227}
228
229/// GCP Cloud Run CPU/memory constraints (matching frontend CLOUD_RUN_MEMORY_CONSTRAINTS)
230pub static CLOUD_RUN_CPU_MEMORY: &[CloudRunCpuMemory] = &[
231    CloudRunCpuMemory {
232        cpu: "1",
233        memory_options: &["128Mi", "256Mi", "512Mi", "1Gi", "2Gi", "4Gi"],
234        default_memory: "512Mi",
235    },
236    CloudRunCpuMemory {
237        cpu: "2",
238        memory_options: &["256Mi", "512Mi", "1Gi", "2Gi", "4Gi", "8Gi"],
239        default_memory: "2Gi",
240    },
241    CloudRunCpuMemory {
242        cpu: "4",
243        memory_options: &["512Mi", "1Gi", "2Gi", "4Gi", "8Gi", "16Gi"],
244        default_memory: "4Gi",
245    },
246    CloudRunCpuMemory {
247        cpu: "6",
248        memory_options: &["1Gi", "2Gi", "4Gi", "8Gi", "16Gi", "24Gi"],
249        default_memory: "8Gi",
250    },
251    CloudRunCpuMemory {
252        cpu: "8",
253        memory_options: &["2Gi", "4Gi", "8Gi", "16Gi", "24Gi", "32Gi"],
254        default_memory: "16Gi",
255    },
256];
257
258// =============================================================================
259// Validation Helpers
260// =============================================================================
261
262/// Validate that a CPU/memory pair is valid for Azure Container Apps
263pub fn validate_aca_cpu_memory(cpu: &str, memory: &str) -> bool {
264    ACA_RESOURCE_PAIRS
265        .iter()
266        .any(|p| p.cpu == cpu && p.memory == memory)
267}
268
269/// Validate that a CPU/memory pair is valid for GCP Cloud Run
270pub fn validate_cloud_run_cpu_memory(cpu: &str, memory: &str) -> bool {
271    CLOUD_RUN_CPU_MEMORY
272        .iter()
273        .any(|c| c.cpu == cpu && c.memory_options.contains(&memory))
274}
275
276/// Get available memory options for a given GCP Cloud Run CPU level
277pub fn get_cloud_run_memory_for_cpu(cpu: &str) -> &'static [&'static str] {
278    CLOUD_RUN_CPU_MEMORY
279        .iter()
280        .find(|c| c.cpu == cpu)
281        .map(|c| c.memory_options)
282        .unwrap_or(&[])
283}
284
285// =============================================================================
286// GCP (Google Cloud Platform) - Static data
287// =============================================================================
288
289/// GCP regions
290pub static GCP_REGIONS: &[CloudRegion] = &[
291    // Americas
292    CloudRegion {
293        id: "us-central1",
294        name: "Iowa",
295        location: "US Central",
296    },
297    CloudRegion {
298        id: "us-east1",
299        name: "South Carolina",
300        location: "US East",
301    },
302    CloudRegion {
303        id: "us-east4",
304        name: "Virginia",
305        location: "US East",
306    },
307    CloudRegion {
308        id: "us-west1",
309        name: "Oregon",
310        location: "US West",
311    },
312    CloudRegion {
313        id: "us-west2",
314        name: "Los Angeles",
315        location: "US West",
316    },
317    // Europe
318    CloudRegion {
319        id: "europe-west1",
320        name: "Belgium",
321        location: "Europe",
322    },
323    CloudRegion {
324        id: "europe-west2",
325        name: "London",
326        location: "UK",
327    },
328    CloudRegion {
329        id: "europe-west3",
330        name: "Frankfurt",
331        location: "Germany",
332    },
333    CloudRegion {
334        id: "europe-west4",
335        name: "Netherlands",
336        location: "Europe",
337    },
338    CloudRegion {
339        id: "europe-north1",
340        name: "Finland",
341        location: "Europe",
342    },
343    // Asia Pacific
344    CloudRegion {
345        id: "asia-east1",
346        name: "Taiwan",
347        location: "Asia Pacific",
348    },
349    CloudRegion {
350        id: "asia-northeast1",
351        name: "Tokyo",
352        location: "Japan",
353    },
354    CloudRegion {
355        id: "asia-southeast1",
356        name: "Singapore",
357        location: "Southeast Asia",
358    },
359    CloudRegion {
360        id: "australia-southeast1",
361        name: "Sydney",
362        location: "Australia",
363    },
364];
365
366/// GCP machine types (Compute Engine)
367pub static GCP_MACHINE_TYPES: &[MachineType] = &[
368    // E2 Series (Cost-optimized)
369    MachineType {
370        id: "e2-micro",
371        name: "e2-micro",
372        cpu: "0.25",
373        memory: "1 GB",
374        description: Some("Shared-core"),
375    },
376    MachineType {
377        id: "e2-small",
378        name: "e2-small",
379        cpu: "0.5",
380        memory: "2 GB",
381        description: Some("Shared-core"),
382    },
383    MachineType {
384        id: "e2-medium",
385        name: "e2-medium",
386        cpu: "1",
387        memory: "4 GB",
388        description: Some("Shared-core"),
389    },
390    MachineType {
391        id: "e2-standard-2",
392        name: "e2-standard-2",
393        cpu: "2",
394        memory: "8 GB",
395        description: None,
396    },
397    MachineType {
398        id: "e2-standard-4",
399        name: "e2-standard-4",
400        cpu: "4",
401        memory: "16 GB",
402        description: None,
403    },
404    MachineType {
405        id: "e2-standard-8",
406        name: "e2-standard-8",
407        cpu: "8",
408        memory: "32 GB",
409        description: None,
410    },
411    // N2 Series (Balanced)
412    MachineType {
413        id: "n2-standard-2",
414        name: "n2-standard-2",
415        cpu: "2",
416        memory: "8 GB",
417        description: None,
418    },
419    MachineType {
420        id: "n2-standard-4",
421        name: "n2-standard-4",
422        cpu: "4",
423        memory: "16 GB",
424        description: None,
425    },
426    MachineType {
427        id: "n2-standard-8",
428        name: "n2-standard-8",
429        cpu: "8",
430        memory: "32 GB",
431        description: None,
432    },
433];
434
435// =============================================================================
436// Static Helper Functions (for non-Hetzner providers only)
437// =============================================================================
438
439/// Get static regions for a cloud provider
440/// NOTE: For Hetzner, returns empty - use get_hetzner_regions_dynamic() instead
441pub fn get_regions_for_provider(provider: &CloudProvider) -> &'static [CloudRegion] {
442    match provider {
443        CloudProvider::Hetzner => &[], // Use dynamic fetching for Hetzner
444        CloudProvider::Gcp => GCP_REGIONS,
445        CloudProvider::Azure => AZURE_REGIONS,
446        _ => &[], // AWS not yet supported
447    }
448}
449
450/// Get static machine types for a cloud provider
451/// NOTE: For Hetzner, returns empty - use get_hetzner_server_types_dynamic() instead
452pub fn get_machine_types_for_provider(provider: &CloudProvider) -> &'static [MachineType] {
453    match provider {
454        CloudProvider::Hetzner => &[], // Use dynamic fetching for Hetzner
455        CloudProvider::Gcp => GCP_MACHINE_TYPES,
456        _ => &[], // AWS, Azure not yet supported
457    }
458}
459
460/// Get default region for a provider
461pub fn get_default_region(provider: &CloudProvider) -> &'static str {
462    match provider {
463        CloudProvider::Hetzner => "nbg1",
464        CloudProvider::Gcp => "us-central1",
465        CloudProvider::Azure => "eastus",
466        _ => "",
467    }
468}
469
470/// Get default machine type for a provider
471/// For Azure, returns the default CPU value (used with ACA resource pairs)
472pub fn get_default_machine_type(provider: &CloudProvider) -> &'static str {
473    match provider {
474        CloudProvider::Hetzner => "cx22",
475        CloudProvider::Gcp => "e2-small",
476        CloudProvider::Azure => "0.5",
477        _ => "",
478    }
479}
480
481// =============================================================================
482// Dynamic Types and Fetching (Hetzner)
483// =============================================================================
484
485/// Dynamic cloud region with real-time availability info
486#[derive(Debug, Clone)]
487pub struct DynamicCloudRegion {
488    /// Region ID (e.g., "nbg1")
489    pub id: String,
490    /// Human-readable name (e.g., "Nuremberg")
491    pub name: String,
492    /// Geographic location (e.g., "Germany")
493    pub location: String,
494    /// Network zone (e.g., "eu-central")
495    pub network_zone: String,
496    /// Server types currently available in this region
497    pub available_server_types: Vec<String>,
498}
499
500/// Dynamic machine type with real-time pricing and availability
501#[derive(Debug, Clone)]
502pub struct DynamicMachineType {
503    /// Machine type ID (e.g., "cx22")
504    pub id: String,
505    /// Display name
506    pub name: String,
507    /// Number of vCPUs
508    pub cores: i32,
509    /// Memory in GB
510    pub memory_gb: f64,
511    /// Disk size in GB
512    pub disk_gb: i64,
513    /// Monthly price in EUR (from Hetzner API)
514    pub price_monthly: f64,
515    /// Hourly price in EUR (from Hetzner API)
516    pub price_hourly: f64,
517    /// Locations where this type is currently available
518    pub available_in: Vec<String>,
519}
520
521/// Result of dynamic Hetzner data fetch
522#[derive(Debug)]
523pub enum HetznerFetchResult<T> {
524    /// Successfully fetched data
525    Success(T),
526    /// Failed to fetch - requires Hetzner credentials
527    NoCredentials,
528    /// Failed to fetch - API error
529    ApiError(String),
530}
531
532/// Convert API LocationWithAvailability to DynamicCloudRegion
533fn location_to_dynamic_region(loc: &LocationWithAvailability) -> DynamicCloudRegion {
534    DynamicCloudRegion {
535        id: loc.location.name.clone(),
536        name: loc.location.city.clone(),
537        location: loc.location.country.clone(),
538        network_zone: loc.location.network_zone.clone(),
539        available_server_types: loc.available_server_types.clone(),
540    }
541}
542
543/// Convert API ServerTypeSummary to DynamicMachineType
544fn server_type_to_dynamic(st: &ServerTypeSummary) -> DynamicMachineType {
545    DynamicMachineType {
546        id: st.name.clone(),
547        name: st.name.clone(),
548        cores: st.cores,
549        memory_gb: st.memory_gb,
550        disk_gb: st.disk_gb,
551        price_monthly: st.price_monthly,
552        price_hourly: st.price_hourly,
553        available_in: st.available_in.clone(),
554    }
555}
556
557/// Fetch Hetzner regions dynamically with REAL-TIME availability
558///
559/// Uses the /api/deployments/availability/locations endpoint which checks
560/// Hetzner's datacenter API for actual capacity - not just what exists.
561/// Returns only regions where server types are CURRENTLY available.
562///
563/// # Errors
564/// Returns error if credentials are missing or API call fails.
565pub async fn get_hetzner_regions_dynamic(
566    client: &PlatformApiClient,
567    project_id: &str,
568) -> HetznerFetchResult<Vec<DynamicCloudRegion>> {
569    match client.get_hetzner_locations(project_id).await {
570        Ok(locations) => {
571            HetznerFetchResult::Success(locations.iter().map(location_to_dynamic_region).collect())
572        }
573        Err(e) => {
574            let error_msg = e.to_string();
575            // Check for various credential-related error patterns
576            if error_msg.contains("credentials")
577                || error_msg.contains("Unauthorized")
578                || error_msg.contains("token")
579                || error_msg.contains("API token")
580                || error_msg.contains("401")
581                || error_msg.contains("412")
582            // failedPrecondition
583            {
584                HetznerFetchResult::NoCredentials
585            } else {
586                HetznerFetchResult::ApiError(error_msg)
587            }
588        }
589    }
590}
591
592/// Fetch Hetzner server types dynamically with REAL-TIME availability and pricing
593///
594/// Uses the /api/deployments/availability/server-types endpoint which returns
595/// server types sorted by price with ACTUAL availability per datacenter.
596/// Only returns server types that are currently in stock.
597///
598/// # Errors
599/// Returns error if credentials are missing or API call fails.
600pub async fn get_hetzner_server_types_dynamic(
601    client: &PlatformApiClient,
602    project_id: &str,
603    preferred_location: Option<&str>,
604) -> HetznerFetchResult<Vec<DynamicMachineType>> {
605    match client
606        .get_hetzner_server_types(project_id, preferred_location)
607        .await
608    {
609        Ok(server_types) => {
610            HetznerFetchResult::Success(server_types.iter().map(server_type_to_dynamic).collect())
611        }
612        Err(e) => {
613            let error_msg = e.to_string();
614            // Check for various credential-related error patterns
615            if error_msg.contains("credentials")
616                || error_msg.contains("Unauthorized")
617                || error_msg.contains("token")
618                || error_msg.contains("API token")
619                || error_msg.contains("401")
620                || error_msg.contains("412")
621            // failedPrecondition
622            {
623                HetznerFetchResult::NoCredentials
624            } else {
625                HetznerFetchResult::ApiError(error_msg)
626            }
627        }
628    }
629}
630
631/// Check availability of a specific server type at a location
632///
633/// Returns (available, reason, alternative_locations):
634/// - available: true if the server type can be provisioned now
635/// - reason: None if available, Some("capacity"|"unsupported") if not
636/// - alternative_locations: Other locations where this server type IS available
637///
638/// The agent uses this for pre-deployment validation and smart fallback.
639pub async fn check_hetzner_availability(
640    client: &PlatformApiClient,
641    project_id: &str,
642    location: &str,
643    server_type: &str,
644) -> (bool, Option<String>, Vec<String>) {
645    match client
646        .check_hetzner_availability(project_id, location, server_type)
647        .await
648    {
649        Ok(result) => (
650            result.available,
651            result.reason,
652            result.alternative_locations.unwrap_or_default(),
653        ),
654        Err(e) => {
655            // On error, return unavailable with error message
656            (false, Some(format!("Failed to check: {}", e)), vec![])
657        }
658    }
659}
660
661/// Get recommended server type for a workload profile
662///
663/// Fetches real-time pricing and returns the cheapest server type meeting requirements:
664/// - minimal: 1 core, 2GB RAM (development/testing)
665/// - standard: 2 cores, 4GB RAM (small production)
666/// - performance: 4 cores, 8GB RAM with dedicated CPU (production)
667/// - high-memory: 2 cores, 16GB RAM (memory-intensive workloads)
668///
669/// The agent uses this for intelligent resource recommendations.
670pub async fn get_recommended_server_type(
671    client: &PlatformApiClient,
672    project_id: &str,
673    profile: &str,
674    preferred_location: Option<&str>,
675) -> Option<DynamicMachineType> {
676    let (min_cores, min_memory, prefer_dedicated) = match profile {
677        "minimal" => (1, 2.0, false),
678        "standard" => (2, 4.0, false),
679        "performance" => (4, 8.0, true),
680        "high-memory" => (2, 16.0, false),
681        _ => (2, 4.0, false), // Default to standard
682    };
683
684    let server_types =
685        match get_hetzner_server_types_dynamic(client, project_id, preferred_location).await {
686            HetznerFetchResult::Success(types) => types,
687            _ => return None,
688        };
689
690    // Filter by requirements and find cheapest
691    server_types
692        .into_iter()
693        .filter(|st| {
694            st.cores >= min_cores
695                && st.memory_gb >= min_memory
696                && (!prefer_dedicated || st.name.starts_with("ccx"))
697        })
698        .filter(|st| {
699            // If preferred location is set, only include types available there
700            preferred_location.is_none_or(|loc| st.available_in.contains(&loc.to_string()))
701        })
702        .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
703}
704
705/// Find the best region for a workload based on availability
706///
707/// Returns the region with the most available server types,
708/// preferring regions in the specified network zone.
709pub async fn find_best_region(
710    client: &PlatformApiClient,
711    project_id: &str,
712    preferred_zone: Option<&str>,
713) -> Option<DynamicCloudRegion> {
714    let regions = match get_hetzner_regions_dynamic(client, project_id).await {
715        HetznerFetchResult::Success(r) => r,
716        _ => return None,
717    };
718
719    // Sort by availability count, preferring specified zone
720    let mut sorted_regions = regions;
721    sorted_regions.sort_by(|a, b| {
722        let a_zone_match = preferred_zone.is_some_and(|z| a.network_zone == z);
723        let b_zone_match = preferred_zone.is_some_and(|z| b.network_zone == z);
724
725        match (a_zone_match, b_zone_match) {
726            (true, false) => std::cmp::Ordering::Less,
727            (false, true) => std::cmp::Ordering::Greater,
728            _ => b
729                .available_server_types
730                .len()
731                .cmp(&a.available_server_types.len()),
732        }
733    });
734
735    sorted_regions.into_iter().next()
736}
737
738/// Find cheapest available server type for a region
739///
740/// Returns the cheapest server type that is currently available
741/// in the specified region.
742pub async fn find_cheapest_available(
743    client: &PlatformApiClient,
744    project_id: &str,
745    region: &str,
746) -> Option<DynamicMachineType> {
747    let server_types =
748        match get_hetzner_server_types_dynamic(client, project_id, Some(region)).await {
749            HetznerFetchResult::Success(types) => types,
750            _ => return None,
751        };
752
753    // Filter to only available types in this region, sort by price
754    server_types
755        .into_iter()
756        .filter(|st| st.available_in.contains(&region.to_string()))
757        .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
758}
759
760// =============================================================================
761// Display Formatting
762// =============================================================================
763
764/// Format dynamic region for display
765pub fn format_dynamic_region_display(region: &DynamicCloudRegion) -> String {
766    if region.available_server_types.is_empty() {
767        format!(
768            "{} ({}) - checking availability...",
769            region.name, region.location
770        )
771    } else {
772        format!(
773            "{} ({}) · {} server types available",
774            region.name,
775            region.location,
776            region.available_server_types.len()
777        )
778    }
779}
780
781/// Format dynamic machine type for display with pricing
782pub fn format_dynamic_machine_type_display(machine: &DynamicMachineType) -> String {
783    format!(
784        "{} · {} vCPU · {:.0} GB · €{:.2}/mo",
785        machine.name, machine.cores, machine.memory_gb, machine.price_monthly
786    )
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn test_gcp_regions() {
795        assert!(!GCP_REGIONS.is_empty());
796        assert!(GCP_REGIONS.iter().any(|r| r.id == "us-central1"));
797    }
798
799    #[test]
800    fn test_gcp_machine_types() {
801        assert!(!GCP_MACHINE_TYPES.is_empty());
802        assert!(GCP_MACHINE_TYPES.iter().any(|m| m.id == "e2-small"));
803    }
804
805    #[test]
806    fn test_hetzner_returns_empty_static() {
807        // Hetzner should return empty from static functions
808        // because we want to force dynamic fetching
809        let regions = get_regions_for_provider(&CloudProvider::Hetzner);
810        assert!(regions.is_empty());
811
812        let machines = get_machine_types_for_provider(&CloudProvider::Hetzner);
813        assert!(machines.is_empty());
814    }
815
816    #[test]
817    fn test_gcp_returns_static_data() {
818        let regions = get_regions_for_provider(&CloudProvider::Gcp);
819        assert!(!regions.is_empty());
820
821        let machines = get_machine_types_for_provider(&CloudProvider::Gcp);
822        assert!(!machines.is_empty());
823    }
824
825    #[test]
826    fn test_defaults() {
827        assert_eq!(get_default_region(&CloudProvider::Hetzner), "nbg1");
828        assert_eq!(get_default_region(&CloudProvider::Gcp), "us-central1");
829        assert_eq!(get_default_region(&CloudProvider::Azure), "eastus");
830        assert_eq!(get_default_machine_type(&CloudProvider::Hetzner), "cx22");
831        assert_eq!(get_default_machine_type(&CloudProvider::Gcp), "e2-small");
832        assert_eq!(get_default_machine_type(&CloudProvider::Azure), "0.5");
833    }
834
835    #[test]
836    fn test_azure_regions() {
837        assert!(!AZURE_REGIONS.is_empty());
838        assert_eq!(AZURE_REGIONS.len(), 22);
839        assert!(AZURE_REGIONS.iter().any(|r| r.id == "eastus"));
840        assert!(AZURE_REGIONS.iter().any(|r| r.id == "westeurope"));
841    }
842
843    #[test]
844    fn test_azure_regions_via_provider() {
845        let regions = get_regions_for_provider(&CloudProvider::Azure);
846        assert!(!regions.is_empty());
847        assert_eq!(regions.len(), 22);
848    }
849
850    #[test]
851    fn test_aca_resource_pairs() {
852        assert_eq!(ACA_RESOURCE_PAIRS.len(), 8);
853        assert_eq!(ACA_RESOURCE_PAIRS[0].cpu, "0.25");
854        assert_eq!(ACA_RESOURCE_PAIRS[0].memory, "0.5Gi");
855        assert_eq!(ACA_RESOURCE_PAIRS[7].cpu, "2.0");
856        assert_eq!(ACA_RESOURCE_PAIRS[7].memory, "4.0Gi");
857    }
858
859    #[test]
860    fn test_validate_aca_cpu_memory() {
861        assert!(validate_aca_cpu_memory("0.5", "1.0Gi"));
862        assert!(validate_aca_cpu_memory("2.0", "4.0Gi"));
863        assert!(!validate_aca_cpu_memory("0.5", "4.0Gi")); // invalid pair
864        assert!(!validate_aca_cpu_memory("3.0", "8.0Gi")); // non-existent
865    }
866
867    #[test]
868    fn test_cloud_run_cpu_memory() {
869        assert_eq!(CLOUD_RUN_CPU_MEMORY.len(), 5);
870        assert_eq!(CLOUD_RUN_CPU_MEMORY[0].cpu, "1");
871        assert_eq!(CLOUD_RUN_CPU_MEMORY[0].default_memory, "512Mi");
872    }
873
874    #[test]
875    fn test_validate_cloud_run_cpu_memory() {
876        assert!(validate_cloud_run_cpu_memory("2", "4Gi"));
877        assert!(validate_cloud_run_cpu_memory("1", "512Mi"));
878        assert!(!validate_cloud_run_cpu_memory("1", "16Gi")); // too big for 1 CPU
879        assert!(!validate_cloud_run_cpu_memory("3", "4Gi")); // non-existent CPU
880    }
881
882    #[test]
883    fn test_get_cloud_run_memory_for_cpu() {
884        let options = get_cloud_run_memory_for_cpu("1");
885        assert_eq!(options.len(), 6);
886        assert!(options.contains(&"512Mi"));
887        assert!(options.contains(&"4Gi"));
888
889        let empty = get_cloud_run_memory_for_cpu("99");
890        assert!(empty.is_empty());
891    }
892
893    #[test]
894    fn test_dynamic_region_display() {
895        let region = DynamicCloudRegion {
896            id: "nbg1".to_string(),
897            name: "Nuremberg".to_string(),
898            location: "Germany".to_string(),
899            network_zone: "eu-central".to_string(),
900            available_server_types: vec!["cx22".to_string(), "cx32".to_string()],
901        };
902        let display = format_dynamic_region_display(&region);
903        assert!(display.contains("Nuremberg"));
904        assert!(display.contains("2 server types"));
905    }
906
907    #[test]
908    fn test_dynamic_machine_display() {
909        let machine = DynamicMachineType {
910            id: "cx22".to_string(),
911            name: "cx22".to_string(),
912            cores: 2,
913            memory_gb: 4.0,
914            disk_gb: 40,
915            price_monthly: 5.95,
916            price_hourly: 0.008,
917            available_in: vec!["nbg1".to_string()],
918        };
919        let display = format_dynamic_machine_type_display(&machine);
920        assert!(display.contains("cx22"));
921        assert!(display.contains("2 vCPU"));
922        assert!(display.contains("€5.95/mo"));
923    }
924}