mod local;
mod registry;
pub use local::{LocalStore, LocalStoreConfig};
pub use registry::{StorageConfig, StorageFactory, StorageRegistry, register_local};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use rskit_errors::AppResult;
use serde::{Deserialize, Serialize};
use crate::FileSource;
pub const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredFile {
pub key: String,
pub size: u64,
pub content_type: String,
pub stored_at: DateTime<Utc>,
pub metadata: HashMap<String, String>,
}
impl StoredFile {
#[must_use]
pub fn new(key: impl Into<String>, size: u64, content_type: Option<&str>) -> Self {
Self {
key: key.into(),
size,
content_type: content_type_or_default(content_type).to_string(),
stored_at: Utc::now(),
metadata: HashMap::new(),
}
}
#[must_use]
pub fn with_metadata(mut self, metadata: HashMap<String, String>) -> Self {
self.metadata = metadata;
self
}
#[must_use]
pub fn with_stored_at(mut self, stored_at: DateTime<Utc>) -> Self {
self.stored_at = stored_at;
self
}
}
#[must_use]
pub fn content_type_or_default(content_type: Option<&str>) -> &str {
content_type
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_CONTENT_TYPE)
}
#[must_use]
pub fn prefixed_key(prefix: Option<&str>, key: &str) -> String {
let key = key.trim_start_matches('/');
match prefix
.map(str::trim)
.map(|prefix| prefix.trim_matches('/'))
.filter(|prefix| !prefix.is_empty())
{
Some(prefix) if key.is_empty() => format!("{prefix}/"),
Some(prefix) => format!("{prefix}/{key}"),
None => key.to_string(),
}
}
pub struct UploadProgress {
pub bytes_sent: u64,
pub total_bytes: Option<u64>,
pub percent: Option<f32>,
}
pub type ProgressCallback = Arc<dyn Fn(UploadProgress) + Send + Sync>;
#[async_trait::async_trait]
pub trait FileStore: Send + Sync {
async fn upload(
&self,
source: &FileSource,
key: &str,
content_type: Option<&str>,
metadata: Option<HashMap<String, String>>,
) -> AppResult<StoredFile>;
async fn upload_with_progress(
&self,
source: &FileSource,
key: &str,
content_type: Option<&str>,
on_progress: ProgressCallback,
) -> AppResult<StoredFile>;
async fn download(&self, key: &str) -> AppResult<FileSource>;
async fn delete(&self, key: &str) -> AppResult<()>;
async fn exists(&self, key: &str) -> AppResult<bool>;
async fn head(&self, key: &str) -> AppResult<StoredFile>;
async fn list(&self, prefix: &str, limit: Option<usize>) -> AppResult<Vec<StoredFile>>;
async fn presigned_url(&self, key: &str, expires_in: Duration) -> AppResult<String>;
async fn copy(&self, from_key: &str, to_key: &str) -> AppResult<StoredFile>;
async fn rename(&self, from_key: &str, to_key: &str) -> AppResult<StoredFile>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_type_defaults_for_missing_or_empty_values() {
assert_eq!(content_type_or_default(None), DEFAULT_CONTENT_TYPE);
assert_eq!(content_type_or_default(Some(" ")), DEFAULT_CONTENT_TYPE);
assert_eq!(content_type_or_default(Some("text/plain")), "text/plain");
assert_eq!(content_type_or_default(Some(" text/plain ")), "text/plain");
}
#[test]
fn prefixed_key_normalizes_separator_boundaries() {
assert_eq!(prefixed_key(None, "/file.txt"), "file.txt");
assert_eq!(
prefixed_key(Some("uploads/"), "/file.txt"),
"uploads/file.txt"
);
assert_eq!(
prefixed_key(Some(" /uploads/ "), "file.txt"),
"uploads/file.txt"
);
assert_eq!(prefixed_key(Some("/"), "file.txt"), "file.txt");
assert_eq!(prefixed_key(Some("///"), "/file.txt"), "file.txt");
assert_eq!(prefixed_key(Some("uploads"), ""), "uploads/");
}
#[test]
fn stored_file_constructor_applies_defaults() {
let stored = StoredFile::new("key", 42, None);
assert_eq!(stored.key, "key");
assert_eq!(stored.size, 42);
assert_eq!(stored.content_type, DEFAULT_CONTENT_TYPE);
assert!(stored.metadata.is_empty());
}
}