syncable_cli/wizard/
infrastructure_selection.rs1use 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#[derive(Debug, Clone)]
17pub enum InfrastructureSelectionResult {
18 Selected {
20 region: String,
21 machine_type: String,
22 },
23 Back,
25 Cancelled,
27}
28
29struct 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
45struct 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
68pub fn select_infrastructure(
70 provider: &CloudProvider,
71 step_number: u8,
72) -> InfrastructureSelectionResult {
73 let region = match select_region(provider, step_number) {
75 Some(r) => r,
76 None => return InfrastructureSelectionResult::Back,
77 };
78
79 match select_machine_type(provider, ®ion) {
81 Some(machine_type) => InfrastructureSelectionResult::Selected {
82 region,
83 machine_type,
84 },
85 None => InfrastructureSelectionResult::Back,
86 }
87}
88
89fn 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
142fn 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: ®ion };
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}