syncable_cli/wizard/
provider_selection.rs

1//! Provider selection step for deployment wizard
2
3use crate::platform::api::{
4    types::{
5        CloudProvider, ClusterStatus, ClusterSummary, ProviderDeploymentStatus, RegistryStatus,
6        RegistrySummary,
7    },
8    PlatformApiClient,
9};
10use crate::wizard::render::{display_step_header, status_indicator, wizard_render_config};
11use colored::Colorize;
12use inquire::{InquireError, Select};
13use std::collections::HashMap;
14
15/// Get deployment status for all providers
16///
17/// Queries the platform to determine which providers are connected and what
18/// resources (clusters, registries) are available for each.
19pub async fn get_provider_deployment_statuses(
20    client: &PlatformApiClient,
21    project_id: &str,
22) -> Result<Vec<ProviderDeploymentStatus>, crate::platform::api::PlatformApiError> {
23    // Get all cloud credentials for the project (determines connectivity)
24    let credentials = client
25        .list_cloud_credentials_for_project(project_id)
26        .await
27        .unwrap_or_default();
28
29    // Build set of connected providers from credentials
30    let connected_providers: std::collections::HashSet<String> = credentials
31        .iter()
32        .map(|c| c.provider.to_lowercase())
33        .collect();
34
35    // Get all clusters and registries for the project
36    let clusters = client
37        .list_clusters_for_project(project_id)
38        .await
39        .unwrap_or_default();
40    let registries = client
41        .list_registries_for_project(project_id)
42        .await
43        .unwrap_or_default();
44
45    // Group by provider
46    let mut provider_clusters: HashMap<CloudProvider, Vec<ClusterSummary>> = HashMap::new();
47    let mut provider_registries: HashMap<CloudProvider, Vec<RegistrySummary>> = HashMap::new();
48
49    for cluster in clusters {
50        let summary = ClusterSummary {
51            id: cluster.id,
52            name: cluster.name,
53            region: cluster.region,
54            is_healthy: cluster.status == ClusterStatus::Running,
55        };
56        provider_clusters
57            .entry(cluster.provider)
58            .or_default()
59            .push(summary);
60    }
61
62    for registry in registries {
63        let summary = RegistrySummary {
64            id: registry.id,
65            name: registry.name,
66            region: registry.region,
67            is_ready: registry.status == RegistryStatus::Ready,
68        };
69        provider_registries
70            .entry(registry.cloud_provider)
71            .or_default()
72            .push(summary);
73    }
74
75    // Build status for each supported provider
76    // Available providers first, then coming soon providers
77    let providers = [
78        CloudProvider::Gcp,
79        CloudProvider::Hetzner,
80        CloudProvider::Aws,
81        CloudProvider::Azure,
82        CloudProvider::Scaleway,
83        CloudProvider::Cyso,
84    ];
85    let mut statuses = Vec::new();
86
87    for provider in providers {
88        let clusters = provider_clusters.remove(&provider).unwrap_or_default();
89        let registries = provider_registries.remove(&provider).unwrap_or_default();
90
91        // Provider is connected if it has cloud credentials (NOT just resources)
92        let is_connected = connected_providers.contains(provider.as_str());
93
94        // Cloud Runner available for GCP and Hetzner when connected
95        let cloud_runner_available =
96            is_connected && matches!(provider, CloudProvider::Gcp | CloudProvider::Hetzner);
97
98        let summary = build_status_summary(&clusters, &registries, cloud_runner_available);
99
100        statuses.push(ProviderDeploymentStatus {
101            provider,
102            is_connected,
103            clusters,
104            registries,
105            cloud_runner_available,
106            summary,
107        });
108    }
109
110    Ok(statuses)
111}
112
113/// Build a human-readable summary string for a provider
114fn build_status_summary(
115    clusters: &[ClusterSummary],
116    registries: &[RegistrySummary],
117    cloud_runner: bool,
118) -> String {
119    let mut parts = Vec::new();
120
121    if cloud_runner {
122        parts.push("Cloud Run".to_string());
123    }
124
125    let healthy_clusters = clusters.iter().filter(|c| c.is_healthy).count();
126    if healthy_clusters > 0 {
127        parts.push(format!(
128            "{} cluster{}",
129            healthy_clusters,
130            if healthy_clusters == 1 { "" } else { "s" }
131        ));
132    }
133
134    let ready_registries = registries.iter().filter(|r| r.is_ready).count();
135    if ready_registries > 0 {
136        parts.push(format!(
137            "{} registr{}",
138            ready_registries,
139            if ready_registries == 1 { "y" } else { "ies" }
140        ));
141    }
142
143    if parts.is_empty() {
144        "Not connected".to_string()
145    } else {
146        parts.join(", ")
147    }
148}
149
150/// Result of provider selection step
151#[derive(Debug, Clone)]
152pub enum ProviderSelectionResult {
153    /// User selected a provider
154    Selected(CloudProvider),
155    /// User cancelled the wizard
156    Cancelled,
157}
158
159/// Display provider selection and prompt user to choose
160pub fn select_provider(statuses: &[ProviderDeploymentStatus]) -> ProviderSelectionResult {
161    display_step_header(
162        1,
163        "Select Provider",
164        "Choose which cloud provider to deploy to. You'll need to connect providers in the platform settings first.",
165    );
166
167    // Build options with status indicators
168    let options: Vec<String> = statuses
169        .iter()
170        .map(|s| {
171            let name = format!("{:?}", s.provider);
172            // Check availability first - unavailable providers show "Coming Soon"
173            if !s.provider.is_available() {
174                format!("○ {}  {}", name.dimmed(), "(Coming Soon)".yellow())
175            } else {
176                let indicator = status_indicator(s.is_connected);
177                if s.is_connected {
178                    format!("{} {}  {}", indicator, name, s.summary.dimmed())
179                } else {
180                    format!("{} {}  {}", indicator, name.dimmed(), "Not connected".dimmed())
181                }
182            }
183        })
184        .collect();
185
186    // Find available AND connected providers for validation
187    let available_connected_indices: Vec<usize> = statuses
188        .iter()
189        .enumerate()
190        .filter(|(_, s)| s.provider.is_available() && s.is_connected)
191        .map(|(i, _)| i)
192        .collect();
193
194    if available_connected_indices.is_empty() {
195        println!(
196            "\n{}",
197            "No providers connected. Connect a cloud provider in platform settings first.".red()
198        );
199        println!(
200            "  {}",
201            "Visit: https://app.syncable.dev/integrations".dimmed()
202        );
203        println!(
204            "  {}",
205            "Note: GCP and Hetzner are currently available. AWS, Azure, Scaleway, and Cyso Cloud are coming soon.".dimmed()
206        );
207        return ProviderSelectionResult::Cancelled;
208    }
209
210    let selection = Select::new("Select a provider:", options)
211        .with_render_config(wizard_render_config())
212        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
213        .with_page_size(6)
214        .prompt();
215
216    match selection {
217        Ok(answer) => {
218            // Find which provider was selected
219            let selected_idx = statuses
220                .iter()
221                .position(|s| {
222                    let display = format!("{:?}", s.provider);
223                    answer.contains(&display)
224                })
225                .unwrap_or(0);
226
227            let selected_status = &statuses[selected_idx];
228
229            // Check availability first - coming soon providers can't be selected
230            if !selected_status.provider.is_available() {
231                println!(
232                    "\n{}",
233                    format!(
234                        "{} is coming soon! Currently only GCP and Hetzner are available.",
235                        selected_status.provider.display_name()
236                    )
237                    .yellow()
238                );
239                return ProviderSelectionResult::Cancelled;
240            }
241
242            if !selected_status.is_connected {
243                println!(
244                    "\n{}",
245                    format!(
246                        "{:?} is not connected. Please connect it in platform settings first.",
247                        selected_status.provider
248                    )
249                    .yellow()
250                );
251                return ProviderSelectionResult::Cancelled;
252            }
253
254            println!(
255                "\n{} Selected: {:?}",
256                "✓".green(),
257                selected_status.provider
258            );
259            ProviderSelectionResult::Selected(selected_status.provider.clone())
260        }
261        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
262            println!("\n{}", "Wizard cancelled.".dimmed());
263            ProviderSelectionResult::Cancelled
264        }
265        Err(_) => ProviderSelectionResult::Cancelled,
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_build_status_summary_cloud_runner_only() {
275        let summary = build_status_summary(&[], &[], true);
276        assert_eq!(summary, "Cloud Run");
277    }
278
279    #[test]
280    fn test_build_status_summary_full() {
281        let clusters = vec![
282            ClusterSummary {
283                id: "c1".to_string(),
284                name: "prod".to_string(),
285                region: "us-central1".to_string(),
286                is_healthy: true,
287            },
288            ClusterSummary {
289                id: "c2".to_string(),
290                name: "staging".to_string(),
291                region: "us-east1".to_string(),
292                is_healthy: false,
293            },
294        ];
295        let registries = vec![RegistrySummary {
296            id: "r1".to_string(),
297            name: "main".to_string(),
298            region: "us-central1".to_string(),
299            is_ready: true,
300        }];
301        let summary = build_status_summary(&clusters, &registries, true);
302        assert_eq!(summary, "Cloud Run, 1 cluster, 1 registry");
303    }
304
305    #[test]
306    fn test_build_status_summary_not_connected() {
307        let summary = build_status_summary(&[], &[], false);
308        assert_eq!(summary, "Not connected");
309    }
310
311    #[test]
312    fn test_provider_selection_result_variants() {
313        let _ = ProviderSelectionResult::Selected(CloudProvider::Gcp);
314        let _ = ProviderSelectionResult::Cancelled;
315    }
316}