1use crate::platform::api::client::PlatformApiClient;
11use crate::platform::api::types::CloudProvider;
12use crate::wizard::cloud_provider_data::{
13 ACA_RESOURCE_PAIRS, CLOUD_RUN_CPU_MEMORY, DynamicCloudRegion, DynamicMachineType,
14 HetznerFetchResult, get_default_machine_type, get_default_region, get_hetzner_regions_dynamic,
15 get_hetzner_server_types_dynamic, get_machine_types_for_provider, get_regions_for_provider,
16};
17use crate::wizard::render::{display_step_header, wizard_render_config};
18use colored::Colorize;
19use inquire::{InquireError, Select};
20use std::fmt;
21
22#[derive(Debug, Clone)]
24pub enum InfrastructureSelectionResult {
25 Selected {
27 region: String,
28 machine_type: String,
29 cpu: Option<String>,
30 memory: Option<String>,
31 },
32 Back,
34 Cancelled,
36}
37
38struct DynamicRegionOption {
40 region: DynamicCloudRegion,
41}
42
43impl fmt::Display for DynamicRegionOption {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 let availability = if !self.region.available_server_types.is_empty() {
46 format!(
47 " · {} types available",
48 self.region.available_server_types.len()
49 )
50 } else {
51 String::new()
52 };
53 write!(
54 f,
55 "{} {}{}",
56 self.region.id.cyan(),
57 format!("{} ({})", self.region.name, self.region.location).dimmed(),
58 availability.green()
59 )
60 }
61}
62
63struct DynamicMachineTypeOption {
65 machine: DynamicMachineType,
66}
67
68impl fmt::Display for DynamicMachineTypeOption {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 let specs = format!(
71 "{} vCPU · {:.0} GB",
72 self.machine.cores, self.machine.memory_gb
73 );
74 let price = if self.machine.price_monthly > 0.0 {
75 format!(" · €{:.2}/mo", self.machine.price_monthly)
76 } else {
77 String::new()
78 };
79 write!(
80 f,
81 "{} {}{}",
82 self.machine.name.cyan(),
83 specs.dimmed(),
84 price.green()
85 )
86 }
87}
88
89pub async fn select_infrastructure(
94 provider: &CloudProvider,
95 step_number: u8,
96 client: Option<&PlatformApiClient>,
97 project_id: Option<&str>,
98) -> InfrastructureSelectionResult {
99 let region = match select_region(provider, step_number, client, project_id).await {
101 Some(r) => r,
102 None => return InfrastructureSelectionResult::Back,
103 };
104
105 match select_machine_type(provider, ®ion, client, project_id).await {
107 Some((machine_type, cpu, memory)) => InfrastructureSelectionResult::Selected {
108 region,
109 machine_type,
110 cpu,
111 memory,
112 },
113 None => InfrastructureSelectionResult::Back,
114 }
115}
116
117pub fn select_infrastructure_sync(
119 provider: &CloudProvider,
120 step_number: u8,
121) -> InfrastructureSelectionResult {
122 let region = match select_region_static(provider, step_number) {
124 Some(r) => r,
125 None => return InfrastructureSelectionResult::Back,
126 };
127
128 match select_machine_type_static(provider) {
130 Some(machine_type) => InfrastructureSelectionResult::Selected {
131 region,
132 machine_type,
133 cpu: None,
134 memory: None,
135 },
136 None => InfrastructureSelectionResult::Back,
137 }
138}
139
140async fn select_region(
142 provider: &CloudProvider,
143 step_number: u8,
144 client: Option<&PlatformApiClient>,
145 project_id: Option<&str>,
146) -> Option<String> {
147 let provider_name = match provider {
148 CloudProvider::Hetzner => "Hetzner",
149 CloudProvider::Gcp => "GCP",
150 _ => "Cloud",
151 };
152
153 display_step_header(
154 step_number,
155 &format!("Select {} Region", provider_name),
156 "Choose the geographic location for your deployment.",
157 );
158
159 if *provider == CloudProvider::Hetzner {
161 if let (Some(client), Some(project_id)) = (client, project_id) {
162 match get_hetzner_regions_dynamic(client, project_id).await {
163 HetznerFetchResult::Success(regions) => {
164 if regions.is_empty() {
165 println!(
166 "\n{} No Hetzner regions available. Please check your Hetzner account.",
167 "✗".red()
168 );
169 return None;
170 }
171 return select_region_from_dynamic(regions, provider);
172 }
173 HetznerFetchResult::NoCredentials => {
174 println!(
175 "\n{} Hetzner credentials not configured for this project.",
176 "✗".red()
177 );
178 println!(
179 " {} Please add your Hetzner API token in project settings.",
180 "→".dimmed()
181 );
182 return None;
183 }
184 HetznerFetchResult::ApiError(err) => {
185 println!("\n{} Failed to fetch Hetzner regions: {}", "✗".red(), err);
186 return None;
187 }
188 }
189 } else {
190 println!(
191 "\n{} Cannot fetch Hetzner regions without authentication.",
192 "✗".red()
193 );
194 return None;
195 }
196 }
197
198 select_region_static(provider, step_number)
200}
201
202fn select_region_from_dynamic(
204 regions: Vec<DynamicCloudRegion>,
205 provider: &CloudProvider,
206) -> Option<String> {
207 if regions.is_empty() {
208 println!("\n{} No regions available for this provider.", "⚠".yellow());
209 return None;
210 }
211
212 let default_region = get_default_region(provider);
213 let default_index = regions
214 .iter()
215 .position(|r| r.id == default_region)
216 .unwrap_or(0);
217
218 let options: Vec<DynamicRegionOption> = regions
219 .into_iter()
220 .map(|r| DynamicRegionOption { region: r })
221 .collect();
222
223 let selection = Select::new("Select region:", options)
224 .with_render_config(wizard_render_config())
225 .with_starting_cursor(default_index)
226 .with_help_message("Use ↑/↓ to navigate, Enter to select · Real-time availability shown")
227 .prompt();
228
229 match selection {
230 Ok(selected) => {
231 println!(
232 "\n{} Selected region: {} ({})",
233 "✓".green(),
234 selected.region.name.cyan(),
235 selected.region.id
236 );
237 Some(selected.region.id)
238 }
239 Err(InquireError::OperationCanceled) => None,
240 Err(InquireError::OperationInterrupted) => None,
241 Err(_) => None,
242 }
243}
244
245fn select_region_static(provider: &CloudProvider, step_number: u8) -> Option<String> {
247 display_step_header(
248 step_number,
249 &format!(
250 "Select {} Region",
251 match provider {
252 CloudProvider::Hetzner => "Hetzner",
253 CloudProvider::Gcp => "GCP",
254 _ => "Cloud",
255 }
256 ),
257 "Choose the geographic location for your deployment.",
258 );
259
260 let regions = get_regions_for_provider(provider);
261 if regions.is_empty() {
262 println!("\n{} No regions available for this provider.", "⚠".yellow());
263 return None;
264 }
265
266 let default_region = get_default_region(provider);
267 let default_index = regions
268 .iter()
269 .position(|r| r.id == default_region)
270 .unwrap_or(0);
271
272 let options: Vec<DynamicRegionOption> = regions
274 .iter()
275 .map(|r| DynamicRegionOption {
276 region: DynamicCloudRegion {
277 id: r.id.to_string(),
278 name: r.name.to_string(),
279 location: r.location.to_string(),
280 network_zone: String::new(),
281 available_server_types: vec![],
282 },
283 })
284 .collect();
285
286 let selection = Select::new("Select region:", options)
287 .with_render_config(wizard_render_config())
288 .with_starting_cursor(default_index)
289 .with_help_message("Use ↑/↓ to navigate, Enter to select")
290 .prompt();
291
292 match selection {
293 Ok(selected) => {
294 println!(
295 "\n{} Selected region: {} ({})",
296 "✓".green(),
297 selected.region.name.cyan(),
298 selected.region.id
299 );
300 Some(selected.region.id)
301 }
302 Err(InquireError::OperationCanceled) => None,
303 Err(InquireError::OperationInterrupted) => None,
304 Err(_) => None,
305 }
306}
307
308async fn select_machine_type(
312 provider: &CloudProvider,
313 region: &str,
314 client: Option<&PlatformApiClient>,
315 project_id: Option<&str>,
316) -> Option<(String, Option<String>, Option<String>)> {
317 println!();
318 println!(
319 "{}",
320 "─── Machine Type ────────────────────────────".dimmed()
321 );
322 println!(" {}", "Select the VM size for your deployment.".dimmed());
323
324 if *provider == CloudProvider::Hetzner {
326 if let (Some(client), Some(project_id)) = (client, project_id) {
327 match get_hetzner_server_types_dynamic(client, project_id, Some(region)).await {
328 HetznerFetchResult::Success(machine_types) => {
329 if machine_types.is_empty() {
330 println!(
331 "\n{} No Hetzner server types available. Please check your Hetzner account.",
332 "✗".red()
333 );
334 return None;
335 }
336 return select_machine_type_from_dynamic(machine_types, provider, region)
337 .map(|m| (m, None, None));
338 }
339 HetznerFetchResult::NoCredentials => {
340 println!(
341 "\n{} Hetzner credentials not configured for this project.",
342 "✗".red()
343 );
344 println!(
345 " {} Please add your Hetzner API token in project settings.",
346 "→".dimmed()
347 );
348 return None;
349 }
350 HetznerFetchResult::ApiError(err) => {
351 println!(
352 "\n{} Failed to fetch Hetzner server types: {}",
353 "✗".red(),
354 err
355 );
356 return None;
357 }
358 }
359 } else {
360 println!(
361 "\n{} Cannot fetch Hetzner server types without authentication.",
362 "✗".red()
363 );
364 return None;
365 }
366 }
367
368 match provider {
370 CloudProvider::Azure => {
371 select_aca_resource_pair().map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem)))
372 }
373 CloudProvider::Gcp => {
374 select_cloud_run_resources().map(|(machine, cpu, mem)| (machine, Some(cpu), Some(mem)))
375 }
376 _ => select_machine_type_static(provider).map(|m| (m, None, None)),
377 }
378}
379
380fn select_aca_resource_pair() -> Option<(String, String, String)> {
384 let pairs = ACA_RESOURCE_PAIRS;
385 if pairs.is_empty() {
386 println!(
387 "\n{} No Azure Container Apps resource options available.",
388 "⚠".yellow()
389 );
390 return None;
391 }
392
393 let labels: Vec<String> = pairs.iter().map(|p| p.label.to_string()).collect();
394 let default_index = 1;
396
397 let selection = Select::new("Select resource allocation:", labels)
398 .with_render_config(wizard_render_config())
399 .with_starting_cursor(default_index)
400 .with_help_message("Azure Container Apps fixed CPU/memory pairs")
401 .prompt();
402
403 match selection {
404 Ok(selected_label) => {
405 let pair = pairs.iter().find(|p| p.label == selected_label)?;
406 let machine_type_id = format!("{}-cpu-{}-mem", pair.cpu, pair.memory);
407 println!(
408 "\n{} Selected: {} vCPU / {}",
409 "✓".green(),
410 pair.cpu.cyan(),
411 pair.memory.cyan()
412 );
413 Some((
414 machine_type_id,
415 pair.cpu.to_string(),
416 pair.memory.to_string(),
417 ))
418 }
419 Err(InquireError::OperationCanceled) => None,
420 Err(InquireError::OperationInterrupted) => None,
421 Err(_) => None,
422 }
423}
424
425fn select_cloud_run_resources() -> Option<(String, String, String)> {
429 let cpu_levels = CLOUD_RUN_CPU_MEMORY;
430 if cpu_levels.is_empty() {
431 println!("\n{} No Cloud Run CPU options available.", "⚠".yellow());
432 return None;
433 }
434
435 let cpu_labels: Vec<String> = cpu_levels
437 .iter()
438 .map(|c| format!("{} vCPU", c.cpu))
439 .collect();
440
441 let cpu_selection = Select::new("Select CPU allocation:", cpu_labels)
442 .with_render_config(wizard_render_config())
443 .with_starting_cursor(0) .with_help_message("Cloud Run CPU allocation")
445 .prompt();
446
447 let selected_cpu = match cpu_selection {
448 Ok(label) => {
449 let cpu_str = label.replace(" vCPU", "");
450 cpu_levels.iter().find(|c| c.cpu == cpu_str)?
451 }
452 Err(InquireError::OperationCanceled) => return None,
453 Err(InquireError::OperationInterrupted) => return None,
454 Err(_) => return None,
455 };
456
457 let memory_options: Vec<String> = selected_cpu
459 .memory_options
460 .iter()
461 .map(|m| m.to_string())
462 .collect();
463
464 let default_mem_index = memory_options
465 .iter()
466 .position(|m| m == selected_cpu.default_memory)
467 .unwrap_or(0);
468
469 let mem_selection = Select::new("Select memory allocation:", memory_options)
470 .with_render_config(wizard_render_config())
471 .with_starting_cursor(default_mem_index)
472 .with_help_message("Memory must be compatible with selected CPU")
473 .prompt();
474
475 match mem_selection {
476 Ok(selected_memory) => {
477 let machine_type_id = format!("{}-cpu-{}-mem", selected_cpu.cpu, selected_memory);
478 println!(
479 "\n{} Selected: {} vCPU / {}",
480 "✓".green(),
481 selected_cpu.cpu.cyan(),
482 selected_memory.cyan()
483 );
484 Some((
485 machine_type_id,
486 selected_cpu.cpu.to_string(),
487 selected_memory,
488 ))
489 }
490 Err(InquireError::OperationCanceled) => None,
491 Err(InquireError::OperationInterrupted) => None,
492 Err(_) => None,
493 }
494}
495
496fn select_machine_type_from_dynamic(
498 machine_types: Vec<DynamicMachineType>,
499 provider: &CloudProvider,
500 region: &str,
501) -> Option<String> {
502 if machine_types.is_empty() {
503 println!(
504 "\n{} No machine types available for this provider.",
505 "⚠".yellow()
506 );
507 return None;
508 }
509
510 let available_types: Vec<DynamicMachineType> = machine_types
512 .into_iter()
513 .filter(|m| m.available_in.is_empty() || m.available_in.contains(®ion.to_string()))
514 .collect();
515
516 if available_types.is_empty() {
517 println!(
518 "\n{} No machine types available in {} region.",
519 "⚠".yellow(),
520 region
521 );
522 return None;
523 }
524
525 let default_machine = get_default_machine_type(provider);
526 let default_index = available_types
527 .iter()
528 .position(|m| m.id == default_machine)
529 .unwrap_or(0);
530
531 let options: Vec<DynamicMachineTypeOption> = available_types
532 .into_iter()
533 .map(|m| DynamicMachineTypeOption { machine: m })
534 .collect();
535
536 let selection = Select::new("Select machine type:", options)
537 .with_render_config(wizard_render_config())
538 .with_starting_cursor(default_index)
539 .with_help_message("Sorted by price · Real-time pricing shown")
540 .prompt();
541
542 match selection {
543 Ok(selected) => {
544 let price_info = if selected.machine.price_monthly > 0.0 {
545 format!(" · €{:.2}/mo", selected.machine.price_monthly)
546 } else {
547 String::new()
548 };
549 println!(
550 "\n{} Selected: {} ({} vCPU, {:.0} GB){}",
551 "✓".green(),
552 selected.machine.name.cyan(),
553 selected.machine.cores,
554 selected.machine.memory_gb,
555 price_info.green()
556 );
557 Some(selected.machine.id)
558 }
559 Err(InquireError::OperationCanceled) => None,
560 Err(InquireError::OperationInterrupted) => None,
561 Err(_) => None,
562 }
563}
564
565fn select_machine_type_static(provider: &CloudProvider) -> Option<String> {
567 let machine_types = get_machine_types_for_provider(provider);
568 if machine_types.is_empty() {
569 println!(
570 "\n{} No machine types available for this provider.",
571 "⚠".yellow()
572 );
573 return None;
574 }
575
576 let default_machine = get_default_machine_type(provider);
577 let default_index = machine_types
578 .iter()
579 .position(|m| m.id == default_machine)
580 .unwrap_or(0);
581
582 let options: Vec<DynamicMachineTypeOption> = machine_types
584 .iter()
585 .map(|m| DynamicMachineTypeOption {
586 machine: DynamicMachineType {
587 id: m.id.to_string(),
588 name: m.name.to_string(),
589 cores: m.cpu.parse().unwrap_or(2),
590 memory_gb: m.memory.replace(" GB", "").parse().unwrap_or(4.0),
591 disk_gb: 40,
592 price_monthly: 0.0,
593 price_hourly: 0.0,
594 available_in: vec![],
595 },
596 })
597 .collect();
598
599 let selection = Select::new("Select machine type:", options)
600 .with_render_config(wizard_render_config())
601 .with_starting_cursor(default_index)
602 .with_help_message("Smaller = cheaper, Larger = more resources")
603 .prompt();
604
605 match selection {
606 Ok(selected) => {
607 println!(
608 "\n{} Selected: {} ({} vCPU, {:.0} GB)",
609 "✓".green(),
610 selected.machine.name.cyan(),
611 selected.machine.cores,
612 selected.machine.memory_gb
613 );
614 Some(selected.machine.id)
615 }
616 Err(InquireError::OperationCanceled) => None,
617 Err(InquireError::OperationInterrupted) => None,
618 Err(_) => None,
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn test_dynamic_region_option_display() {
628 let region = DynamicCloudRegion {
629 id: "nbg1".to_string(),
630 name: "Nuremberg".to_string(),
631 location: "Germany".to_string(),
632 network_zone: "eu-central".to_string(),
633 available_server_types: vec!["cx22".to_string(), "cx32".to_string()],
634 };
635 let option = DynamicRegionOption { region };
636 let display = format!("{}", option);
637 assert!(display.contains("nbg1"));
638 assert!(display.contains("Nuremberg"));
639 assert!(display.contains("2 types available"));
640 }
641
642 #[test]
643 fn test_dynamic_machine_type_option_display() {
644 let machine = DynamicMachineType {
645 id: "cx22".to_string(),
646 name: "CX22".to_string(),
647 cores: 2,
648 memory_gb: 4.0,
649 disk_gb: 40,
650 price_monthly: 5.95,
651 price_hourly: 0.008,
652 available_in: vec!["nbg1".to_string()],
653 };
654 let option = DynamicMachineTypeOption { machine };
655 let display = format!("{}", option);
656 assert!(display.contains("CX22"));
657 assert!(display.contains("2 vCPU"));
658 assert!(display.contains("4 GB"));
659 assert!(display.contains("€5.95/mo"));
660 }
661
662 #[test]
663 fn test_dynamic_machine_type_option_display_no_price() {
664 let machine = DynamicMachineType {
665 id: "cx22".to_string(),
666 name: "CX22".to_string(),
667 cores: 2,
668 memory_gb: 4.0,
669 disk_gb: 40,
670 price_monthly: 0.0,
671 price_hourly: 0.0,
672 available_in: vec![],
673 };
674 let option = DynamicMachineTypeOption { machine };
675 let display = format!("{}", option);
676 assert!(display.contains("CX22"));
677 assert!(!display.contains("€"));
678 }
679
680 #[test]
681 fn test_infrastructure_selection_result_variants() {
682 let selected = InfrastructureSelectionResult::Selected {
683 region: "nbg1".to_string(),
684 machine_type: "cx22".to_string(),
685 cpu: None,
686 memory: None,
687 };
688 matches!(selected, InfrastructureSelectionResult::Selected { .. });
689
690 let selected_with_resources = InfrastructureSelectionResult::Selected {
691 region: "eastus".to_string(),
692 machine_type: "0.5-cpu-1.0Gi-mem".to_string(),
693 cpu: Some("0.5".to_string()),
694 memory: Some("1.0Gi".to_string()),
695 };
696 matches!(
697 selected_with_resources,
698 InfrastructureSelectionResult::Selected { .. }
699 );
700
701 let _ = InfrastructureSelectionResult::Back;
702 let _ = InfrastructureSelectionResult::Cancelled;
703 }
704}