use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
pub enum CloudCommand {
Upload {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
provider: String,
#[arg(long)]
bucket: String,
#[arg(long)]
key: Option<String>,
#[arg(long)]
region: Option<String>,
#[arg(long)]
multipart: bool,
#[arg(long)]
bandwidth_limit: Option<u32>,
},
Download {
#[arg(long)]
provider: String,
#[arg(long)]
bucket: String,
#[arg(long)]
key: String,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
region: Option<String>,
},
Transcode {
#[arg(long)]
provider: String,
#[arg(long)]
bucket: String,
#[arg(long)]
input_key: String,
#[arg(long)]
output_key: String,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
region: Option<String>,
},
Status {
#[arg(long)]
provider: String,
#[arg(long)]
job_id: String,
#[arg(long)]
region: Option<String>,
},
Cost {
#[arg(long)]
provider: String,
#[arg(long)]
storage_gb: f64,
#[arg(long)]
egress_gb: Option<f64>,
#[arg(long)]
transcode_minutes: Option<f64>,
#[arg(long)]
region: Option<String>,
},
}
struct PricingTier {
storage_per_gb: f64,
egress_per_gb: f64,
transcode_per_min: f64,
name: &'static str,
}
fn pricing_for(provider: &str, region: &str) -> Result<PricingTier> {
let _region = region; match provider.to_lowercase().as_str() {
"s3" | "aws" => Ok(PricingTier {
storage_per_gb: 0.023,
egress_per_gb: 0.09,
transcode_per_min: 0.024,
name: "AWS S3",
}),
"azure" => Ok(PricingTier {
storage_per_gb: 0.018,
egress_per_gb: 0.087,
transcode_per_min: 0.022,
name: "Azure Blob",
}),
"gcs" | "google" => Ok(PricingTier {
storage_per_gb: 0.020,
egress_per_gb: 0.12,
transcode_per_min: 0.025,
name: "Google Cloud Storage",
}),
other => Err(anyhow::anyhow!(
"Unknown cloud provider '{}'. Supported: s3, azure, gcs",
other
)),
}
}
fn validate_provider(provider: &str) -> Result<()> {
match provider.to_lowercase().as_str() {
"s3" | "aws" | "azure" | "gcs" | "google" => Ok(()),
other => Err(anyhow::anyhow!(
"Unknown cloud provider '{}'. Supported: s3, azure, gcs",
other
)),
}
}
fn format_provider(provider: &str) -> &str {
match provider.to_lowercase().as_str() {
"s3" | "aws" => "AWS S3",
"azure" => "Azure Blob Storage",
"gcs" | "google" => "Google Cloud Storage",
_ => provider,
}
}
pub async fn handle_cloud_command(command: CloudCommand, json_output: bool) -> Result<()> {
match command {
CloudCommand::Upload {
input,
provider,
bucket,
key,
region,
multipart,
bandwidth_limit,
} => {
run_upload(
&input,
&provider,
&bucket,
&key,
®ion,
multipart,
bandwidth_limit,
json_output,
)
.await
}
CloudCommand::Download {
provider,
bucket,
key,
output,
region,
} => run_download(&provider, &bucket, &key, &output, ®ion, json_output).await,
CloudCommand::Transcode {
provider,
bucket,
input_key,
output_key,
preset,
region,
} => {
run_transcode(
&provider,
&bucket,
&input_key,
&output_key,
&preset,
®ion,
json_output,
)
.await
}
CloudCommand::Status {
provider,
job_id,
region,
} => run_status(&provider, &job_id, ®ion, json_output).await,
CloudCommand::Cost {
provider,
storage_gb,
egress_gb,
transcode_minutes,
region,
} => {
run_cost(
&provider,
storage_gb,
egress_gb,
transcode_minutes,
®ion,
json_output,
)
.await
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderKind {
S3,
Azure,
Gcs,
}
impl ProviderKind {
fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"s3" | "aws" => Some(Self::S3),
"azure" => Some(Self::Azure),
"gcs" | "google" => Some(Self::Gcs),
_ => None,
}
}
}
#[derive(Debug)]
struct ProviderCredentials {
kind: ProviderKind,
region: String,
access_key: Option<String>,
secret_key: Option<String>,
}
fn resolve_credentials(provider: &str, region: Option<&str>) -> Result<ProviderCredentials> {
let kind = ProviderKind::from_str(provider).ok_or_else(|| {
anyhow::anyhow!("unknown provider: {provider}; supported: s3, azure, gcs")
})?;
match kind {
ProviderKind::S3 => {
let access_key = std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| {
anyhow::anyhow!(
"AWS_ACCESS_KEY_ID environment variable not set; \
configure credentials before uploading to S3"
)
})?;
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").map_err(|_| {
anyhow::anyhow!(
"AWS_SECRET_ACCESS_KEY environment variable not set; \
configure credentials before uploading to S3"
)
})?;
let resolved_region = region
.map(String::from)
.or_else(|| std::env::var("AWS_REGION").ok())
.or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
.unwrap_or_else(|| "us-east-1".to_string());
Ok(ProviderCredentials {
kind,
region: resolved_region,
access_key: Some(access_key),
secret_key: Some(secret_key),
})
}
ProviderKind::Azure => {
let account = std::env::var("AZURE_STORAGE_ACCOUNT").map_err(|_| {
anyhow::anyhow!(
"AZURE_STORAGE_ACCOUNT environment variable not set; \
configure credentials before uploading to Azure"
)
})?;
let key = std::env::var("AZURE_STORAGE_KEY").map_err(|_| {
anyhow::anyhow!(
"AZURE_STORAGE_KEY environment variable not set; \
configure credentials before uploading to Azure"
)
})?;
Ok(ProviderCredentials {
kind,
region: region.unwrap_or("global").to_string(),
access_key: Some(account),
secret_key: Some(key),
})
}
ProviderKind::Gcs => {
let _creds = std::env::var("GOOGLE_APPLICATION_CREDENTIALS").map_err(|_| {
anyhow::anyhow!(
"GOOGLE_APPLICATION_CREDENTIALS environment variable not set; \
set it to the path of a service account JSON file before uploading to GCS"
)
})?;
Ok(ProviderCredentials {
kind,
region: region.unwrap_or("us-central1").to_string(),
access_key: None,
secret_key: None,
})
}
}
}
fn build_unified_config(
creds: &ProviderCredentials,
bucket: &str,
) -> oximedia_storage::UnifiedConfig {
use oximedia_storage::{StorageProvider, UnifiedConfig};
match creds.kind {
ProviderKind::S3 => {
let mut cfg = UnifiedConfig {
provider: StorageProvider::S3,
bucket: bucket.to_string(),
region: Some(creds.region.clone()),
endpoint: None,
access_key: creds.access_key.clone(),
secret_key: creds.secret_key.clone(),
project_id: None,
credentials_file: None,
transfer_acceleration: false,
path_style: false,
max_connections: 10,
timeout_seconds: 300,
enable_cache: false,
cache_dir: None,
max_cache_size: 10 * 1024 * 1024 * 1024,
retry: oximedia_storage::RetryConfig::default(),
pool_config: oximedia_storage::ConnectionPoolConfig::default(),
};
if let (Some(ak), Some(sk)) = (&creds.access_key, &creds.secret_key) {
cfg = cfg.with_credentials(ak.clone(), sk.clone());
}
cfg
}
ProviderKind::Azure => UnifiedConfig {
provider: StorageProvider::Azure,
bucket: bucket.to_string(),
region: None,
endpoint: None,
access_key: creds.access_key.clone(),
secret_key: creds.secret_key.clone(),
project_id: None,
credentials_file: None,
transfer_acceleration: false,
path_style: false,
max_connections: 10,
timeout_seconds: 300,
enable_cache: false,
cache_dir: None,
max_cache_size: 10 * 1024 * 1024 * 1024,
retry: oximedia_storage::RetryConfig::default(),
pool_config: oximedia_storage::ConnectionPoolConfig::default(),
},
ProviderKind::Gcs => UnifiedConfig {
provider: StorageProvider::GCS,
bucket: bucket.to_string(),
region: Some(creds.region.clone()),
endpoint: None,
access_key: None,
secret_key: None,
project_id: None,
credentials_file: None,
transfer_acceleration: false,
path_style: false,
max_connections: 10,
timeout_seconds: 300,
enable_cache: false,
cache_dir: None,
max_cache_size: 10 * 1024 * 1024 * 1024,
retry: oximedia_storage::RetryConfig::default(),
pool_config: oximedia_storage::ConnectionPoolConfig::default(),
},
}
}
async fn run_upload(
input: &PathBuf,
provider: &str,
bucket: &str,
key: &Option<String>,
region: &Option<String>,
_multipart: bool,
bandwidth_limit: Option<u32>,
json_output: bool,
) -> Result<()> {
validate_provider(provider)?;
if !input.exists() {
return Err(anyhow::anyhow!("Input file not found: {}", input.display()));
}
let meta = std::fs::metadata(input).context("Failed to read file metadata")?;
let remote_key = key.clone().unwrap_or_else(|| {
input
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let creds = resolve_credentials(provider, region.as_deref())?;
let region_str = creds.region.clone();
let config = build_unified_config(&creds, bucket);
let etag = upload_file_via_storage(config, creds.kind, input, &remote_key, meta.len())
.await
.with_context(|| format!("Upload to {}/{} failed", bucket, remote_key))?;
if let Some(limit) = bandwidth_limit {
if !json_output {
eprintln!(
"Note: requested bandwidth limit {} KB/s (advisory only)",
limit
);
}
}
if json_output {
let result = serde_json::json!({
"command": "upload",
"provider": format_provider(provider),
"bucket": bucket,
"key": remote_key,
"region": region_str,
"size_bytes": meta.len(),
"etag": etag,
"status": "uploaded",
});
let s = serde_json::to_string_pretty(&result).context("Failed to serialize")?;
println!("{s}");
} else {
println!("{}", "Cloud Upload".green().bold());
println!("{}", "=".repeat(60));
println!("{:22} {}", "Provider:", format_provider(provider));
println!("{:22} {}", "Bucket:", bucket);
println!("{:22} {}", "Remote key:", remote_key);
println!("{:22} {}", "Region:", region_str);
println!("{:22} {}", "Local file:", input.display());
println!(
"{:22} {:.2} MB",
"File size:",
meta.len() as f64 / (1024.0 * 1024.0)
);
println!("{:22} {}", "ETag:", etag);
println!("{:22} {}", "Status:", "uploaded".green().bold());
}
Ok(())
}
async fn upload_file_via_storage(
config: oximedia_storage::UnifiedConfig,
kind: ProviderKind,
file_path: &std::path::Path,
key: &str,
_size: u64,
) -> Result<String> {
use oximedia_storage::UploadOptions;
let opts = UploadOptions::default();
match kind {
ProviderKind::S3 => {
#[cfg(feature = "s3")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::s3::S3Storage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create S3 client: {}", e))?;
storage
.upload_file(key, file_path, opts)
.await
.map_err(|e| anyhow::anyhow!("S3 upload failed: {}", e))
}
#[cfg(not(feature = "s3"))]
{
let _ = (config, file_path, key, opts);
Err(anyhow::anyhow!(
"S3 backend is not compiled in; rebuild oximedia-cli with --features s3"
))
}
}
ProviderKind::Azure => {
#[cfg(feature = "azure")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::azure::AzureStorage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create Azure client: {}", e))?;
storage
.upload_file(key, file_path, opts)
.await
.map_err(|e| anyhow::anyhow!("Azure upload failed: {}", e))
}
#[cfg(not(feature = "azure"))]
{
let _ = (config, file_path, key, opts);
Err(anyhow::anyhow!(
"Azure backend is not compiled in; rebuild oximedia-cli with --features azure"
))
}
}
ProviderKind::Gcs => {
#[cfg(feature = "gcs")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::gcs::GcsStorage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create GCS client: {}", e))?;
storage
.upload_file(key, file_path, opts)
.await
.map_err(|e| anyhow::anyhow!("GCS upload failed: {}", e))
}
#[cfg(not(feature = "gcs"))]
{
let _ = (config, file_path, key, opts);
Err(anyhow::anyhow!(
"GCS backend is not compiled in; rebuild oximedia-cli with --features gcs"
))
}
}
}
}
async fn run_download(
provider: &str,
bucket: &str,
key: &str,
output: &PathBuf,
region: &Option<String>,
json_output: bool,
) -> Result<()> {
validate_provider(provider)?;
let creds = resolve_credentials(provider, region.as_deref())?;
let region_str = creds.region.clone();
let config = build_unified_config(&creds, bucket);
download_file_via_storage(config, creds.kind, key, output)
.await
.with_context(|| format!("Download of {}/{} failed", bucket, key))?;
let file_size = std::fs::metadata(output).map(|m| m.len()).unwrap_or(0);
if json_output {
let result = serde_json::json!({
"command": "download",
"provider": format_provider(provider),
"bucket": bucket,
"key": key,
"region": region_str,
"output": output.display().to_string(),
"size_bytes": file_size,
"status": "downloaded",
});
let s = serde_json::to_string_pretty(&result).context("Failed to serialize")?;
println!("{s}");
} else {
println!("{}", "Cloud Download".green().bold());
println!("{}", "=".repeat(60));
println!("{:22} {}", "Provider:", format_provider(provider));
println!("{:22} {}", "Bucket:", bucket);
println!("{:22} {}", "Remote key:", key);
println!("{:22} {}", "Region:", region_str);
println!("{:22} {}", "Output:", output.display());
println!(
"{:22} {:.2} MB",
"File size:",
file_size as f64 / (1024.0 * 1024.0)
);
println!("{:22} {}", "Status:", "downloaded".green().bold());
}
Ok(())
}
async fn download_file_via_storage(
config: oximedia_storage::UnifiedConfig,
kind: ProviderKind,
key: &str,
output: &std::path::Path,
) -> Result<()> {
use oximedia_storage::DownloadOptions;
let opts = DownloadOptions::default();
match kind {
ProviderKind::S3 => {
#[cfg(feature = "s3")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::s3::S3Storage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create S3 client: {}", e))?;
storage
.download_file(key, output, opts)
.await
.map_err(|e| anyhow::anyhow!("S3 download failed: {}", e))
}
#[cfg(not(feature = "s3"))]
{
let _ = (config, key, output, opts);
Err(anyhow::anyhow!(
"S3 backend is not compiled in; rebuild oximedia-cli with --features s3"
))
}
}
ProviderKind::Azure => {
#[cfg(feature = "azure")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::azure::AzureStorage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create Azure client: {}", e))?;
storage
.download_file(key, output, opts)
.await
.map_err(|e| anyhow::anyhow!("Azure download failed: {}", e))
}
#[cfg(not(feature = "azure"))]
{
let _ = (config, key, output, opts);
Err(anyhow::anyhow!(
"Azure backend is not compiled in; rebuild oximedia-cli with --features azure"
))
}
}
ProviderKind::Gcs => {
#[cfg(feature = "gcs")]
{
use oximedia_storage::CloudStorage as _;
let storage = oximedia_storage::gcs::GcsStorage::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create GCS client: {}", e))?;
storage
.download_file(key, output, opts)
.await
.map_err(|e| anyhow::anyhow!("GCS download failed: {}", e))
}
#[cfg(not(feature = "gcs"))]
{
let _ = (config, key, output, opts);
Err(anyhow::anyhow!(
"GCS backend is not compiled in; rebuild oximedia-cli with --features gcs"
))
}
}
}
}
async fn run_transcode(
_provider: &str,
_bucket: &str,
_input_key: &str,
_output_key: &str,
_preset: &Option<String>,
_region: &Option<String>,
_json_output: bool,
) -> Result<()> {
Err(anyhow::anyhow!(
"managed cloud transcoding is not available; \
use 'oximedia transcode' for local transcoding instead"
))
}
async fn run_status(
_provider: &str,
_job_id: &str,
_region: &Option<String>,
_json_output: bool,
) -> Result<()> {
Err(anyhow::anyhow!(
"cloud job status requires a managed cloud transcoding service; \
use 'oximedia transcode' for local jobs"
))
}
async fn run_cost(
provider: &str,
storage_gb: f64,
egress_gb: Option<f64>,
transcode_minutes: Option<f64>,
region: &Option<String>,
json_output: bool,
) -> Result<()> {
let region_str = region.as_deref().unwrap_or("us-east-1");
let pricing = pricing_for(provider, region_str)?;
let egress = egress_gb.unwrap_or(0.0);
let transcode = transcode_minutes.unwrap_or(0.0);
let storage_cost = storage_gb * pricing.storage_per_gb;
let egress_cost = egress * pricing.egress_per_gb;
let transcode_cost = transcode * pricing.transcode_per_min;
let total_cost = storage_cost + egress_cost + transcode_cost;
if json_output {
let result = serde_json::json!({
"command": "cost",
"provider": pricing.name,
"region": region_str,
"storage_gb": storage_gb,
"egress_gb": egress,
"transcode_minutes": transcode,
"storage_cost_usd": format!("{:.4}", storage_cost),
"egress_cost_usd": format!("{:.4}", egress_cost),
"transcode_cost_usd": format!("{:.4}", transcode_cost),
"total_cost_usd": format!("{:.4}", total_cost),
"currency": "USD",
"note": "Estimates based on standard tier pricing",
});
let s = serde_json::to_string_pretty(&result).context("Failed to serialize")?;
println!("{s}");
} else {
println!("{}", "Cloud Cost Estimate".green().bold());
println!("{}", "=".repeat(60));
println!("{:22} {}", "Provider:", pricing.name);
println!("{:22} {}", "Region:", region_str);
println!();
println!("{}", "Usage".cyan().bold());
println!("{}", "-".repeat(60));
println!("{:22} {:.2} GB", "Storage:", storage_gb);
println!("{:22} {:.2} GB", "Egress:", egress);
println!("{:22} {:.1} min", "Transcode:", transcode);
println!();
println!("{}", "Cost Breakdown (USD/month)".cyan().bold());
println!("{}", "-".repeat(60));
println!(
"{:22} ${:.4} (${:.4}/GB)",
"Storage:", storage_cost, pricing.storage_per_gb
);
println!(
"{:22} ${:.4} (${:.4}/GB)",
"Egress:", egress_cost, pricing.egress_per_gb
);
println!(
"{:22} ${:.4} (${:.4}/min)",
"Transcode:", transcode_cost, pricing.transcode_per_min
);
println!("{}", "-".repeat(60));
println!(
"{:22} {}",
"TOTAL:",
format!("${:.4}", total_cost).green().bold()
);
println!();
println!(
"{}",
"Note: Estimates based on standard tier pricing. Actual costs may vary.".dimmed()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_provider_known() {
assert!(validate_provider("s3").is_ok());
assert!(validate_provider("azure").is_ok());
assert!(validate_provider("gcs").is_ok());
assert!(validate_provider("aws").is_ok());
assert!(validate_provider("google").is_ok());
}
#[test]
fn test_validate_provider_unknown() {
assert!(validate_provider("dropbox").is_err());
assert!(validate_provider("").is_err());
}
#[test]
fn test_pricing_s3() {
let p = pricing_for("s3", "us-east-1");
assert!(p.is_ok());
let p = p.expect("should succeed");
assert!(p.storage_per_gb > 0.0);
assert!(p.egress_per_gb > 0.0);
assert!(p.transcode_per_min > 0.0);
}
#[test]
fn test_pricing_unknown() {
let p = pricing_for("dropbox", "us-east-1");
assert!(p.is_err());
}
#[test]
fn test_cost_calculation() {
let p = pricing_for("s3", "us-east-1").expect("should succeed");
let storage_cost = 100.0 * p.storage_per_gb;
let egress_cost = 50.0 * p.egress_per_gb;
let transcode_cost = 120.0 * p.transcode_per_min;
let total = storage_cost + egress_cost + transcode_cost;
assert!(total > 0.0);
assert!(total > 5.0);
}
#[test]
fn unknown_provider_returns_err() {
match resolve_credentials("unknown-provider-xyz", None) {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("unknown") || msg.contains("provider"),
"got: {msg}"
);
}
Ok(_) => panic!("expected error for unknown provider"),
}
}
#[test]
fn s3_missing_key_id_returns_err() {
let result = resolve_credentials("s3", None);
if let Err(e) = result {
let msg = e.to_string();
assert!(
msg.contains("AWS_ACCESS_KEY_ID") || msg.contains("credentials"),
"expected credential mention, got: {msg}"
);
assert!(
!msg.to_lowercase().contains("simulation"),
"must not say simulation: {msg}"
);
}
}
#[test]
fn provider_kind_from_str_roundtrip() {
assert_eq!(ProviderKind::from_str("s3"), Some(ProviderKind::S3));
assert_eq!(ProviderKind::from_str("aws"), Some(ProviderKind::S3));
assert_eq!(ProviderKind::from_str("azure"), Some(ProviderKind::Azure));
assert_eq!(ProviderKind::from_str("gcs"), Some(ProviderKind::Gcs));
assert_eq!(ProviderKind::from_str("google"), Some(ProviderKind::Gcs));
assert_eq!(ProviderKind::from_str("dropbox"), None);
}
}