1use 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
15pub async fn get_provider_deployment_statuses(
20 client: &PlatformApiClient,
21 project_id: &str,
22) -> Result<Vec<ProviderDeploymentStatus>, crate::platform::api::PlatformApiError> {
23 let credentials = client
25 .list_cloud_credentials_for_project(project_id)
26 .await
27 .unwrap_or_default();
28
29 let connected_providers: std::collections::HashSet<String> = credentials
31 .iter()
32 .map(|c| c.provider.to_lowercase())
33 .collect();
34
35 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 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 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 let is_connected = connected_providers.contains(provider.as_str());
93
94 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, ®istries, 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
116fn 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#[derive(Debug, Clone)]
155pub enum ProviderSelectionResult {
156 Selected(CloudProvider),
158 Cancelled,
160}
161
162pub 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 let options: Vec<String> = statuses
172 .iter()
173 .map(|s| {
174 let name = format!("{:?}", s.provider);
175 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 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 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 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, ®istries, 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}