Skip to main content

syncable_cli/wizard/
provider_selection.rs

1//! Provider selection step for deployment wizard
2
3use crate::platform::api::{
4    PlatformApiClient,
5    types::{
6        CloudProvider, ClusterStatus, ClusterSummary, ProviderDeploymentStatus, RegistryStatus,
7        RegistrySummary,
8    },
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, Hetzner, and Azure when connected
95        let cloud_runner_available = is_connected
96            && matches!(
97                provider,
98                CloudProvider::Gcp | CloudProvider::Hetzner | CloudProvider::Azure
99            );
100
101        let summary = build_status_summary(&clusters, &registries, cloud_runner_available);
102
103        statuses.push(ProviderDeploymentStatus {
104            provider,
105            is_connected,
106            clusters,
107            registries,
108            cloud_runner_available,
109            summary,
110        });
111    }
112
113    Ok(statuses)
114}
115
116/// Build a human-readable summary string for a provider
117fn build_status_summary(
118    clusters: &[ClusterSummary],
119    registries: &[RegistrySummary],
120    cloud_runner: bool,
121) -> String {
122    let mut parts = Vec::new();
123
124    if cloud_runner {
125        parts.push("Cloud Run".to_string());
126    }
127
128    let healthy_clusters = clusters.iter().filter(|c| c.is_healthy).count();
129    if healthy_clusters > 0 {
130        parts.push(format!(
131            "{} cluster{}",
132            healthy_clusters,
133            if healthy_clusters == 1 { "" } else { "s" }
134        ));
135    }
136
137    let ready_registries = registries.iter().filter(|r| r.is_ready).count();
138    if ready_registries > 0 {
139        parts.push(format!(
140            "{} registr{}",
141            ready_registries,
142            if ready_registries == 1 { "y" } else { "ies" }
143        ));
144    }
145
146    if parts.is_empty() {
147        "Not connected".to_string()
148    } else {
149        parts.join(", ")
150    }
151}
152
153/// Result of provider selection step
154#[derive(Debug, Clone)]
155pub enum ProviderSelectionResult {
156    /// User selected a provider
157    Selected(CloudProvider),
158    /// User cancelled the wizard
159    Cancelled,
160}
161
162/// Display provider selection and prompt user to choose
163pub fn select_provider(statuses: &[ProviderDeploymentStatus]) -> ProviderSelectionResult {
164    display_step_header(
165        1,
166        "Select Provider",
167        "Choose which cloud provider to deploy to. You'll need to connect providers in the platform settings first.",
168    );
169
170    // Build options with status indicators
171    let options: Vec<String> = statuses
172        .iter()
173        .map(|s| {
174            let name = format!("{:?}", s.provider);
175            // Check availability first - unavailable providers show "Coming Soon"
176            if !s.provider.is_available() {
177                format!("○ {}  {}", name.dimmed(), "(Coming Soon)".yellow())
178            } else {
179                let indicator = status_indicator(s.is_connected);
180                if s.is_connected {
181                    format!("{} {}  {}", indicator, name, s.summary.dimmed())
182                } else {
183                    format!(
184                        "{} {}  {}",
185                        indicator,
186                        name.dimmed(),
187                        "Not connected".dimmed()
188                    )
189                }
190            }
191        })
192        .collect();
193
194    // Find available AND connected providers for validation
195    let available_connected_indices: Vec<usize> = statuses
196        .iter()
197        .enumerate()
198        .filter(|(_, s)| s.provider.is_available() && s.is_connected)
199        .map(|(i, _)| i)
200        .collect();
201
202    if available_connected_indices.is_empty() {
203        println!(
204            "\n{}",
205            "No providers connected. Connect a cloud provider in platform settings first.".red()
206        );
207        println!(
208            "  {}",
209            "Visit: https://app.syncable.dev/integrations".dimmed()
210        );
211        println!(
212            "  {}",
213            "Note: GCP, Hetzner, and Azure are currently available. AWS, Scaleway, and Cyso Cloud are coming soon.".dimmed()
214        );
215        return ProviderSelectionResult::Cancelled;
216    }
217
218    let selection = Select::new("Select a provider:", options)
219        .with_render_config(wizard_render_config())
220        .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
221        .with_page_size(6)
222        .prompt();
223
224    match selection {
225        Ok(answer) => {
226            // Find which provider was selected
227            let selected_idx = statuses
228                .iter()
229                .position(|s| {
230                    let display = format!("{:?}", s.provider);
231                    answer.contains(&display)
232                })
233                .unwrap_or(0);
234
235            let selected_status = &statuses[selected_idx];
236
237            // Check availability first - coming soon providers can't be selected
238            if !selected_status.provider.is_available() {
239                println!(
240                    "\n{}",
241                    format!(
242                        "{} is coming soon! Currently only GCP, Hetzner, and Azure are available.",
243                        selected_status.provider.display_name()
244                    )
245                    .yellow()
246                );
247                return ProviderSelectionResult::Cancelled;
248            }
249
250            if !selected_status.is_connected {
251                println!(
252                    "\n{}",
253                    format!(
254                        "{:?} is not connected. Please connect it in platform settings first.",
255                        selected_status.provider
256                    )
257                    .yellow()
258                );
259                return ProviderSelectionResult::Cancelled;
260            }
261
262            println!("\n{} Selected: {:?}", "✓".green(), selected_status.provider);
263            ProviderSelectionResult::Selected(selected_status.provider.clone())
264        }
265        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
266            println!("\n{}", "Wizard cancelled.".dimmed());
267            ProviderSelectionResult::Cancelled
268        }
269        Err(_) => ProviderSelectionResult::Cancelled,
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_build_status_summary_cloud_runner_only() {
279        let summary = build_status_summary(&[], &[], true);
280        assert_eq!(summary, "Cloud Run");
281    }
282
283    #[test]
284    fn test_build_status_summary_full() {
285        let clusters = vec![
286            ClusterSummary {
287                id: "c1".to_string(),
288                name: "prod".to_string(),
289                region: "us-central1".to_string(),
290                is_healthy: true,
291            },
292            ClusterSummary {
293                id: "c2".to_string(),
294                name: "staging".to_string(),
295                region: "us-east1".to_string(),
296                is_healthy: false,
297            },
298        ];
299        let registries = vec![RegistrySummary {
300            id: "r1".to_string(),
301            name: "main".to_string(),
302            region: "us-central1".to_string(),
303            is_ready: true,
304        }];
305        let summary = build_status_summary(&clusters, &registries, true);
306        assert_eq!(summary, "Cloud Run, 1 cluster, 1 registry");
307    }
308
309    #[test]
310    fn test_build_status_summary_not_connected() {
311        let summary = build_status_summary(&[], &[], false);
312        assert_eq!(summary, "Not connected");
313    }
314
315    #[test]
316    fn test_provider_selection_result_variants() {
317        let _ = ProviderSelectionResult::Selected(CloudProvider::Gcp);
318        let _ = ProviderSelectionResult::Cancelled;
319    }
320}