use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RetentionPolicy {
Transient,
Temporary,
Persistent,
}
impl std::fmt::Display for RetentionPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RetentionPolicy::Transient => write!(f, "transient"),
RetentionPolicy::Temporary => write!(f, "temporary"),
RetentionPolicy::Persistent => write!(f, "persistent"),
}
}
}
impl RetentionPolicy {
pub fn all() -> Vec<Self> {
vec![Self::Transient, Self::Temporary, Self::Persistent]
}
}
impl FromStr for RetentionPolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"transient" => Ok(Self::Transient),
"temporary" => Ok(Self::Temporary),
"persistent" => Ok(Self::Persistent),
_ => Err("Invalid retention policy".to_string()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Region {
US,
#[allow(clippy::upper_case_acronyms)]
EMEA,
}
impl std::fmt::Display for Region {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Region::US => write!(f, "US"),
Region::EMEA => write!(f, "EMEA"),
}
}
}
impl Region {
pub fn all() -> Vec<Self> {
vec![Self::US, Self::EMEA]
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBucketRequest {
pub bucket_key: String,
pub policy_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bucket {
pub bucket_key: String,
pub bucket_owner: String,
pub created_date: u64,
pub permissions: Vec<Permission>,
pub policy_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Permission {
pub auth_id: String,
pub access: String,
}
#[derive(Debug, Deserialize)]
pub struct BucketsResponse {
pub items: Vec<BucketItem>,
pub next: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BucketItem {
pub bucket_key: String,
pub created_date: u64,
pub policy_key: String,
#[serde(skip)]
pub region: Option<String>,
}
#[derive(Debug)]
pub struct RegionResult {
pub region: Region,
pub buckets: anyhow::Result<Vec<BucketItem>>,
pub elapsed: std::time::Duration,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedS3DownloadResponse {
pub url: Option<String>,
pub urls: Option<Vec<String>>,
pub size: Option<u64>,
pub sha1: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignedS3UploadResponse {
pub upload_key: String,
pub urls: Vec<String>,
pub upload_expiration: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MultipartUploadState {
pub bucket_key: String,
pub object_key: String,
pub file_path: String,
pub file_size: u64,
pub chunk_size: u64,
pub total_parts: u32,
pub completed_parts: Vec<u32>,
pub part_etags: std::collections::HashMap<u32, String>,
pub upload_key: String,
pub started_at: i64,
pub file_mtime: i64,
}
impl MultipartUploadState {
pub const DEFAULT_CHUNK_SIZE: u64 = 5 * 1024 * 1024;
pub const MAX_CHUNK_SIZE: u64 = 100 * 1024 * 1024;
pub const MULTIPART_THRESHOLD: u64 = 5 * 1024 * 1024;
pub fn state_file_path(bucket_key: &str, object_key: &str) -> Result<PathBuf> {
let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
.context("Failed to get project directories")?;
let cache_dir = proj_dirs.cache_dir();
std::fs::create_dir_all(cache_dir)?;
let safe_name = format!("{}_{}", bucket_key, object_key)
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
Ok(cache_dir.join(format!("upload_{}.json", safe_name)))
}
pub fn save(&self) -> Result<()> {
let path = Self::state_file_path(&self.bucket_key, &self.object_key)?;
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn load(bucket_key: &str, object_key: &str) -> Result<Option<Self>> {
let path = Self::state_file_path(bucket_key, object_key)?;
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)?;
let state: Self = serde_json::from_str(&json)?;
Ok(Some(state))
}
pub fn delete(bucket_key: &str, object_key: &str) -> Result<()> {
let path = Self::state_file_path(bucket_key, object_key)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn can_resume(&self, file_path: &Path) -> bool {
if let Ok(metadata) = std::fs::metadata(file_path) {
let current_size = metadata.len();
let current_mtime = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
current_size == self.file_size && current_mtime == self.file_mtime
} else {
false
}
}
pub fn remaining_parts(&self) -> Vec<u32> {
(1..=self.total_parts)
.filter(|p| !self.completed_parts.contains(p))
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectInfo {
pub bucket_key: String,
pub object_key: String,
pub object_id: String,
#[serde(default)]
pub sha1: Option<String>,
pub size: u64,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub content_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectDetails {
pub bucket_key: String,
pub object_key: String,
pub object_id: String,
pub sha1: String,
pub size: u64,
pub content_type: String,
#[serde(default)]
pub content_disposition: Option<String>,
#[serde(alias = "createdDate")]
pub created_date: Option<String>,
#[serde(alias = "lastModifiedDate")]
pub last_modified_date: Option<String>,
#[serde(default)]
pub location: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ObjectsResponse {
pub items: Vec<ObjectItem>,
pub next: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectItem {
pub bucket_key: String,
pub object_key: String,
pub object_id: String,
#[serde(default)]
pub sha1: Option<String>,
pub size: u64,
}
#[derive(Debug, Serialize)]
pub struct BatchResult<T: std::fmt::Debug> {
pub total: usize,
pub succeeded: usize,
pub failed: usize,
pub results: Vec<BatchItemResult<T>>,
}
#[derive(Debug, Serialize)]
pub struct BatchItemResult<T: std::fmt::Debug> {
pub key: String,
#[serde(skip)]
pub result: std::result::Result<T, String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multipart_upload_state_constants() {
assert_eq!(MultipartUploadState::DEFAULT_CHUNK_SIZE, 5 * 1024 * 1024);
assert_eq!(MultipartUploadState::MAX_CHUNK_SIZE, 100 * 1024 * 1024);
assert_eq!(MultipartUploadState::MULTIPART_THRESHOLD, 5 * 1024 * 1024);
}
#[test]
fn test_multipart_upload_state_remaining_parts() {
let state = MultipartUploadState {
bucket_key: "test-bucket".to_string(),
object_key: "test-object".to_string(),
file_path: "/tmp/test.bin".to_string(),
file_size: 20 * 1024 * 1024,
chunk_size: 5 * 1024 * 1024,
total_parts: 4,
completed_parts: vec![1, 3],
part_etags: std::collections::HashMap::new(),
upload_key: "test-key".to_string(),
started_at: 0,
file_mtime: 0,
};
let remaining = state.remaining_parts();
assert_eq!(remaining, vec![2, 4]);
}
#[test]
fn test_retention_policy_display() {
assert_eq!(RetentionPolicy::Transient.to_string(), "transient");
assert_eq!(RetentionPolicy::Temporary.to_string(), "temporary");
assert_eq!(RetentionPolicy::Persistent.to_string(), "persistent");
}
#[test]
fn test_retention_policy_from_str() {
assert_eq!(
RetentionPolicy::from_str("transient"),
Ok(RetentionPolicy::Transient)
);
assert_eq!(
RetentionPolicy::from_str("TRANSIENT"),
Ok(RetentionPolicy::Transient)
);
assert!(RetentionPolicy::from_str("invalid").is_err());
}
#[test]
fn test_region_display() {
assert_eq!(Region::US.to_string(), "US");
assert_eq!(Region::EMEA.to_string(), "EMEA");
}
#[test]
fn test_region_all() {
let regions = Region::all();
assert_eq!(regions.len(), 2);
assert!(regions.contains(&Region::US));
assert!(regions.contains(&Region::EMEA));
}
#[test]
fn test_retention_policy_all() {
let policies = RetentionPolicy::all();
assert_eq!(policies.len(), 3);
assert!(policies.contains(&RetentionPolicy::Transient));
assert!(policies.contains(&RetentionPolicy::Temporary));
assert!(policies.contains(&RetentionPolicy::Persistent));
}
#[test]
fn test_retention_policy_temporary() {
assert_eq!(
RetentionPolicy::from_str("temporary"),
Ok(RetentionPolicy::Temporary)
);
assert_eq!(
RetentionPolicy::from_str("TEMPORARY"),
Ok(RetentionPolicy::Temporary)
);
}
#[test]
fn test_retention_policy_persistent() {
assert_eq!(
RetentionPolicy::from_str("persistent"),
Ok(RetentionPolicy::Persistent)
);
assert_eq!(
RetentionPolicy::from_str("PERSISTENT"),
Ok(RetentionPolicy::Persistent)
);
}
#[test]
fn test_multipart_upload_state_chunk_calculation() {
let file_size: u64 = 12 * 1024 * 1024;
let chunk_size = MultipartUploadState::DEFAULT_CHUNK_SIZE;
let total_parts = file_size.div_ceil(chunk_size);
assert_eq!(total_parts, 3);
}
#[test]
fn test_multipart_upload_state_all_parts_remaining() {
let state = MultipartUploadState {
bucket_key: "test-bucket".to_string(),
object_key: "test-object".to_string(),
file_path: "/tmp/test.bin".to_string(),
file_size: 15 * 1024 * 1024,
chunk_size: 5 * 1024 * 1024,
total_parts: 3,
completed_parts: vec![], part_etags: std::collections::HashMap::new(),
upload_key: "test-key".to_string(),
started_at: 0,
file_mtime: 0,
};
let remaining = state.remaining_parts();
assert_eq!(remaining, vec![1, 2, 3]);
}
#[test]
fn test_multipart_upload_state_no_parts_remaining() {
let state = MultipartUploadState {
bucket_key: "test-bucket".to_string(),
object_key: "test-object".to_string(),
file_path: "/tmp/test.bin".to_string(),
file_size: 15 * 1024 * 1024,
chunk_size: 5 * 1024 * 1024,
total_parts: 3,
completed_parts: vec![1, 2, 3], part_etags: std::collections::HashMap::new(),
upload_key: "test-key".to_string(),
started_at: 0,
file_mtime: 0,
};
let remaining = state.remaining_parts();
assert!(remaining.is_empty());
}
#[test]
fn test_create_bucket_request_serialization() {
let request = CreateBucketRequest {
bucket_key: "test-bucket".to_string(),
policy_key: "transient".to_string(),
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["bucketKey"], "test-bucket");
assert_eq!(json["policyKey"], "transient");
}
#[test]
fn test_bucket_deserialization() {
let json = r#"{
"bucketKey": "test-bucket",
"bucketOwner": "test-owner",
"createdDate": 1609459200000,
"permissions": [{"authId": "test-auth", "access": "full"}],
"policyKey": "transient"
}"#;
let bucket: Bucket = serde_json::from_str(json).unwrap();
assert_eq!(bucket.bucket_key, "test-bucket");
assert_eq!(bucket.bucket_owner, "test-owner");
assert_eq!(bucket.policy_key, "transient");
assert_eq!(bucket.permissions.len(), 1);
}
#[test]
fn test_buckets_response_deserialization() {
let json = r#"{
"items": [
{"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"},
{"bucketKey": "bucket2", "createdDate": 1609459200000, "policyKey": "persistent"}
],
"next": "bucket3"
}"#;
let response: BucketsResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.items.len(), 2);
assert_eq!(response.items[0].bucket_key, "bucket1");
assert_eq!(response.next, Some("bucket3".to_string()));
}
#[test]
fn test_buckets_response_no_next() {
let json = r#"{
"items": [
{"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"}
]
}"#;
let response: BucketsResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.items.len(), 1);
assert!(response.next.is_none());
}
#[test]
fn test_object_info_deserialization() {
let json = r#"{
"bucketKey": "test-bucket",
"objectKey": "test-object.dwg",
"objectId": "urn:adsk.objects:os.object:test-bucket/test-object.dwg",
"sha1": "abc123",
"size": 1024,
"location": "https://example.com/object"
}"#;
let object: ObjectInfo = serde_json::from_str(json).unwrap();
assert_eq!(object.bucket_key, "test-bucket");
assert_eq!(object.object_key, "test-object.dwg");
assert_eq!(object.size, 1024);
}
#[test]
fn test_objects_response_deserialization() {
let json = r#"{
"items": [
{"bucketKey": "bucket", "objectKey": "file1.dwg", "objectId": "urn:1", "size": 100},
{"bucketKey": "bucket", "objectKey": "file2.rvt", "objectId": "urn:2", "size": 200}
],
"next": "file3.dwg"
}"#;
let response: ObjectsResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.items.len(), 2);
assert_eq!(response.items[0].object_key, "file1.dwg");
assert_eq!(response.items[1].size, 200);
}
#[test]
fn test_signed_s3_download_response_deserialization() {
let json = r#"{
"url": "https://s3.amazonaws.com/signed-url",
"size": 1048576,
"sha1": "abc123"
}"#;
let response: SignedS3DownloadResponse = serde_json::from_str(json).unwrap();
assert_eq!(
response.url,
Some("https://s3.amazonaws.com/signed-url".to_string())
);
assert_eq!(response.size, Some(1048576));
}
#[test]
fn test_signed_s3_upload_response_deserialization() {
let json = r#"{
"uploadKey": "upload-key-123",
"urls": ["https://s3.amazonaws.com/part1", "https://s3.amazonaws.com/part2"],
"uploadExpiration": "2024-01-15T12:00:00Z"
}"#;
let response: SignedS3UploadResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.upload_key, "upload-key-123");
assert_eq!(response.urls.len(), 2);
}
#[test]
fn test_retention_policy_serialization() {
let policy = RetentionPolicy::Persistent;
let json = serde_json::to_value(policy).unwrap();
assert_eq!(json, "persistent");
}
#[test]
fn test_region_serialization() {
let region = Region::EMEA;
let json = serde_json::to_value(region).unwrap();
assert_eq!(json, "EMEA");
}
#[test]
fn test_batch_result_summary() {
let result: BatchResult<ObjectDetails> = BatchResult {
total: 3,
succeeded: 2,
failed: 1,
results: vec![],
};
assert_eq!(result.total, 3);
assert_eq!(result.succeeded, 2);
assert_eq!(result.failed, 1);
}
#[test]
fn test_batch_item_result_success_and_failure() {
let success: BatchItemResult<String> = BatchItemResult {
key: "file.txt".to_string(),
result: Ok("done".to_string()),
};
assert!(success.result.is_ok());
let failure: BatchItemResult<String> = BatchItemResult {
key: "missing.txt".to_string(),
result: Err("not found".to_string()),
};
assert!(failure.result.is_err());
assert_eq!(failure.result.unwrap_err(), "not found");
}
}