Skip to main content

raps_cli/commands/
bucket.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Bucket management commands
5//!
6//! Commands for creating, listing, and deleting OSS buckets.
7
8use 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;
16// use raps_kernel::output::OutputFormat;
17use raps_kernel::prompts;
18use raps_oss::{OssClient, Region, RetentionPolicy};
19
20#[derive(Debug, Subcommand)]
21pub enum BucketCommands {
22    /// Create a new bucket (interactive)
23    Create {
24        /// Bucket key (optional, will prompt if not provided)
25        #[arg(short, long)]
26        key: Option<String>,
27
28        /// Retention policy: transient, temporary, or persistent
29        #[arg(short, long)]
30        policy: Option<String>,
31
32        /// Region: US or EMEA
33        #[arg(short, long)]
34        region: Option<String>,
35    },
36
37    /// List all buckets
38    List,
39
40    /// Show bucket details
41    Info {
42        /// Bucket key
43        bucket_key: String,
44    },
45
46    /// Delete a bucket
47    Delete {
48        /// Bucket key to delete
49        bucket_key: Option<String>,
50
51        /// Skip confirmation prompt
52        #[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/// Serializable bucket representation for output
77#[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/// Serializable bucket info representation
88#[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    // Generate a unique prefix suggestion based on timestamp
112    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    // Get bucket key interactively if not provided
119    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    // Get region interactively if not provided
162    let selected_region = match region {
163        Some(r) => {
164            // Try to find matching region from the enum list
165            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            // Default to US, or prompt if interactive
181            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", &region_labels, 0)?;
184            regions[selection]
185        }
186    };
187
188    // Get retention policy interactively if not provided
189    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            // Default to transient, or prompt if interactive
195            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
261/// Streaming bucket listing for Table mode — displays results per-region as they arrive.
262async 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 &region_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
352/// Batch bucket listing for structured output (JSON/YAML/CSV).
353async 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    // Get bucket key interactively if not provided
452    let key = match bucket_key {
453        Some(k) => k,
454        None => {
455            // List buckets and let user select
456            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    // Confirm deletion (respects --yes flag in non-interactive mode)
473    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
523/// Convert millisecond timestamp to human-readable format
524fn 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}