syncable_cli/wizard/
infrastructure_selection.rs

1//! Infrastructure selection step for the deployment wizard
2//!
3//! Handles region and machine type selection for Cloud Runner deployments.
4
5use crate::platform::api::types::CloudProvider;
6use crate::wizard::cloud_provider_data::{
7    get_default_machine_type, get_default_region, get_machine_types_for_provider,
8    get_regions_for_provider, CloudRegion, MachineType,
9};
10use crate::wizard::render::{display_step_header, wizard_render_config};
11use colored::Colorize;
12use inquire::{InquireError, Select};
13use std::fmt;
14
15/// Result of infrastructure selection step
16#[derive(Debug, Clone)]
17pub enum InfrastructureSelectionResult {
18    /// User selected region and machine type
19    Selected {
20        region: String,
21        machine_type: String,
22    },
23    /// User wants to go back
24    Back,
25    /// User cancelled the wizard
26    Cancelled,
27}
28
29/// Wrapper for displaying region options in the selection menu
30struct RegionOption<'a> {
31    region: &'a CloudRegion,
32}
33
34impl<'a> fmt::Display for RegionOption<'a> {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(
37            f,
38            "{}  {}",
39            self.region.id.cyan(),
40            format!("{}  ({})", self.region.name, self.region.location).dimmed()
41        )
42    }
43}
44
45/// Wrapper for displaying machine type options in the selection menu
46struct MachineTypeOption<'a> {
47    machine: &'a MachineType,
48}
49
50impl<'a> fmt::Display for MachineTypeOption<'a> {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        let specs = format!("{} vCPU · {}", self.machine.cpu, self.machine.memory);
53        let desc = self
54            .machine
55            .description
56            .map(|d| format!(" · {}", d))
57            .unwrap_or_default();
58        write!(
59            f,
60            "{}  {}{}",
61            self.machine.name.cyan(),
62            specs.dimmed(),
63            desc.dimmed()
64        )
65    }
66}
67
68/// Select region and machine type for Cloud Runner deployment
69pub fn select_infrastructure(
70    provider: &CloudProvider,
71    step_number: u8,
72) -> InfrastructureSelectionResult {
73    // Select region first
74    let region = match select_region(provider, step_number) {
75        Some(r) => r,
76        None => return InfrastructureSelectionResult::Back,
77    };
78
79    // Then select machine type
80    match select_machine_type(provider, &region) {
81        Some(machine_type) => InfrastructureSelectionResult::Selected {
82            region,
83            machine_type,
84        },
85        None => InfrastructureSelectionResult::Back,
86    }
87}
88
89/// Select region/location for deployment
90fn select_region(provider: &CloudProvider, step_number: u8) -> Option<String> {
91    let provider_name = match provider {
92        CloudProvider::Hetzner => "Hetzner",
93        CloudProvider::Gcp => "GCP",
94        _ => "Cloud",
95    };
96
97    display_step_header(
98        step_number,
99        &format!("Select {} Region", provider_name),
100        "Choose the geographic location for your deployment.",
101    );
102
103    let regions = get_regions_for_provider(provider);
104    if regions.is_empty() {
105        println!(
106            "\n{} No regions available for this provider.",
107            "⚠".yellow()
108        );
109        return None;
110    }
111
112    let default_region = get_default_region(provider);
113    let default_index = regions
114        .iter()
115        .position(|r| r.id == default_region)
116        .unwrap_or(0);
117
118    let options: Vec<RegionOption> = regions.iter().map(|r| RegionOption { region: r }).collect();
119
120    let selection = Select::new("Select region:", options)
121        .with_render_config(wizard_render_config())
122        .with_starting_cursor(default_index)
123        .with_help_message("Use ↑/↓ to navigate, Enter to select")
124        .prompt();
125
126    match selection {
127        Ok(selected) => {
128            println!(
129                "\n{} Selected region: {} ({})",
130                "✓".green(),
131                selected.region.name.cyan(),
132                selected.region.id
133            );
134            Some(selected.region.id.to_string())
135        }
136        Err(InquireError::OperationCanceled) => None,
137        Err(InquireError::OperationInterrupted) => None,
138        Err(_) => None,
139    }
140}
141
142/// Select machine/instance type for deployment
143fn select_machine_type(provider: &CloudProvider, _region: &str) -> Option<String> {
144    println!();
145    println!(
146        "{}",
147        "─── Machine Type ────────────────────────────".dimmed()
148    );
149    println!(
150        "  {}",
151        "Select the VM size for your deployment.".dimmed()
152    );
153
154    let machine_types = get_machine_types_for_provider(provider);
155    if machine_types.is_empty() {
156        println!(
157            "\n{} No machine types available for this provider.",
158            "⚠".yellow()
159        );
160        return None;
161    }
162
163    let default_machine = get_default_machine_type(provider);
164    let default_index = machine_types
165        .iter()
166        .position(|m| m.id == default_machine)
167        .unwrap_or(0);
168
169    let options: Vec<MachineTypeOption> = machine_types
170        .iter()
171        .map(|m| MachineTypeOption { machine: m })
172        .collect();
173
174    let selection = Select::new("Select machine type:", options)
175        .with_render_config(wizard_render_config())
176        .with_starting_cursor(default_index)
177        .with_help_message("Smaller = cheaper, Larger = more resources")
178        .prompt();
179
180    match selection {
181        Ok(selected) => {
182            println!(
183                "\n{} Selected: {} ({} vCPU, {})",
184                "✓".green(),
185                selected.machine.name.cyan(),
186                selected.machine.cpu,
187                selected.machine.memory
188            );
189            Some(selected.machine.id.to_string())
190        }
191        Err(InquireError::OperationCanceled) => None,
192        Err(InquireError::OperationInterrupted) => None,
193        Err(_) => None,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_region_option_display() {
203        let region = CloudRegion {
204            id: "nbg1",
205            name: "Nuremberg",
206            location: "Germany",
207        };
208        let option = RegionOption { region: &region };
209        let display = format!("{}", option);
210        assert!(display.contains("nbg1"));
211        assert!(display.contains("Nuremberg"));
212    }
213
214    #[test]
215    fn test_machine_type_option_display() {
216        let machine = MachineType {
217            id: "cx22",
218            name: "CX22",
219            cpu: "2",
220            memory: "4 GB",
221            description: Some("Shared Intel"),
222        };
223        let option = MachineTypeOption { machine: &machine };
224        let display = format!("{}", option);
225        assert!(display.contains("CX22"));
226        assert!(display.contains("2 vCPU"));
227        assert!(display.contains("4 GB"));
228    }
229
230    #[test]
231    fn test_infrastructure_selection_result_variants() {
232        let selected = InfrastructureSelectionResult::Selected {
233            region: "nbg1".to_string(),
234            machine_type: "cx22".to_string(),
235        };
236        matches!(selected, InfrastructureSelectionResult::Selected { .. });
237
238        let _ = InfrastructureSelectionResult::Back;
239        let _ = InfrastructureSelectionResult::Cancelled;
240    }
241}