use super::config::{StorageConfig, StorageType};
use super::error::{StorageError, StorageResult};
use super::provider::{string_to_path, FileMetadata, StorageProvider};
use crate::util::retry::retry_with_max_retries;
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::StreamExt;
use hdfs_native_object_store::HdfsObjectStoreBuilder;
use object_store::{
aws::AmazonS3Builder, azure::MicrosoftAzureBuilder, gcp::GoogleCloudStorageBuilder,
http::HttpBuilder, local::LocalFileSystem, ClientOptions, ObjectStore, ObjectStoreExt,
RetryConfig,
};
use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing::info;
pub struct ObjectStoreProvider {
pub config: StorageConfig,
pub store: Arc<dyn ObjectStore>,
pub base_path: String,
}
impl ObjectStoreProvider {
pub async fn new(config: StorageConfig) -> StorageResult<Self> {
let (store, base_path) = Self::build_store(&config).await?;
Ok(Self {
config,
store: Arc::new(store),
base_path,
})
}
async fn build_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
match config.storage_type {
StorageType::Local => Self::build_local_store(config),
StorageType::Aws => Self::build_aws_store(config),
StorageType::Azure => Self::build_azure_store(config),
StorageType::Gcs => Self::build_gcs_store(config),
StorageType::Hdfs => Self::build_hdfs_store(config),
StorageType::Http => Self::build_http_store(config),
}
}
fn build_local_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let path = config.options.get("path").ok_or_else(|| {
StorageError::ConfigError("Local storage requires 'path' option".to_string())
})?;
let base_path = PathBuf::from(path);
let canonical_path = base_path.canonicalize().map_err(|e| {
StorageError::ConfigError(format!(
"Failed to resolve path '{}': {} (path must exist)",
path, e
))
})?;
if !canonical_path.is_dir() {
return Err(StorageError::ConfigError(format!(
"Base path is not a directory: {}",
canonical_path.display()
)));
}
let store = LocalFileSystem::new_with_prefix(&canonical_path).map_err(|e| {
StorageError::ConfigError(format!("Failed to create local store: {}", e))
})?;
let base_path_str = canonical_path.to_string_lossy().to_string();
Ok((Box::new(store), base_path_str))
}
fn build_connection_options(config: &StorageConfig) -> ClientOptions {
let mut client_options = ClientOptions::default();
if let Some(timeout_str) = config.options.get("timeout") {
if timeout_str == "0" || timeout_str == "disabled" {
client_options = client_options.with_timeout_disabled();
} else if let Ok(sec) = timeout_str.parse::<u64>() {
client_options = client_options.with_timeout(Duration::from_secs(sec))
}
};
if let Some(connect_timeout_str) = config.options.get("connect_timeout") {
if connect_timeout_str == "0" || connect_timeout_str == "disabled" {
client_options = client_options.with_connect_timeout_disabled();
} else if let Ok(ms) = connect_timeout_str.parse::<u64>() {
client_options = client_options.with_connect_timeout(Duration::from_secs(ms))
}
}
if let Some(pool_idle_timeout_str) = config.options.get("pool_idle_timeout") {
if let Ok(sec) = pool_idle_timeout_str.parse::<u64>() {
client_options = client_options.with_pool_idle_timeout(Duration::from_secs(sec))
}
}
if let Some(pool_max_idle_per_host_str) = config.options.get("pool_max_idle_per_host") {
if let Ok(max_idle) = pool_max_idle_per_host_str.parse::<usize>() {
client_options = client_options.with_pool_max_idle_per_host(max_idle)
}
}
client_options
}
fn build_retry_options(config: &StorageConfig) -> RetryConfig {
let default_retry_config = RetryConfig::default();
let max_retries = config
.options
.get("max_retries")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(default_retry_config.max_retries);
let retry_timeout = config
.options
.get("retry_timeout")
.and_then(|s| Some(Duration::from_secs(s.parse::<u64>().ok()?)))
.unwrap_or(default_retry_config.retry_timeout);
RetryConfig {
backoff: Default::default(),
max_retries,
retry_timeout,
}
}
fn get_max_retries(config: &StorageConfig) -> usize {
config
.options
.get("max_retries")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(10)
}
async fn retry_operation<F, Fut, T>(
&self,
operation_name: &str,
operation: F,
) -> StorageResult<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = StorageResult<T>>,
{
let max_retries = Self::get_max_retries(&self.config);
retry_with_max_retries(max_retries, operation_name, operation).await
}
fn build_aws_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let mut builder = AmazonS3Builder::new()
.with_client_options(Self::build_connection_options(config))
.with_retry(Self::build_retry_options(config));
let mut bucket: Option<&String> = None;
let mut endpoint: Option<&String> = None;
for (key, value) in &config.options {
match key.as_str() {
"bucket" => {
bucket = Some(value);
builder = builder.with_bucket_name(value);
}
"region" => builder = builder.with_region(value),
"access_key_id" => builder = builder.with_access_key_id(value),
"secret_access_key" => builder = builder.with_secret_access_key(value),
"session_token" | "token" => builder = builder.with_token(value),
"endpoint" => {
endpoint = Some(value);
builder = builder.with_endpoint(value);
}
"allow_http" => {
if value.to_lowercase() == "true" {
builder = builder.with_allow_http(true);
}
}
"timeout"
| "connect_timeout"
| "max_retries"
| "retry_timeout"
| "pool_idle_timeout"
| "pool_max_idle_per_host" => (),
_ => {
tracing::warn!("Unknown AWS S3 option: {}", key);
}
}
}
let store = builder
.build()
.map_err(|e| StorageError::ConfigError(format!("Failed to create S3 store: {}", e)))?;
let base_url = if let Some(endpoint_url) = endpoint {
endpoint_url.trim_end_matches('/').to_string()
} else if let Some(bucket_name) = bucket {
format!("s3://{}", bucket_name)
} else {
"s3://".to_string()
};
Ok((Box::new(store), base_url))
}
fn build_azure_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let mut builder = MicrosoftAzureBuilder::new()
.with_client_options(Self::build_connection_options(config))
.with_retry(Self::build_retry_options(config));
let mut account_name = config.get_option("account_name").ok_or_else(|| {
StorageError::ConfigError("Azure requires 'account_name' option".to_string())
})?;
let mut container = config.get_option("container").ok_or_else(|| {
StorageError::ConfigError("Azure requires 'container' option".to_string())
})?;
builder = builder.with_account(account_name);
let mut use_fabric_endpoint = false;
let mut custom_endpoint: Option<&String> = None;
for (key, value) in &config.options {
match key.as_str() {
"container" => {
container = value;
builder = builder.with_container_name(value)
}
"account_name" => {
account_name = value;
builder = builder.with_account(value)
}
"access_key" | "account_key" => builder = builder.with_access_key(value),
"sas_token" => {
let pairs: Vec<(String, String)> = value
.trim_start_matches('?')
.split('&')
.filter_map(|pair| {
let mut parts = pair.split('=');
match (parts.next(), parts.next()) {
(Some(k), Some(v)) => Some((k.to_string(), v.to_string())),
_ => None,
}
})
.collect();
builder = builder.with_sas_authorization(pairs);
}
"tenant_id" => builder = builder.with_tenant_id(value),
"client_id" => builder = builder.with_client_id(value),
"client_secret" => builder = builder.with_client_secret(value),
"use_fabric_endpoint" => {
use_fabric_endpoint = value.to_lowercase() == "true";
builder = builder.with_use_fabric_endpoint(use_fabric_endpoint);
}
"endpoint" => {
custom_endpoint = Some(value);
builder = builder.with_endpoint(value.clone());
}
"timeout"
| "connect_timeout"
| "max_retries"
| "retry_timeout"
| "pool_idle_timeout"
| "pool_max_idle_per_host" => (),
_ => {
tracing::info!("Unknown Azure option: {}", key);
}
}
}
let store = builder.build().map_err(|e| {
StorageError::ConfigError(format!("Failed to create Azure store: {}", e))
})?;
let base_url = if let Some(endpoint) = custom_endpoint {
endpoint.trim_end_matches('/').to_string()
} else {
let endpoint_domain = if use_fabric_endpoint {
"dfs.fabric.microsoft.com"
} else {
"dfs.core.windows.net"
};
format!("abfss://{}@{}.{}", container, account_name, endpoint_domain)
};
Ok((Box::new(store), base_url))
}
fn build_gcs_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let mut builder = GoogleCloudStorageBuilder::new()
.with_client_options(Self::build_connection_options(config))
.with_retry(Self::build_retry_options(config));
let mut bucket: Option<&String> = None;
for (key, value) in &config.options {
match key.as_str() {
"bucket" => {
bucket = Some(value);
builder = builder.with_bucket_name(value);
}
"service_account_key_path" => builder = builder.with_service_account_path(value),
"service_account_key" => builder = builder.with_service_account_key(value),
"timeout"
| "connect_timeout"
| "max_retries"
| "retry_timeout"
| "pool_idle_timeout"
| "pool_max_idle_per_host" => (),
_ => {
tracing::warn!("Unknown GCS option: {}", key);
}
}
}
let store = builder
.build()
.map_err(|e| StorageError::ConfigError(format!("Failed to create GCS store: {}", e)))?;
let base_url = if let Some(bucket_name) = bucket {
format!("gs://{}", bucket_name)
} else {
"gs://".to_string()
};
Ok((Box::new(store), base_url))
}
fn build_hdfs_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let url = config.options.get("url").ok_or_else(|| {
StorageError::ConfigError("HDFS storage requires 'url' option".to_string())
})?;
let store = HdfsObjectStoreBuilder::new()
.with_url(url)
.build()
.map_err(|e| {
StorageError::ConfigError(format!("Failed to create HDFS store: {}", e))
})?;
Ok((Box::new(store), url.clone()))
}
fn build_http_store(config: &StorageConfig) -> StorageResult<(Box<dyn ObjectStore>, String)> {
let url = config.options.get("url").ok_or_else(|| {
StorageError::ConfigError("HTTP storage requires 'url' option".to_string())
})?;
let builder = HttpBuilder::new()
.with_url(url)
.with_client_options(Self::build_connection_options(config))
.with_retry(Self::build_retry_options(config));
let store = builder.build().map_err(|e| {
StorageError::ConfigError(format!("Failed to create HTTP store: {}", e))
})?;
Ok((Box::new(store), url.clone()))
}
async fn list_partitions_parallel(
&self,
partitions: Vec<String>,
parallelism: usize,
) -> StorageResult<Vec<FileMetadata>> {
use futures::stream;
let parallelism = parallelism.max(1);
let store = Arc::clone(&self.store);
let config = self.config.clone();
let results: Vec<StorageResult<Vec<FileMetadata>>> = stream::iter(partitions)
.map(|partition_path| {
info!("Listing partition={}", &partition_path);
let store_clone = Arc::clone(&store);
let max_retries = Self::get_max_retries(&config);
async move {
retry_with_max_retries(
max_retries,
&format!("list_partition({})", partition_path),
|| async {
let mut files = Vec::new();
let path = string_to_path(&partition_path);
let mut stream = store_clone.list(Some(&path));
while let Some(meta) = stream.next().await {
let meta = meta?;
files.push(FileMetadata {
path: meta.location.to_string(),
size: meta.size,
last_modified: Some(meta.last_modified),
});
}
info!(
"Listed partition={}, found count={} files",
string_to_path(&partition_path),
files.len()
);
Ok(files)
},
)
.await
}
})
.buffer_unordered(parallelism)
.collect()
.await;
info!(
"Collected count={} first level partitions, parallelism={}",
results.len(),
parallelism
);
let mut all_files = Vec::new();
for result in results {
all_files.extend(result?);
}
info!(
"Found count={} total files across all partitions, parallelism={}",
all_files.len(),
parallelism,
);
Ok(all_files)
}
}
#[async_trait]
impl StorageProvider for ObjectStoreProvider {
fn base_path(&self) -> &str {
&self.base_path
}
async fn validate_connection(&self, path: &str) -> StorageResult<()> {
if self.config.storage_type == StorageType::Local {
let mut path_url = PathBuf::from(&self.base_path);
path_url.push(path);
return if path_url.exists() && path_url.is_dir() {
Ok(())
} else {
Err(StorageError::ConnectionError(format!(
"Base path is not accessible: {}",
path
)))
};
}
self.store.list_with_delimiter(None).await?;
Ok(())
}
async fn list_files(&self, path: &str, recursive: bool) -> StorageResult<Vec<FileMetadata>> {
let path_str = path.to_string();
let store = Arc::clone(&self.store);
self.retry_operation(&format!("list_files({})", path), || async {
let object_path = if path_str.is_empty() {
None
} else {
Some(string_to_path(&path_str))
};
let mut files = Vec::new();
if recursive {
let mut stream = store.list(object_path.as_ref());
while let Some(meta) = stream.next().await {
let meta = meta?;
files.push(FileMetadata {
path: meta.location.to_string(),
size: meta.size,
last_modified: Some(meta.last_modified),
});
}
} else {
let list_result = store.list_with_delimiter(object_path.as_ref()).await?;
for meta in list_result.objects {
files.push(FileMetadata {
path: meta.location.to_string(),
size: meta.size,
last_modified: Some(meta.last_modified),
});
}
}
Ok(files)
})
.await
}
async fn discover_partitions(
&self,
path: &str,
exclude_prefixes: Vec<&str>,
) -> StorageResult<Vec<String>> {
let path_str = path.to_string();
let exclude_prefixes_owned: Vec<String> =
exclude_prefixes.iter().map(|s| s.to_string()).collect();
let store = Arc::clone(&self.store);
self.retry_operation(&format!("discover_partitions({})", path), || async {
let object_path = if path_str.is_empty() {
None
} else {
Some(string_to_path(&path_str))
};
let list_result = store.list_with_delimiter(object_path.as_ref()).await?;
let partitions: Vec<String> = list_result
.common_prefixes
.iter()
.map(|prefix| prefix.to_string())
.filter(|prefix| !exclude_prefixes_owned.iter().any(|p| prefix.contains(p)))
.collect();
Ok(partitions)
})
.await
}
async fn list_files_parallel(
&self,
path: &str,
partitions: Vec<String>,
parallelism: usize,
) -> StorageResult<Vec<FileMetadata>> {
let path_str = path.to_string();
let store = Arc::clone(&self.store);
let mut all_files: Vec<FileMetadata> = self
.retry_operation(&format!("list_files_parallel_root({})", path), || async {
let object_path = if path_str.is_empty() {
None
} else {
Some(string_to_path(&path_str))
};
let list_result = store.list_with_delimiter(object_path.as_ref()).await?;
let files: Vec<FileMetadata> = list_result
.objects
.into_iter()
.map(|meta| FileMetadata {
path: meta.location.to_string(),
size: meta.size,
last_modified: Some(meta.last_modified),
})
.collect();
Ok(files)
})
.await?;
if !partitions.is_empty() {
info!(
"Found count={} first level partitions at location={}, now listing with parallelism={}",
partitions.len(),
path,
parallelism
);
let partition_files = self
.list_partitions_parallel(partitions.clone(), parallelism)
.await?;
all_files.extend(partition_files);
} else {
tracing::debug!(
"No partitions found at location={}, using standard recursive listing",
path
);
let recursive_files = self
.retry_operation(
&format!("list_files_parallel_recursive({})", path),
|| async {
let object_path = if path_str.is_empty() {
None
} else {
Some(string_to_path(&path_str))
};
let mut files = Vec::new();
let mut stream = store.list(object_path.as_ref());
while let Some(meta) = stream.next().await {
let meta = meta?;
files.push(FileMetadata {
path: meta.location.to_string(),
size: meta.size,
last_modified: Some(meta.last_modified),
});
}
Ok(files)
},
)
.await?;
all_files.extend(recursive_files);
}
info!(
"Found count={} partitions and file_count={} at location={}",
partitions.len(),
all_files.len(),
path
);
Ok(all_files)
}
async fn read_file(&self, path: &str) -> StorageResult<Vec<u8>> {
let object_path = string_to_path(path);
let result = self.store.get(&object_path).await?;
let bytes: Bytes = result.bytes().await?;
Ok(bytes.to_vec())
}
async fn exists(&self, path: &str) -> StorageResult<bool> {
let object_path = string_to_path(path);
match self.store.head(&object_path).await {
Ok(_) => Ok(true),
Err(object_store::Error::NotFound { .. }) => Ok(false),
Err(e) => Err(e.into()),
}
}
async fn get_metadata(&self, path: &str) -> StorageResult<FileMetadata> {
let object_path = string_to_path(path);
let meta = self.store.head(&object_path).await?;
Ok(FileMetadata {
path: meta.location.to_string(),
size: meta.size as u64,
last_modified: Some(meta.last_modified),
})
}
fn options(&self) -> &HashMap<String, String> {
&self.config.options
}
fn clean_options(&self) -> HashMap<String, String> {
self.config
.options
.clone()
.into_iter()
.filter(|(k, _)| {
![
"retry_timeout",
"timeout",
"connect_timeout",
"max_retries",
"pool_idle_timeout",
"pool_max_idle_per_host",
]
.contains(&k.as_str())
})
.collect::<HashMap<_, _>>()
}
fn uri_from_path(&self, path: &str) -> String {
fn fix_uri(storage_type: &StorageType, path: &str) -> String {
if storage_type == &StorageType::Local {
let path = path.replace('\\', "/");
let path = path.strip_prefix("//?/").unwrap_or(&path).to_string();
let path_without_scheme = if let Some(without_scheme) = path.strip_prefix("file:") {
without_scheme.trim_start_matches('/').to_string()
} else if path.starts_with('/') {
path.trim_start_matches('/').to_string()
} else {
path.to_string()
};
format!("file:///{}", path_without_scheme)
} else {
path.to_string()
}
}
let fp = if path.contains(&self.base_path) {
path.to_string()
} else {
format!("{}/{}", self.base_path, path.trim_start_matches('/'))
};
fix_uri(&self.config.storage_type, fp.as_str())
}
}
impl Debug for ObjectStoreProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"StorageProvider(type=generic, cloud_provider={}, config={:?})",
self.config.storage_type_str(),
self.config
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_build_connection_options_default() {
let config = StorageConfig::local();
let _options = ObjectStoreProvider::build_connection_options(&config);
}
#[test]
fn test_build_connection_options_with_timeout() {
let config = StorageConfig::local()
.with_option("timeout", "60")
.with_option("connect_timeout", "10");
let _options = ObjectStoreProvider::build_connection_options(&config);
}
#[test]
fn test_build_connection_options_disabled_timeout() {
let config = StorageConfig::local()
.with_option("timeout", "disabled")
.with_option("connect_timeout", "0");
let _options = ObjectStoreProvider::build_connection_options(&config);
}
#[test]
fn test_build_connection_options_with_pool_settings() {
let config = StorageConfig::local()
.with_option("pool_idle_timeout", "30")
.with_option("pool_max_idle_per_host", "10");
let _options = ObjectStoreProvider::build_connection_options(&config);
}
#[test]
fn test_build_connection_options_invalid_values() {
let config = StorageConfig::local()
.with_option("timeout", "invalid")
.with_option("pool_max_idle_per_host", "not_a_number");
let _options = ObjectStoreProvider::build_connection_options(&config);
}
#[test]
fn test_build_retry_options_default() {
let config = StorageConfig::local();
let retry_config = ObjectStoreProvider::build_retry_options(&config);
assert!(retry_config.max_retries > 0);
}
#[test]
fn test_build_retry_options_custom() {
let config = StorageConfig::local()
.with_option("max_retries", "5")
.with_option("retry_timeout", "300");
let retry_config = ObjectStoreProvider::build_retry_options(&config);
assert_eq!(retry_config.max_retries, 5);
assert_eq!(retry_config.retry_timeout, Duration::from_secs(300));
}
#[test]
fn test_build_retry_options_invalid_values() {
let config = StorageConfig::local()
.with_option("max_retries", "invalid")
.with_option("retry_timeout", "not_a_number");
let retry_config = ObjectStoreProvider::build_retry_options(&config);
assert!(retry_config.max_retries > 0);
}
#[test]
fn test_get_max_retries_default() {
let config = StorageConfig::local();
let max_retries = ObjectStoreProvider::get_max_retries(&config);
assert_eq!(max_retries, 20);
}
#[test]
fn test_get_max_retries_custom() {
let config = StorageConfig::local().with_option("max_retries", "15");
let max_retries = ObjectStoreProvider::get_max_retries(&config);
assert_eq!(max_retries, 15);
}
#[test]
fn test_get_max_retries_invalid() {
let config = StorageConfig::local().with_option("max_retries", "invalid");
let max_retries = ObjectStoreProvider::get_max_retries(&config);
assert_eq!(max_retries, 10);
}
#[tokio::test]
async fn test_new_local_provider() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_ok());
let provider = provider.unwrap();
let base_path = provider.base_path.replace("\\\\?\\", "").replace('\\', "/");
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap()
.to_str()
.unwrap()
.replace("\\\\?\\", "")
.replace('\\', "/");
assert!(
base_path.contains(&canonical_temp),
"base_path '{}' should contain '{}'",
base_path,
canonical_temp
);
assert_eq!(provider.config.storage_type, StorageType::Local);
}
#[tokio::test]
async fn test_new_local_provider_invalid_path() {
let config = StorageConfig::local().with_option("path", "/nonexistent/invalid/path");
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_err());
match provider {
Err(StorageError::ConfigError(msg)) => {
assert!(msg.contains("Failed to resolve path"));
}
_ => panic!("Expected ConfigError"),
}
}
#[tokio::test]
async fn test_new_local_provider_missing_path() {
let config = StorageConfig::local();
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_err());
match provider {
Err(StorageError::ConfigError(msg)) => {
assert!(msg.contains("path"));
}
_ => panic!("Expected ConfigError for missing path"),
}
}
#[tokio::test]
async fn test_new_local_provider_file_not_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
fs::write(&file_path, "test content").unwrap();
let config = StorageConfig::local().with_option("path", file_path.to_str().unwrap());
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_err());
match provider {
Err(StorageError::ConfigError(msg)) => {
assert!(msg.contains("not a directory"));
}
_ => panic!("Expected ConfigError for file instead of directory"),
}
}
#[tokio::test]
async fn test_provider_base_path() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let base_path = provider.base_path();
assert!(!base_path.is_empty());
let normalized_base_path = base_path.replace("\\\\?\\", "").replace('\\', "/");
let canonical_temp = temp_dir
.path()
.canonicalize()
.unwrap()
.to_str()
.unwrap()
.replace("\\\\?\\", "")
.replace('\\', "/");
assert!(
normalized_base_path.contains(&canonical_temp),
"base_path '{}' should contain '{}'",
normalized_base_path,
canonical_temp
);
}
#[tokio::test]
async fn test_provider_options() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local()
.with_option("path", temp_path)
.with_option("custom_option", "custom_value");
let provider = ObjectStoreProvider::new(config).await.unwrap();
let options = provider.options();
assert!(options.contains_key("path"));
assert!(options.contains_key("custom_option"));
assert_eq!(options.get("custom_option").unwrap(), "custom_value");
}
#[tokio::test]
async fn test_provider_clean_options() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local()
.with_option("path", temp_path)
.with_option("timeout", "60")
.with_option("max_retries", "10")
.with_option("custom_option", "custom_value");
let provider = ObjectStoreProvider::new(config).await.unwrap();
let clean_options = provider.clean_options();
assert!(!clean_options.contains_key("timeout"));
assert!(!clean_options.contains_key("max_retries"));
assert!(!clean_options.contains_key("retry_timeout"));
assert!(!clean_options.contains_key("connect_timeout"));
assert!(clean_options.contains_key("path"));
assert!(clean_options.contains_key("custom_option"));
}
#[tokio::test]
async fn test_uri_from_path_local() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let uri = provider.uri_from_path("test/file.txt");
assert!(uri.starts_with("file://"));
assert!(uri.contains("test"));
assert!(uri.contains("file.txt"));
#[cfg(unix)]
{
assert!(
uri.starts_with("file:///"),
"Unix absolute path URI should start with file:/// but got: {}",
uri
);
}
assert!(
!uri.contains('\\'),
"URI should use forward slashes only, but got: {}",
uri
);
}
#[tokio::test]
async fn test_uri_from_path_with_base_path() {
use std::path::MAIN_SEPARATOR;
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let full_path = format!(
"{}{}test{}file.txt",
provider.base_path, MAIN_SEPARATOR, MAIN_SEPARATOR
);
let uri = provider.uri_from_path(&full_path);
assert!(
uri.starts_with("file://"),
"URI should start with file:// but got: {}",
uri
);
assert!(
!uri.contains('\\'),
"URI should use forward slashes only, but got: {}",
uri
);
#[cfg(unix)]
assert!(
uri.starts_with("file:///"),
"Unix absolute path URI should start with file:/// but got: {}",
uri
);
}
#[tokio::test]
async fn test_uri_from_path_with_leading_slash() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let uri = provider.uri_from_path("/test/file.txt");
assert!(uri.starts_with("file://"));
assert!(uri.contains("test"));
assert!(uri.contains("file.txt"));
assert!(
!uri.contains('\\'),
"URI should use forward slashes only, but got: {}",
uri
);
#[cfg(unix)]
assert!(
uri.starts_with("file:///"),
"Unix absolute path URI should start with file:/// but got: {}",
uri
);
}
#[tokio::test]
async fn test_uri_from_path_format_validation() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let test_cases = vec![
("relative/path.txt", "relative path"),
("/absolute/path.txt", "absolute path with leading slash"),
];
for (input, description) in test_cases {
let uri = provider.uri_from_path(input);
assert!(
uri.starts_with("file://"),
"{}: URI should start with file:// but got: {}",
description,
uri
);
let path_part = input.trim_start_matches('/');
assert!(
uri.contains(path_part),
"{}: URI should contain path '{}' but got: {}",
description,
path_part,
uri
);
assert!(
!uri.contains('\\'),
"{}: URI should use forward slashes only, but got: {}",
description,
uri
);
#[cfg(unix)]
{
assert!(
uri.starts_with("file:///"),
"{}: Unix URI should start with file:/// but got: {}",
description,
uri
);
let after_scheme = &uri[7..]; assert!(
!after_scheme.contains("//"),
"{}: URI should not have double slashes in path: {}",
description,
uri
);
}
}
}
#[test]
fn test_fix_uri_windows_extended_path_prefix() {
fn fix_uri_for_test(path: &str) -> String {
let path = path.replace('\\', "/");
let path = path.strip_prefix("//?/").unwrap_or(&path).to_string();
let path_without_scheme = if let Some(without_scheme) = path.strip_prefix("file:") {
without_scheme.trim_start_matches('/').to_string()
} else if path.starts_with('/') {
path.trim_start_matches('/').to_string()
} else {
path.to_string()
};
format!("file:///{}", path_without_scheme)
}
let windows_path = "\\\\?\\C:\\Users\\test\\data";
let uri = fix_uri_for_test(windows_path);
assert_eq!(uri, "file:///C:/Users/test/data");
assert!(!uri.contains("?"), "URI should not contain ? from prefix");
let windows_path_fwd = "//?/C:/Users/test/data";
let uri = fix_uri_for_test(windows_path_fwd);
assert_eq!(uri, "file:///C:/Users/test/data");
let normal_windows = "C:\\Users\\test\\data";
let uri = fix_uri_for_test(normal_windows);
assert_eq!(uri, "file:///C:/Users/test/data");
let unix_path = "/home/user/data";
let uri = fix_uri_for_test(unix_path);
assert_eq!(uri, "file:///home/user/data");
}
#[tokio::test]
async fn test_validate_connection_local_valid() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let sub_dir = temp_dir.path().join("subdir");
fs::create_dir(&sub_dir).unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let result = provider.validate_connection("subdir").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_validate_connection_local_invalid() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let result = provider.validate_connection("nonexistent").await;
assert!(result.is_err());
}
#[test]
fn test_debug_implementation() {
let config = StorageConfig::local()
.with_option("path", "/tmp")
.with_option("timeout", "60");
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("StorageConfig"));
}
#[tokio::test]
async fn test_read_file() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let file_path = temp_dir.path().join("test.txt");
let test_content = b"Hello, World!";
fs::write(&file_path, test_content).unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let content = provider.read_file("test.txt").await.unwrap();
assert_eq!(content, test_content);
}
#[tokio::test]
async fn test_read_file_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let result = provider.read_file("nonexistent.txt").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_exists_file() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let file_path = temp_dir.path().join("exists.txt");
fs::write(&file_path, "content").unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let exists = provider.exists("exists.txt").await.unwrap();
assert!(exists);
}
#[tokio::test]
async fn test_exists_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let exists = provider.exists("nonexistent.txt").await.unwrap();
assert!(!exists);
}
#[tokio::test]
async fn test_get_metadata() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let file_path = temp_dir.path().join("metadata.txt");
let test_content = b"Test content for metadata";
fs::write(&file_path, test_content).unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let metadata = provider.get_metadata("metadata.txt").await.unwrap();
assert_eq!(metadata.path, "metadata.txt");
assert_eq!(metadata.size, test_content.len() as u64);
assert!(metadata.last_modified.is_some());
}
#[tokio::test]
async fn test_list_files_non_recursive() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
let sub_dir = temp_dir.path().join("subdir");
fs::create_dir(&sub_dir).unwrap();
fs::write(sub_dir.join("file3.txt"), "content3").unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let files = provider.list_files("", false).await.unwrap();
assert_eq!(files.len(), 2);
let file_names: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
assert!(file_names.iter().any(|name| name.contains("file1.txt")));
assert!(file_names.iter().any(|name| name.contains("file2.txt")));
}
#[tokio::test]
async fn test_list_files_recursive() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
let sub_dir = temp_dir.path().join("subdir");
fs::create_dir(&sub_dir).unwrap();
fs::write(sub_dir.join("file2.txt"), "content2").unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let files = provider.list_files("", true).await.unwrap();
assert!(files.len() >= 2);
let file_names: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
assert!(file_names.iter().any(|name| name.contains("file1.txt")));
assert!(file_names.iter().any(|name| name.contains("file2.txt")));
}
#[tokio::test]
async fn test_discover_partitions() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
fs::create_dir(temp_dir.path().join("partition1")).unwrap();
fs::create_dir(temp_dir.path().join("partition2")).unwrap();
fs::create_dir(temp_dir.path().join("_metadata")).unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let partitions = provider
.discover_partitions("", vec!["_metadata"])
.await
.unwrap();
assert!(partitions.len() >= 2);
assert!(partitions.iter().any(|p| p.contains("partition1")));
assert!(partitions.iter().any(|p| p.contains("partition2")));
assert!(!partitions.iter().any(|p| p.contains("_metadata")));
}
#[tokio::test]
async fn test_provider_debug_format() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().to_str().unwrap();
let config = StorageConfig::local().with_option("path", temp_path);
let provider = ObjectStoreProvider::new(config).await.unwrap();
let debug_str = format!("{:?}", provider);
assert!(debug_str.contains("StorageProvider"));
assert!(debug_str.contains("local"));
}
#[tokio::test]
async fn test_hdfs_provider_missing_url() {
let config = StorageConfig::hdfs();
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_err());
match provider {
Err(StorageError::ConfigError(msg)) => {
assert!(
msg.contains("HDFS storage requires 'url' option"),
"Expected error about missing URL, got: {}",
msg
);
}
_ => panic!("Expected ConfigError for missing HDFS URL"),
}
}
#[tokio::test]
async fn test_hdfs_provider_invalid_url() {
let config = StorageConfig::hdfs().with_option("url", "not-a-valid-hdfs-url");
let provider = ObjectStoreProvider::new(config).await;
assert!(provider.is_err());
match provider {
Err(StorageError::ConfigError(msg)) => {
assert!(
msg.contains("Failed to create HDFS store"),
"Expected error about HDFS store creation failure, got: {}",
msg
);
}
_ => panic!("Expected ConfigError for invalid HDFS URL"),
}
}
#[tokio::test]
async fn test_hdfs_provider_unreachable_namenode() {
let config = StorageConfig::hdfs().with_option("url", "hdfs://nonexistent-namenode:8020");
let provider = ObjectStoreProvider::new(config).await;
match provider {
Ok(_) => {
}
Err(StorageError::ConfigError(msg)) => {
assert!(
msg.contains("Failed to create HDFS store"),
"Expected HDFS store creation error, got: {}",
msg
);
}
Err(e) => panic!("Unexpected error type: {:?}", e),
}
}
#[test]
fn test_build_hdfs_store_missing_url() {
let config = StorageConfig::hdfs();
let result = ObjectStoreProvider::build_hdfs_store(&config);
assert!(result.is_err());
match result {
Err(StorageError::ConfigError(msg)) => {
assert!(msg.contains("HDFS storage requires 'url' option"));
}
_ => panic!("Expected ConfigError for missing URL"),
}
}
#[test]
fn test_build_hdfs_store_invalid_url() {
let config = StorageConfig::hdfs().with_option("url", "invalid://not-hdfs");
let result = ObjectStoreProvider::build_hdfs_store(&config);
assert!(result.is_err());
match result {
Err(StorageError::ConfigError(msg)) => {
assert!(
msg.contains("Failed to create HDFS store"),
"Expected HDFS creation error, got: {}",
msg
);
}
_ => panic!("Expected ConfigError for invalid URL"),
}
}
#[test]
fn test_build_hdfs_store_empty_url() {
let config = StorageConfig::hdfs().with_option("url", "");
let result = ObjectStoreProvider::build_hdfs_store(&config);
assert!(result.is_err());
match result {
Err(StorageError::ConfigError(msg)) => {
assert!(
msg.contains("Failed to create HDFS store"),
"Expected HDFS creation error for empty URL, got: {}",
msg
);
}
_ => panic!("Expected ConfigError for empty URL"),
}
}
#[test]
fn test_build_http_store_missing_url() {
let config = StorageConfig::http();
let result = ObjectStoreProvider::build_http_store(&config);
assert!(result.is_err());
match result {
Err(StorageError::ConfigError(msg)) => {
assert!(msg.contains("HTTP storage requires 'url' option"));
}
_ => panic!("Expected ConfigError for missing URL"),
}
}
#[test]
fn test_build_http_store_valid_url() {
let config = StorageConfig::http().with_option("url", "https://example.com/files");
let result = ObjectStoreProvider::build_http_store(&config);
assert!(result.is_ok());
let (_, base_url) = result.unwrap();
assert_eq!(base_url, "https://example.com/files");
}
#[test]
fn test_build_http_store_webdav_url() {
let config = StorageConfig::http().with_option("url", "https://webdav.example.com/dav");
let result = ObjectStoreProvider::build_http_store(&config);
assert!(result.is_ok());
let (_, base_url) = result.unwrap();
assert_eq!(base_url, "https://webdav.example.com/dav");
}
}