1use anyhow::{Context, Result};
9use clap::Subcommand;
10use colored::Colorize;
11use serde::Serialize;
12use std::str::FromStr;
13
14use crate::commands::tracked::tracked_op;
15use crate::output::OutputFormat;
16use raps_kernel::prompts;
18use raps_oss::{OssClient, Region, RetentionPolicy};
19
20#[derive(Debug, Subcommand)]
21pub enum BucketCommands {
22 Create {
24 #[arg(short, long)]
26 key: Option<String>,
27
28 #[arg(short, long)]
30 policy: Option<String>,
31
32 #[arg(short, long)]
34 region: Option<String>,
35 },
36
37 List,
39
40 Info {
42 bucket_key: String,
44 },
45
46 Delete {
48 bucket_key: Option<String>,
50
51 #[arg(short = 'y', long)]
53 yes: bool,
54 },
55}
56
57impl BucketCommands {
58 pub async fn execute(self, client: &OssClient, output_format: OutputFormat) -> Result<()> {
59 match self {
60 BucketCommands::Create {
61 key,
62 policy,
63 region,
64 } => create_bucket(client, key, policy, region, output_format).await,
65 BucketCommands::List => list_buckets(client, output_format).await,
66 BucketCommands::Info { bucket_key } => {
67 bucket_info(client, &bucket_key, output_format).await
68 }
69 BucketCommands::Delete { bucket_key, yes } => {
70 delete_bucket(client, bucket_key, yes, output_format).await
71 }
72 }
73 }
74}
75
76#[derive(Debug, Serialize)]
78struct BucketOutput {
79 bucket_key: String,
80 policy_key: String,
81 bucket_owner: String,
82 created_date: u64,
83 created_date_human: String,
84 region: String,
85}
86
87#[derive(Debug, Serialize)]
89struct BucketInfoOutput {
90 bucket_key: String,
91 bucket_owner: String,
92 policy_key: String,
93 created_date: u64,
94 created_date_human: String,
95 permissions: Vec<PermissionOutput>,
96}
97
98#[derive(Debug, Serialize)]
99struct PermissionOutput {
100 auth_id: String,
101 access: String,
102}
103
104async fn create_bucket(
105 client: &OssClient,
106 key: Option<String>,
107 policy: Option<String>,
108 region: Option<String>,
109 output_format: OutputFormat,
110) -> Result<()> {
111 let timestamp = std::time::SystemTime::now()
113 .duration_since(std::time::UNIX_EPOCH)
114 .unwrap_or_default()
115 .as_secs();
116 let suggested_prefix = format!("aps-{}", timestamp);
117
118 let bucket_key = match key {
120 Some(k) => k,
121 None => {
122 println!(
123 "{}",
124 "Note: Bucket keys must be globally unique across all APS applications.".yellow()
125 );
126 println!(
127 "{}",
128 format!(
129 "Suggestion: Use a prefix like '{}-yourname'",
130 suggested_prefix
131 )
132 .dimmed()
133 );
134
135 prompts::input_validated(
136 "Enter bucket key",
137 Some(&suggested_prefix),
138 |input: &String| {
139 if input.len() < 3 {
140 Err("Bucket key must be at least 3 characters")
141 } else if input.len() > 128 {
142 Err("Bucket key must be at most 128 characters")
143 } else if !input.chars().all(|c| {
144 c.is_ascii_lowercase()
145 || c.is_ascii_digit()
146 || c == '-'
147 || c == '_'
148 || c == '.'
149 }) {
150 Err(
151 "Bucket key can only contain lowercase letters, numbers, hyphens, underscores, and dots",
152 )
153 } else {
154 Ok(())
155 }
156 },
157 )?
158 }
159 };
160
161 let selected_region = match region {
163 Some(r) => {
164 let regions = Region::all();
166 regions
167 .iter()
168 .find(|reg| reg.to_string().to_uppercase() == r.to_uppercase())
169 .cloned()
170 .ok_or_else(|| {
171 let available = regions
172 .iter()
173 .map(|reg| reg.to_string())
174 .collect::<Vec<_>>()
175 .join(", ");
176 anyhow::anyhow!("Invalid region. Available regions: {}", available)
177 })?
178 }
179 None => {
180 let regions = Region::all();
182 let region_labels: Vec<String> = regions.iter().map(|r| r.to_string()).collect();
183 let selection = prompts::select_with_default("Select region", ®ion_labels, 0)?;
184 regions[selection]
185 }
186 };
187
188 let selected_policy = match policy {
190 Some(p) => RetentionPolicy::from_str(&p).map_err(|_| {
191 anyhow::anyhow!("Invalid policy. Use transient, temporary, or persistent.")
192 })?,
193 None => {
194 let policies = RetentionPolicy::all();
196 let policy_labels: Vec<String> = policies
197 .iter()
198 .map(|p| match p {
199 RetentionPolicy::Transient => "transient (deleted after 24 hours)".to_string(),
200 RetentionPolicy::Temporary => "temporary (deleted after 30 days)".to_string(),
201 RetentionPolicy::Persistent => "persistent (kept until deleted)".to_string(),
202 })
203 .collect();
204
205 let selection =
206 prompts::select_with_default("Select retention policy", &policy_labels, 0)?;
207 policies[selection]
208 }
209 };
210
211 if output_format.supports_colors() {
212 println!("{}", "Creating bucket...".dimmed());
213 }
214
215 let bucket = client
216 .create_bucket(&bucket_key, selected_policy, selected_region)
217 .await
218 .context(format!(
219 "Failed to create bucket '{}'. Bucket keys must be globally unique",
220 bucket_key
221 ))?;
222
223 let bucket_output = BucketInfoOutput {
224 bucket_key: bucket.bucket_key.clone(),
225 bucket_owner: bucket.bucket_owner.clone(),
226 policy_key: bucket.policy_key.clone(),
227 created_date: bucket.created_date,
228 created_date_human: chrono_humanize(bucket.created_date),
229 permissions: bucket
230 .permissions
231 .iter()
232 .map(|p| PermissionOutput {
233 auth_id: p.auth_id.clone(),
234 access: p.access.clone(),
235 })
236 .collect(),
237 };
238
239 match output_format {
240 OutputFormat::Table => {
241 println!("{} Bucket created successfully!", "✓".green().bold());
242 println!(" {} {}", "Key:".bold(), bucket.bucket_key);
243 println!(" {} {}", "Policy:".bold(), bucket.policy_key);
244 println!(" {} {}", "Owner:".bold(), bucket.bucket_owner);
245 }
246 _ => {
247 output_format.write(&bucket_output)?;
248 }
249 }
250
251 Ok(())
252}
253
254async fn list_buckets(client: &OssClient, output_format: OutputFormat) -> Result<()> {
255 match output_format {
256 OutputFormat::Table => list_buckets_streaming(client).await,
257 _ => list_buckets_batch(client, output_format).await,
258 }
259}
260
261async fn list_buckets_streaming(client: &OssClient) -> Result<()> {
263 use raps_kernel::api_health;
264
265 let spinner = raps_kernel::progress::spinner("Fetching buckets from US, EMEA...");
266 let start = std::time::Instant::now();
267
268 let region_results = client.list_buckets_streaming().await;
269 let elapsed = start.elapsed();
270
271 let snap = api_health::snapshot();
272 let status_suffix = if snap.sample_count > 0 {
273 format!(
274 " ({}, avg: {}, API: {})",
275 api_health::format_duration_ms(elapsed),
276 api_health::format_duration_ms(snap.avg_latency),
277 snap.health_status,
278 )
279 } else {
280 format!(" ({})", api_health::format_duration_ms(elapsed))
281 };
282 spinner.finish_with_message(format!(
283 "\u{2713} Fetching buckets from all regions{}",
284 status_suffix
285 ));
286
287 let mut all_outputs = Vec::new();
288
289 for rr in ®ion_results {
290 match &rr.buckets {
291 Ok(buckets) => {
292 println!(
293 "{} {} responded ({}) \u{2014} {} buckets",
294 "\u{2713}".green().bold(),
295 rr.region,
296 api_health::format_duration_ms(rr.elapsed),
297 buckets.len(),
298 );
299 for b in buckets {
300 all_outputs.push(BucketOutput {
301 bucket_key: b.bucket_key.clone(),
302 policy_key: b.policy_key.clone(),
303 bucket_owner: String::new(),
304 created_date: b.created_date,
305 created_date_human: chrono_humanize(b.created_date),
306 region: b.region.as_deref().unwrap_or("US").to_string(),
307 });
308 }
309 }
310 Err(e) => {
311 println!(
312 "{} {} failed ({}) \u{2014} {}",
313 "\u{2717}".red().bold(),
314 rr.region,
315 api_health::format_duration_ms(rr.elapsed),
316 e,
317 );
318 }
319 }
320 }
321
322 if all_outputs.is_empty() {
323 println!("{}", "No buckets found.".yellow());
324 return Ok(());
325 }
326
327 println!("\n{}", "Buckets:".bold());
328 println!("{}", "-".repeat(90));
329 println!(
330 "{:<40} {:<12} {:<8} {}",
331 "Bucket Key".bold(),
332 "Policy".bold(),
333 "Region".bold(),
334 "Created".bold()
335 );
336 println!("{}", "-".repeat(90));
337
338 for bucket in &all_outputs {
339 println!(
340 "{:<40} {:<12} {:<8} {}",
341 bucket.bucket_key.cyan(),
342 bucket.policy_key,
343 bucket.region.yellow(),
344 bucket.created_date_human.dimmed()
345 );
346 }
347
348 println!("{}", "-".repeat(90));
349 Ok(())
350}
351
352async fn list_buckets_batch(client: &OssClient, output_format: OutputFormat) -> Result<()> {
354 let buckets = client
355 .list_buckets()
356 .await
357 .context("Failed to list buckets. Check your authentication with 'raps auth test'")?;
358
359 if buckets.is_empty() {
360 output_format.write(&Vec::<BucketOutput>::new())?;
361 return Ok(());
362 }
363
364 let bucket_outputs: Vec<BucketOutput> = buckets
365 .iter()
366 .map(|b| BucketOutput {
367 bucket_key: b.bucket_key.clone(),
368 policy_key: b.policy_key.clone(),
369 bucket_owner: String::new(),
370 created_date: b.created_date,
371 created_date_human: chrono_humanize(b.created_date),
372 region: b.region.as_deref().unwrap_or("US").to_string(),
373 })
374 .collect();
375
376 output_format.write(&bucket_outputs)?;
377 Ok(())
378}
379
380async fn bucket_info(
381 client: &OssClient,
382 bucket_key: &str,
383 output_format: OutputFormat,
384) -> Result<()> {
385 let bucket = tracked_op("Fetching bucket details", output_format, || async {
386 client.get_bucket_details(bucket_key).await.context(format!(
387 "Failed to get bucket details for '{}'. Verify the bucket key is correct",
388 bucket_key
389 ))
390 })
391 .await?;
392
393 let bucket_output = BucketInfoOutput {
394 bucket_key: bucket.bucket_key.clone(),
395 bucket_owner: bucket.bucket_owner.clone(),
396 policy_key: bucket.policy_key.clone(),
397 created_date: bucket.created_date,
398 created_date_human: chrono_humanize(bucket.created_date),
399 permissions: bucket
400 .permissions
401 .iter()
402 .map(|p| PermissionOutput {
403 auth_id: p.auth_id.clone(),
404 access: p.access.clone(),
405 })
406 .collect(),
407 };
408
409 match output_format {
410 OutputFormat::Table => {
411 println!("\n{}", "Bucket Details".bold());
412 println!("{}", "-".repeat(60));
413
414 println!(" {} {}", "Key:".bold(), bucket.bucket_key.cyan());
415 println!(" {} {}", "Owner:".bold(), bucket.bucket_owner);
416 println!(" {} {}", "Policy:".bold(), bucket.policy_key);
417 println!(
418 " {} {}",
419 "Created:".bold(),
420 bucket_output.created_date_human
421 );
422
423 if !bucket.permissions.is_empty() {
424 println!("\n {}:", "Permissions".bold());
425 for perm in &bucket.permissions {
426 println!(
427 " {} {}: {}",
428 "-".cyan(),
429 perm.auth_id.dimmed(),
430 perm.access
431 );
432 }
433 }
434
435 println!("{}", "-".repeat(60));
436 }
437 _ => {
438 output_format.write(&bucket_output)?;
439 }
440 }
441
442 Ok(())
443}
444
445async fn delete_bucket(
446 client: &OssClient,
447 bucket_key: Option<String>,
448 skip_confirm: bool,
449 output_format: OutputFormat,
450) -> Result<()> {
451 let key = match bucket_key {
453 Some(k) => k,
454 None => {
455 let buckets = client
457 .list_buckets()
458 .await
459 .context("Failed to list buckets")?;
460 if buckets.is_empty() {
461 println!("{}", "No buckets found to delete.".yellow());
462 return Ok(());
463 }
464
465 let bucket_keys: Vec<String> = buckets.iter().map(|b| b.bucket_key.clone()).collect();
466
467 let selection = prompts::select("Select bucket to delete", &bucket_keys)?;
468 bucket_keys[selection].clone()
469 }
470 };
471
472 if !skip_confirm {
474 let confirmed = prompts::confirm_destructive(format!(
475 "Are you sure you want to delete bucket '{}'?",
476 key.red()
477 ))?;
478
479 if !confirmed {
480 println!("{}", "Deletion cancelled.".yellow());
481 return Ok(());
482 }
483 }
484
485 if output_format.supports_colors() {
486 println!("{}", "Deleting bucket...".dimmed());
487 }
488
489 client.delete_bucket(&key).await.context(format!(
490 "Failed to delete bucket '{}'. The bucket must be empty before deletion",
491 key
492 ))?;
493
494 #[derive(Serialize)]
495 struct DeleteResult {
496 success: bool,
497 bucket_key: String,
498 message: String,
499 }
500
501 let result = DeleteResult {
502 success: true,
503 bucket_key: key.clone(),
504 message: format!("Bucket '{}' deleted successfully!", key),
505 };
506
507 match output_format {
508 OutputFormat::Table => {
509 println!(
510 "{} Bucket '{}' deleted successfully!",
511 "✓".green().bold(),
512 key
513 );
514 }
515 _ => {
516 output_format.write(&result)?;
517 }
518 }
519
520 Ok(())
521}
522
523fn chrono_humanize(timestamp_ms: u64) -> String {
525 use std::time::{Duration, UNIX_EPOCH};
526
527 let duration = Duration::from_millis(timestamp_ms);
528 let datetime = UNIX_EPOCH + duration;
529
530 if let Ok(elapsed) = datetime.elapsed() {
531 let secs = elapsed.as_secs();
532 if secs < 60 {
533 format!("{} seconds ago", secs)
534 } else if secs < 3600 {
535 format!("{} minutes ago", secs / 60)
536 } else if secs < 86400 {
537 format!("{} hours ago", secs / 3600)
538 } else {
539 format!("{} days ago", secs / 86400)
540 }
541 } else {
542 "in the future".to_string()
543 }
544}