use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use serde::Serialize;
use std::str::FromStr;
use crate::commands::tracked::tracked_op;
use crate::output::OutputFormat;
use raps_kernel::prompts;
use raps_oss::{OssClient, Region, RetentionPolicy};
#[derive(Debug, Subcommand)]
pub enum BucketCommands {
Create {
#[arg(short, long)]
key: Option<String>,
#[arg(short, long)]
policy: Option<String>,
#[arg(short, long)]
region: Option<String>,
},
List,
Info {
bucket_key: String,
},
Delete {
bucket_key: Option<String>,
#[arg(short = 'y', long)]
yes: bool,
},
}
impl BucketCommands {
pub async fn execute(self, client: &OssClient, output_format: OutputFormat) -> Result<()> {
match self {
BucketCommands::Create {
key,
policy,
region,
} => create_bucket(client, key, policy, region, output_format).await,
BucketCommands::List => list_buckets(client, output_format).await,
BucketCommands::Info { bucket_key } => {
bucket_info(client, &bucket_key, output_format).await
}
BucketCommands::Delete { bucket_key, yes } => {
delete_bucket(client, bucket_key, yes, output_format).await
}
}
}
}
#[derive(Debug, Serialize)]
struct BucketOutput {
bucket_key: String,
policy_key: String,
bucket_owner: String,
created_date: u64,
created_date_human: String,
region: String,
}
#[derive(Debug, Serialize)]
struct BucketInfoOutput {
bucket_key: String,
bucket_owner: String,
policy_key: String,
created_date: u64,
created_date_human: String,
permissions: Vec<PermissionOutput>,
}
#[derive(Debug, Serialize)]
struct PermissionOutput {
auth_id: String,
access: String,
}
async fn create_bucket(
client: &OssClient,
key: Option<String>,
policy: Option<String>,
region: Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let suggested_prefix = format!("aps-{}", timestamp);
let bucket_key = match key {
Some(k) => k,
None => {
println!(
"{}",
"Note: Bucket keys must be globally unique across all APS applications.".yellow()
);
println!(
"{}",
format!(
"Suggestion: Use a prefix like '{}-yourname'",
suggested_prefix
)
.dimmed()
);
prompts::input_validated(
"Enter bucket key",
Some(&suggested_prefix),
|input: &String| {
if input.len() < 3 {
Err("Bucket key must be at least 3 characters")
} else if input.len() > 128 {
Err("Bucket key must be at most 128 characters")
} else if !input.chars().all(|c| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| c == '-'
|| c == '_'
|| c == '.'
}) {
Err(
"Bucket key can only contain lowercase letters, numbers, hyphens, underscores, and dots",
)
} else {
Ok(())
}
},
)?
}
};
let selected_region = match region {
Some(r) => {
let regions = Region::all();
regions
.iter()
.find(|reg| reg.to_string().to_uppercase() == r.to_uppercase())
.cloned()
.ok_or_else(|| {
let available = regions
.iter()
.map(|reg| reg.to_string())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("Invalid region. Available regions: {}", available)
})?
}
None => {
let regions = Region::all();
let region_labels: Vec<String> = regions.iter().map(|r| r.to_string()).collect();
let selection = prompts::select_with_default("Select region", ®ion_labels, 0)?;
regions[selection]
}
};
let selected_policy = match policy {
Some(p) => RetentionPolicy::from_str(&p).map_err(|_| {
anyhow::anyhow!("Invalid policy. Use transient, temporary, or persistent.")
})?,
None => {
let policies = RetentionPolicy::all();
let policy_labels: Vec<String> = policies
.iter()
.map(|p| match p {
RetentionPolicy::Transient => "transient (deleted after 24 hours)".to_string(),
RetentionPolicy::Temporary => "temporary (deleted after 30 days)".to_string(),
RetentionPolicy::Persistent => "persistent (kept until deleted)".to_string(),
})
.collect();
let selection =
prompts::select_with_default("Select retention policy", &policy_labels, 0)?;
policies[selection]
}
};
if output_format.supports_colors() {
println!("{}", "Creating bucket...".dimmed());
}
let bucket = client
.create_bucket(&bucket_key, selected_policy, selected_region)
.await
.context(format!(
"Failed to create bucket '{}'. Bucket keys must be globally unique",
bucket_key
))?;
let bucket_output = BucketInfoOutput {
bucket_key: bucket.bucket_key.clone(),
bucket_owner: bucket.bucket_owner.clone(),
policy_key: bucket.policy_key.clone(),
created_date: bucket.created_date,
created_date_human: chrono_humanize(bucket.created_date),
permissions: bucket
.permissions
.iter()
.map(|p| PermissionOutput {
auth_id: p.auth_id.clone(),
access: p.access.clone(),
})
.collect(),
};
match output_format {
OutputFormat::Table => {
println!("{} Bucket created successfully!", "✓".green().bold());
println!(" {} {}", "Key:".bold(), bucket.bucket_key);
println!(" {} {}", "Policy:".bold(), bucket.policy_key);
println!(" {} {}", "Owner:".bold(), bucket.bucket_owner);
}
_ => {
output_format.write(&bucket_output)?;
}
}
Ok(())
}
async fn list_buckets(client: &OssClient, output_format: OutputFormat) -> Result<()> {
match output_format {
OutputFormat::Table => list_buckets_streaming(client).await,
_ => list_buckets_batch(client, output_format).await,
}
}
async fn list_buckets_streaming(client: &OssClient) -> Result<()> {
use raps_kernel::api_health;
let spinner = raps_kernel::progress::spinner("Fetching buckets from US, EMEA...");
let start = std::time::Instant::now();
let region_results = client.list_buckets_streaming().await;
let elapsed = start.elapsed();
let snap = api_health::snapshot();
let status_suffix = if snap.sample_count > 0 {
format!(
" ({}, avg: {}, API: {})",
api_health::format_duration_ms(elapsed),
api_health::format_duration_ms(snap.avg_latency),
snap.health_status,
)
} else {
format!(" ({})", api_health::format_duration_ms(elapsed))
};
spinner.finish_with_message(format!(
"\u{2713} Fetching buckets from all regions{}",
status_suffix
));
let mut all_outputs = Vec::new();
for rr in ®ion_results {
match &rr.buckets {
Ok(buckets) => {
println!(
"{} {} responded ({}) \u{2014} {} buckets",
"\u{2713}".green().bold(),
rr.region,
api_health::format_duration_ms(rr.elapsed),
buckets.len(),
);
for b in buckets {
all_outputs.push(BucketOutput {
bucket_key: b.bucket_key.clone(),
policy_key: b.policy_key.clone(),
bucket_owner: String::new(),
created_date: b.created_date,
created_date_human: chrono_humanize(b.created_date),
region: b.region.as_deref().unwrap_or("US").to_string(),
});
}
}
Err(e) => {
println!(
"{} {} failed ({}) \u{2014} {}",
"\u{2717}".red().bold(),
rr.region,
api_health::format_duration_ms(rr.elapsed),
e,
);
}
}
}
if all_outputs.is_empty() {
println!("{}", "No buckets found.".yellow());
return Ok(());
}
println!("\n{}", "Buckets:".bold());
println!("{}", "-".repeat(90));
println!(
"{:<40} {:<12} {:<8} {}",
"Bucket Key".bold(),
"Policy".bold(),
"Region".bold(),
"Created".bold()
);
println!("{}", "-".repeat(90));
for bucket in &all_outputs {
println!(
"{:<40} {:<12} {:<8} {}",
bucket.bucket_key.cyan(),
bucket.policy_key,
bucket.region.yellow(),
bucket.created_date_human.dimmed()
);
}
println!("{}", "-".repeat(90));
Ok(())
}
async fn list_buckets_batch(client: &OssClient, output_format: OutputFormat) -> Result<()> {
let buckets = client
.list_buckets()
.await
.context("Failed to list buckets. Check your authentication with 'raps auth test'")?;
if buckets.is_empty() {
output_format.write(&Vec::<BucketOutput>::new())?;
return Ok(());
}
let bucket_outputs: Vec<BucketOutput> = buckets
.iter()
.map(|b| BucketOutput {
bucket_key: b.bucket_key.clone(),
policy_key: b.policy_key.clone(),
bucket_owner: String::new(),
created_date: b.created_date,
created_date_human: chrono_humanize(b.created_date),
region: b.region.as_deref().unwrap_or("US").to_string(),
})
.collect();
output_format.write(&bucket_outputs)?;
Ok(())
}
async fn bucket_info(
client: &OssClient,
bucket_key: &str,
output_format: OutputFormat,
) -> Result<()> {
let bucket = tracked_op("Fetching bucket details", output_format, || async {
client.get_bucket_details(bucket_key).await.context(format!(
"Failed to get bucket details for '{}'. Verify the bucket key is correct",
bucket_key
))
})
.await?;
let bucket_output = BucketInfoOutput {
bucket_key: bucket.bucket_key.clone(),
bucket_owner: bucket.bucket_owner.clone(),
policy_key: bucket.policy_key.clone(),
created_date: bucket.created_date,
created_date_human: chrono_humanize(bucket.created_date),
permissions: bucket
.permissions
.iter()
.map(|p| PermissionOutput {
auth_id: p.auth_id.clone(),
access: p.access.clone(),
})
.collect(),
};
match output_format {
OutputFormat::Table => {
println!("\n{}", "Bucket Details".bold());
println!("{}", "-".repeat(60));
println!(" {} {}", "Key:".bold(), bucket.bucket_key.cyan());
println!(" {} {}", "Owner:".bold(), bucket.bucket_owner);
println!(" {} {}", "Policy:".bold(), bucket.policy_key);
println!(
" {} {}",
"Created:".bold(),
bucket_output.created_date_human
);
if !bucket.permissions.is_empty() {
println!("\n {}:", "Permissions".bold());
for perm in &bucket.permissions {
println!(
" {} {}: {}",
"-".cyan(),
perm.auth_id.dimmed(),
perm.access
);
}
}
println!("{}", "-".repeat(60));
}
_ => {
output_format.write(&bucket_output)?;
}
}
Ok(())
}
async fn delete_bucket(
client: &OssClient,
bucket_key: Option<String>,
skip_confirm: bool,
output_format: OutputFormat,
) -> Result<()> {
let key = match bucket_key {
Some(k) => k,
None => {
let buckets = client
.list_buckets()
.await
.context("Failed to list buckets")?;
if buckets.is_empty() {
println!("{}", "No buckets found to delete.".yellow());
return Ok(());
}
let bucket_keys: Vec<String> = buckets.iter().map(|b| b.bucket_key.clone()).collect();
let selection = prompts::select("Select bucket to delete", &bucket_keys)?;
bucket_keys[selection].clone()
}
};
if !skip_confirm {
let confirmed = prompts::confirm_destructive(format!(
"Are you sure you want to delete bucket '{}'?",
key.red()
))?;
if !confirmed {
println!("{}", "Deletion cancelled.".yellow());
return Ok(());
}
}
if output_format.supports_colors() {
println!("{}", "Deleting bucket...".dimmed());
}
client.delete_bucket(&key).await.context(format!(
"Failed to delete bucket '{}'. The bucket must be empty before deletion",
key
))?;
#[derive(Serialize)]
struct DeleteResult {
success: bool,
bucket_key: String,
message: String,
}
let result = DeleteResult {
success: true,
bucket_key: key.clone(),
message: format!("Bucket '{}' deleted successfully!", key),
};
match output_format {
OutputFormat::Table => {
println!(
"{} Bucket '{}' deleted successfully!",
"✓".green().bold(),
key
);
}
_ => {
output_format.write(&result)?;
}
}
Ok(())
}
fn chrono_humanize(timestamp_ms: u64) -> String {
use std::time::{Duration, UNIX_EPOCH};
let duration = Duration::from_millis(timestamp_ms);
let datetime = UNIX_EPOCH + duration;
if let Ok(elapsed) = datetime.elapsed() {
let secs = elapsed.as_secs();
if secs < 60 {
format!("{} seconds ago", secs)
} else if secs < 3600 {
format!("{} minutes ago", secs / 60)
} else if secs < 86400 {
format!("{} hours ago", secs / 3600)
} else {
format!("{} days ago", secs / 86400)
}
} else {
"in the future".to_string()
}
}