use std::path::PathBuf;
use crate::error::{CliError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageUri {
Local(PathBuf),
S3 {
bucket: String,
prefix: String,
},
}
impl StorageUri {
pub fn parse(uri: &str) -> Result<Self> {
let uri = uri.trim();
if uri.is_empty() {
return Err(CliError::InvalidArgument("URI cannot be empty".to_string()));
}
if let Some(path) = uri.strip_prefix("file://") {
Ok(StorageUri::Local(PathBuf::from(path)))
} else if let Some(rest) = uri.strip_prefix("s3://") {
Self::parse_s3_uri(rest)
} else if uri.contains("://") {
let scheme = uri.split("://").next().unwrap_or("");
Err(CliError::InvalidArgument(format!(
"Unsupported URI scheme: '{}'. Use 's3://' or 'file://' or a local path",
scheme
)))
} else {
Ok(StorageUri::Local(PathBuf::from(uri)))
}
}
fn parse_s3_uri(rest: &str) -> Result<Self> {
if rest.is_empty() {
return Err(CliError::InvalidArgument(
"S3 URI must specify a bucket: s3://bucket[/prefix]".to_string(),
));
}
let (bucket, prefix) = match rest.find('/') {
Some(idx) => {
let bucket = &rest[..idx];
let prefix = &rest[idx + 1..];
(bucket.to_string(), prefix.to_string())
}
None => (rest.to_string(), String::new()),
};
if bucket.is_empty() {
return Err(CliError::InvalidArgument(
"S3 bucket name cannot be empty".to_string(),
));
}
Ok(StorageUri::S3 { bucket, prefix })
}
pub fn to_embedded_uri(&self) -> String {
match self {
StorageUri::Local(path) => {
path.display().to_string()
}
StorageUri::S3 { bucket, prefix } => {
if prefix.is_empty() {
format!("s3://{}", bucket)
} else {
format!("s3://{}/{}", bucket, prefix)
}
}
}
}
pub fn is_s3(&self) -> bool {
matches!(self, StorageUri::S3 { .. })
}
#[allow(dead_code)]
pub fn is_local(&self) -> bool {
matches!(self, StorageUri::Local(_))
}
}
pub fn validate_s3_credentials() -> Result<()> {
let mut missing = Vec::new();
if std::env::var("AWS_ACCESS_KEY_ID").is_err() {
missing.push("AWS_ACCESS_KEY_ID");
}
if std::env::var("AWS_SECRET_ACCESS_KEY").is_err() {
missing.push("AWS_SECRET_ACCESS_KEY");
}
if std::env::var("AWS_REGION").is_err() {
missing.push("AWS_REGION");
}
if missing.is_empty() {
Ok(())
} else {
Err(CliError::Credentials(format!(
"Missing S3 credentials. Please set the following environment variables: {}",
missing.join(", ")
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn test_parse_local_absolute_path() {
let uri = StorageUri::parse("/path/to/db").unwrap();
assert!(uri.is_local());
assert!(!uri.is_s3());
assert_eq!(uri, StorageUri::Local(PathBuf::from("/path/to/db")));
}
#[test]
fn test_parse_local_relative_path() {
let uri = StorageUri::parse("./relative/path").unwrap();
assert!(uri.is_local());
assert_eq!(uri, StorageUri::Local(PathBuf::from("./relative/path")));
}
#[test]
fn test_parse_file_scheme() {
let uri = StorageUri::parse("file:///path/to/db").unwrap();
assert!(uri.is_local());
assert_eq!(uri, StorageUri::Local(PathBuf::from("/path/to/db")));
}
#[test]
fn test_parse_local_to_embedded_uri() {
let uri = StorageUri::parse("/path/to/db").unwrap();
assert_eq!(uri.to_embedded_uri(), "/path/to/db");
}
#[test]
fn test_parse_s3_with_prefix() {
let uri = StorageUri::parse("s3://my-bucket/data/db").unwrap();
assert!(uri.is_s3());
assert!(!uri.is_local());
assert_eq!(
uri,
StorageUri::S3 {
bucket: "my-bucket".to_string(),
prefix: "data/db".to_string(),
}
);
}
#[test]
fn test_parse_s3_bucket_only() {
let uri = StorageUri::parse("s3://my-bucket").unwrap();
assert_eq!(
uri,
StorageUri::S3 {
bucket: "my-bucket".to_string(),
prefix: String::new(),
}
);
}
#[test]
fn test_parse_s3_bucket_with_trailing_slash() {
let uri = StorageUri::parse("s3://my-bucket/").unwrap();
assert_eq!(
uri,
StorageUri::S3 {
bucket: "my-bucket".to_string(),
prefix: String::new(),
}
);
}
#[test]
fn test_s3_to_embedded_uri_with_prefix() {
let uri = StorageUri::S3 {
bucket: "my-bucket".to_string(),
prefix: "data/db".to_string(),
};
assert_eq!(uri.to_embedded_uri(), "s3://my-bucket/data/db");
}
#[test]
fn test_s3_to_embedded_uri_bucket_only() {
let uri = StorageUri::S3 {
bucket: "my-bucket".to_string(),
prefix: String::new(),
};
assert_eq!(uri.to_embedded_uri(), "s3://my-bucket");
}
#[test]
fn test_parse_empty_uri() {
let err = StorageUri::parse("").unwrap_err();
assert!(matches!(err, CliError::InvalidArgument(_)));
}
#[test]
fn test_parse_unknown_scheme() {
let err = StorageUri::parse("http://example.com").unwrap_err();
assert!(matches!(err, CliError::InvalidArgument(_)));
}
#[test]
fn test_parse_s3_empty_bucket() {
let err = StorageUri::parse("s3://").unwrap_err();
assert!(matches!(err, CliError::InvalidArgument(_)));
}
#[test]
fn test_parse_whitespace_trimmed() {
let uri = StorageUri::parse(" /path/to/db ").unwrap();
assert_eq!(uri, StorageUri::Local(PathBuf::from("/path/to/db")));
}
#[test]
fn test_validate_s3_credentials_missing() {
let _guard = env_lock().lock().unwrap();
let access_key = std::env::var("AWS_ACCESS_KEY_ID").ok();
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
let region = std::env::var("AWS_REGION").ok();
std::env::remove_var("AWS_ACCESS_KEY_ID");
std::env::remove_var("AWS_SECRET_ACCESS_KEY");
std::env::remove_var("AWS_REGION");
let result = validate_s3_credentials();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, CliError::Credentials(_)));
if let Some(v) = access_key {
std::env::set_var("AWS_ACCESS_KEY_ID", v);
}
if let Some(v) = secret_key {
std::env::set_var("AWS_SECRET_ACCESS_KEY", v);
}
if let Some(v) = region {
std::env::set_var("AWS_REGION", v);
}
}
#[test]
fn test_validate_s3_credentials_present() {
let _guard = env_lock().lock().unwrap();
let access_key = std::env::var("AWS_ACCESS_KEY_ID").ok();
let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
let region = std::env::var("AWS_REGION").ok();
std::env::set_var("AWS_ACCESS_KEY_ID", "test-key");
std::env::set_var("AWS_SECRET_ACCESS_KEY", "test-secret");
std::env::set_var("AWS_REGION", "us-east-1");
let result = validate_s3_credentials();
assert!(result.is_ok());
match access_key {
Some(v) => std::env::set_var("AWS_ACCESS_KEY_ID", v),
None => std::env::remove_var("AWS_ACCESS_KEY_ID"),
}
match secret_key {
Some(v) => std::env::set_var("AWS_SECRET_ACCESS_KEY", v),
None => std::env::remove_var("AWS_SECRET_ACCESS_KEY"),
}
match region {
Some(v) => std::env::set_var("AWS_REGION", v),
None => std::env::remove_var("AWS_REGION"),
}
}
}