1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub enum RetentionPolicy {
15 Transient,
17 Temporary,
19 Persistent,
21}
22
23impl std::fmt::Display for RetentionPolicy {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 RetentionPolicy::Transient => write!(f, "transient"),
27 RetentionPolicy::Temporary => write!(f, "temporary"),
28 RetentionPolicy::Persistent => write!(f, "persistent"),
29 }
30 }
31}
32
33impl RetentionPolicy {
34 pub fn all() -> Vec<Self> {
35 vec![Self::Transient, Self::Temporary, Self::Persistent]
36 }
37}
38
39impl FromStr for RetentionPolicy {
40 type Err = String;
41
42 fn from_str(s: &str) -> Result<Self, Self::Err> {
43 match s.to_lowercase().as_str() {
44 "transient" => Ok(Self::Transient),
45 "temporary" => Ok(Self::Temporary),
46 "persistent" => Ok(Self::Persistent),
47 _ => Err("Invalid retention policy".to_string()),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum Region {
55 US,
56 #[allow(clippy::upper_case_acronyms)]
57 EMEA,
58}
59
60impl std::fmt::Display for Region {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Region::US => write!(f, "US"),
64 Region::EMEA => write!(f, "EMEA"),
65 }
66 }
67}
68
69impl Region {
70 pub fn all() -> Vec<Self> {
71 vec![Self::US, Self::EMEA]
72 }
73}
74
75#[derive(Debug, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct CreateBucketRequest {
79 pub bucket_key: String,
80 pub policy_key: String,
81}
82
83#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct Bucket {
87 pub bucket_key: String,
88 pub bucket_owner: String,
89 pub created_date: u64,
90 pub permissions: Vec<Permission>,
91 pub policy_key: String,
92}
93
94#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Permission {
98 pub auth_id: String,
99 pub access: String,
100}
101
102#[derive(Debug, Deserialize)]
104pub struct BucketsResponse {
105 pub items: Vec<BucketItem>,
106 pub next: Option<String>,
107}
108
109#[derive(Debug, Clone, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct BucketItem {
113 pub bucket_key: String,
114 pub created_date: u64,
115 pub policy_key: String,
116 #[serde(skip)]
118 pub region: Option<String>,
119}
120
121#[derive(Debug)]
123pub struct RegionResult {
124 pub region: Region,
125 pub buckets: anyhow::Result<Vec<BucketItem>>,
126 pub elapsed: std::time::Duration,
127}
128
129#[derive(Debug, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SignedS3DownloadResponse {
133 pub url: Option<String>,
135 pub urls: Option<Vec<String>>,
137 pub size: Option<u64>,
139 pub sha1: Option<String>,
141 pub status: Option<String>,
143}
144
145#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub struct SignedS3UploadResponse {
149 pub upload_key: String,
151 pub urls: Vec<String>,
153 pub upload_expiration: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MultipartUploadState {
160 pub bucket_key: String,
162 pub object_key: String,
164 pub file_path: String,
166 pub file_size: u64,
168 pub chunk_size: u64,
170 pub total_parts: u32,
172 pub completed_parts: Vec<u32>,
174 pub part_etags: std::collections::HashMap<u32, String>,
176 pub upload_key: String,
178 pub started_at: i64,
180 pub file_mtime: i64,
182}
183
184impl MultipartUploadState {
185 pub const DEFAULT_CHUNK_SIZE: u64 = 5 * 1024 * 1024;
187 pub const MAX_CHUNK_SIZE: u64 = 100 * 1024 * 1024;
189 pub const MULTIPART_THRESHOLD: u64 = 5 * 1024 * 1024;
191
192 pub fn state_file_path(bucket_key: &str, object_key: &str) -> Result<PathBuf> {
194 let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
195 .context("Failed to get project directories")?;
196 let cache_dir = proj_dirs.cache_dir();
197 std::fs::create_dir_all(cache_dir)?;
198
199 let safe_name = format!("{}_{}", bucket_key, object_key)
201 .chars()
202 .map(|c| {
203 if c.is_alphanumeric() || c == '-' || c == '_' {
204 c
205 } else {
206 '_'
207 }
208 })
209 .collect::<String>();
210
211 Ok(cache_dir.join(format!("upload_{}.json", safe_name)))
212 }
213
214 pub fn save(&self) -> Result<()> {
216 let path = Self::state_file_path(&self.bucket_key, &self.object_key)?;
217 let json = serde_json::to_string_pretty(self)?;
218 std::fs::write(&path, json)?;
219 Ok(())
220 }
221
222 pub fn load(bucket_key: &str, object_key: &str) -> Result<Option<Self>> {
224 let path = Self::state_file_path(bucket_key, object_key)?;
225 if !path.exists() {
226 return Ok(None);
227 }
228 let json = std::fs::read_to_string(&path)?;
229 let state: Self = serde_json::from_str(&json)?;
230 Ok(Some(state))
231 }
232
233 pub fn delete(bucket_key: &str, object_key: &str) -> Result<()> {
235 let path = Self::state_file_path(bucket_key, object_key)?;
236 if path.exists() {
237 std::fs::remove_file(&path)?;
238 }
239 Ok(())
240 }
241
242 pub fn can_resume(&self, file_path: &Path) -> bool {
244 if let Ok(metadata) = std::fs::metadata(file_path) {
245 let current_size = metadata.len();
246 let current_mtime = metadata
247 .modified()
248 .ok()
249 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
250 .map(|d| d.as_secs() as i64)
251 .unwrap_or(0);
252
253 current_size == self.file_size && current_mtime == self.file_mtime
254 } else {
255 false
256 }
257 }
258
259 pub fn remaining_parts(&self) -> Vec<u32> {
261 (1..=self.total_parts)
262 .filter(|p| !self.completed_parts.contains(p))
263 .collect()
264 }
265}
266
267#[derive(Debug, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct ObjectInfo {
271 pub bucket_key: String,
272 pub object_key: String,
273 pub object_id: String,
274 #[serde(default)]
275 pub sha1: Option<String>,
276 pub size: u64,
277 #[serde(default)]
278 pub location: Option<String>,
279 #[serde(default)]
281 pub content_type: Option<String>,
282}
283
284#[derive(Debug, Clone, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct ObjectDetails {
288 pub bucket_key: String,
290 pub object_key: String,
292 pub object_id: String,
294 pub sha1: String,
296 pub size: u64,
298 pub content_type: String,
300 #[serde(default)]
302 pub content_disposition: Option<String>,
303 #[serde(alias = "createdDate")]
305 pub created_date: Option<String>,
306 #[serde(alias = "lastModifiedDate")]
308 pub last_modified_date: Option<String>,
309 #[serde(default)]
311 pub location: Option<String>,
312}
313
314#[derive(Debug, Deserialize)]
316pub struct ObjectsResponse {
317 pub items: Vec<ObjectItem>,
318 pub next: Option<String>,
319}
320
321#[derive(Debug, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct ObjectItem {
325 pub bucket_key: String,
326 pub object_key: String,
327 pub object_id: String,
328 #[serde(default)]
329 pub sha1: Option<String>,
330 pub size: u64,
331}
332
333#[derive(Debug, Serialize)]
337pub struct BatchResult<T: std::fmt::Debug> {
338 pub total: usize,
339 pub succeeded: usize,
340 pub failed: usize,
341 pub results: Vec<BatchItemResult<T>>,
342}
343
344#[derive(Debug, Serialize)]
346pub struct BatchItemResult<T: std::fmt::Debug> {
347 pub key: String,
348 #[serde(skip)]
349 pub result: std::result::Result<T, String>,
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_multipart_upload_state_constants() {
358 assert_eq!(MultipartUploadState::DEFAULT_CHUNK_SIZE, 5 * 1024 * 1024);
359 assert_eq!(MultipartUploadState::MAX_CHUNK_SIZE, 100 * 1024 * 1024);
360 assert_eq!(MultipartUploadState::MULTIPART_THRESHOLD, 5 * 1024 * 1024);
361 }
362
363 #[test]
364 fn test_multipart_upload_state_remaining_parts() {
365 let state = MultipartUploadState {
366 bucket_key: "test-bucket".to_string(),
367 object_key: "test-object".to_string(),
368 file_path: "/tmp/test.bin".to_string(),
369 file_size: 20 * 1024 * 1024,
370 chunk_size: 5 * 1024 * 1024,
371 total_parts: 4,
372 completed_parts: vec![1, 3],
373 part_etags: std::collections::HashMap::new(),
374 upload_key: "test-key".to_string(),
375 started_at: 0,
376 file_mtime: 0,
377 };
378
379 let remaining = state.remaining_parts();
380 assert_eq!(remaining, vec![2, 4]);
381 }
382
383 #[test]
384 fn test_retention_policy_display() {
385 assert_eq!(RetentionPolicy::Transient.to_string(), "transient");
386 assert_eq!(RetentionPolicy::Temporary.to_string(), "temporary");
387 assert_eq!(RetentionPolicy::Persistent.to_string(), "persistent");
388 }
389
390 #[test]
391 fn test_retention_policy_from_str() {
392 assert_eq!(
393 RetentionPolicy::from_str("transient"),
394 Ok(RetentionPolicy::Transient)
395 );
396 assert_eq!(
397 RetentionPolicy::from_str("TRANSIENT"),
398 Ok(RetentionPolicy::Transient)
399 );
400 assert!(RetentionPolicy::from_str("invalid").is_err());
401 }
402
403 #[test]
404 fn test_region_display() {
405 assert_eq!(Region::US.to_string(), "US");
406 assert_eq!(Region::EMEA.to_string(), "EMEA");
407 }
408
409 #[test]
410 fn test_region_all() {
411 let regions = Region::all();
412 assert_eq!(regions.len(), 2);
413 assert!(regions.contains(&Region::US));
414 assert!(regions.contains(&Region::EMEA));
415 }
416
417 #[test]
418 fn test_retention_policy_all() {
419 let policies = RetentionPolicy::all();
420 assert_eq!(policies.len(), 3);
421 assert!(policies.contains(&RetentionPolicy::Transient));
422 assert!(policies.contains(&RetentionPolicy::Temporary));
423 assert!(policies.contains(&RetentionPolicy::Persistent));
424 }
425
426 #[test]
427 fn test_retention_policy_temporary() {
428 assert_eq!(
429 RetentionPolicy::from_str("temporary"),
430 Ok(RetentionPolicy::Temporary)
431 );
432 assert_eq!(
433 RetentionPolicy::from_str("TEMPORARY"),
434 Ok(RetentionPolicy::Temporary)
435 );
436 }
437
438 #[test]
439 fn test_retention_policy_persistent() {
440 assert_eq!(
441 RetentionPolicy::from_str("persistent"),
442 Ok(RetentionPolicy::Persistent)
443 );
444 assert_eq!(
445 RetentionPolicy::from_str("PERSISTENT"),
446 Ok(RetentionPolicy::Persistent)
447 );
448 }
449
450 #[test]
451 fn test_multipart_upload_state_chunk_calculation() {
452 let file_size: u64 = 12 * 1024 * 1024;
454 let chunk_size = MultipartUploadState::DEFAULT_CHUNK_SIZE;
455 let total_parts = file_size.div_ceil(chunk_size);
456 assert_eq!(total_parts, 3);
457 }
458
459 #[test]
460 fn test_multipart_upload_state_all_parts_remaining() {
461 let state = MultipartUploadState {
462 bucket_key: "test-bucket".to_string(),
463 object_key: "test-object".to_string(),
464 file_path: "/tmp/test.bin".to_string(),
465 file_size: 15 * 1024 * 1024,
466 chunk_size: 5 * 1024 * 1024,
467 total_parts: 3,
468 completed_parts: vec![], part_etags: std::collections::HashMap::new(),
470 upload_key: "test-key".to_string(),
471 started_at: 0,
472 file_mtime: 0,
473 };
474
475 let remaining = state.remaining_parts();
476 assert_eq!(remaining, vec![1, 2, 3]);
477 }
478
479 #[test]
480 fn test_multipart_upload_state_no_parts_remaining() {
481 let state = MultipartUploadState {
482 bucket_key: "test-bucket".to_string(),
483 object_key: "test-object".to_string(),
484 file_path: "/tmp/test.bin".to_string(),
485 file_size: 15 * 1024 * 1024,
486 chunk_size: 5 * 1024 * 1024,
487 total_parts: 3,
488 completed_parts: vec![1, 2, 3], part_etags: std::collections::HashMap::new(),
490 upload_key: "test-key".to_string(),
491 started_at: 0,
492 file_mtime: 0,
493 };
494
495 let remaining = state.remaining_parts();
496 assert!(remaining.is_empty());
497 }
498
499 #[test]
500 fn test_create_bucket_request_serialization() {
501 let request = CreateBucketRequest {
502 bucket_key: "test-bucket".to_string(),
503 policy_key: "transient".to_string(),
504 };
505
506 let json = serde_json::to_value(&request).unwrap();
507 assert_eq!(json["bucketKey"], "test-bucket");
508 assert_eq!(json["policyKey"], "transient");
509 }
510
511 #[test]
512 fn test_bucket_deserialization() {
513 let json = r#"{
514 "bucketKey": "test-bucket",
515 "bucketOwner": "test-owner",
516 "createdDate": 1609459200000,
517 "permissions": [{"authId": "test-auth", "access": "full"}],
518 "policyKey": "transient"
519 }"#;
520
521 let bucket: Bucket = serde_json::from_str(json).unwrap();
522 assert_eq!(bucket.bucket_key, "test-bucket");
523 assert_eq!(bucket.bucket_owner, "test-owner");
524 assert_eq!(bucket.policy_key, "transient");
525 assert_eq!(bucket.permissions.len(), 1);
526 }
527
528 #[test]
529 fn test_buckets_response_deserialization() {
530 let json = r#"{
531 "items": [
532 {"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"},
533 {"bucketKey": "bucket2", "createdDate": 1609459200000, "policyKey": "persistent"}
534 ],
535 "next": "bucket3"
536 }"#;
537
538 let response: BucketsResponse = serde_json::from_str(json).unwrap();
539 assert_eq!(response.items.len(), 2);
540 assert_eq!(response.items[0].bucket_key, "bucket1");
541 assert_eq!(response.next, Some("bucket3".to_string()));
542 }
543
544 #[test]
545 fn test_buckets_response_no_next() {
546 let json = r#"{
547 "items": [
548 {"bucketKey": "bucket1", "createdDate": 1609459200000, "policyKey": "transient"}
549 ]
550 }"#;
551
552 let response: BucketsResponse = serde_json::from_str(json).unwrap();
553 assert_eq!(response.items.len(), 1);
554 assert!(response.next.is_none());
555 }
556
557 #[test]
558 fn test_object_info_deserialization() {
559 let json = r#"{
560 "bucketKey": "test-bucket",
561 "objectKey": "test-object.dwg",
562 "objectId": "urn:adsk.objects:os.object:test-bucket/test-object.dwg",
563 "sha1": "abc123",
564 "size": 1024,
565 "location": "https://example.com/object"
566 }"#;
567
568 let object: ObjectInfo = serde_json::from_str(json).unwrap();
569 assert_eq!(object.bucket_key, "test-bucket");
570 assert_eq!(object.object_key, "test-object.dwg");
571 assert_eq!(object.size, 1024);
572 }
573
574 #[test]
575 fn test_objects_response_deserialization() {
576 let json = r#"{
577 "items": [
578 {"bucketKey": "bucket", "objectKey": "file1.dwg", "objectId": "urn:1", "size": 100},
579 {"bucketKey": "bucket", "objectKey": "file2.rvt", "objectId": "urn:2", "size": 200}
580 ],
581 "next": "file3.dwg"
582 }"#;
583
584 let response: ObjectsResponse = serde_json::from_str(json).unwrap();
585 assert_eq!(response.items.len(), 2);
586 assert_eq!(response.items[0].object_key, "file1.dwg");
587 assert_eq!(response.items[1].size, 200);
588 }
589
590 #[test]
591 fn test_signed_s3_download_response_deserialization() {
592 let json = r#"{
593 "url": "https://s3.amazonaws.com/signed-url",
594 "size": 1048576,
595 "sha1": "abc123"
596 }"#;
597
598 let response: SignedS3DownloadResponse = serde_json::from_str(json).unwrap();
599 assert_eq!(
600 response.url,
601 Some("https://s3.amazonaws.com/signed-url".to_string())
602 );
603 assert_eq!(response.size, Some(1048576));
604 }
605
606 #[test]
607 fn test_signed_s3_upload_response_deserialization() {
608 let json = r#"{
609 "uploadKey": "upload-key-123",
610 "urls": ["https://s3.amazonaws.com/part1", "https://s3.amazonaws.com/part2"],
611 "uploadExpiration": "2024-01-15T12:00:00Z"
612 }"#;
613
614 let response: SignedS3UploadResponse = serde_json::from_str(json).unwrap();
615 assert_eq!(response.upload_key, "upload-key-123");
616 assert_eq!(response.urls.len(), 2);
617 }
618
619 #[test]
620 fn test_retention_policy_serialization() {
621 let policy = RetentionPolicy::Persistent;
622 let json = serde_json::to_value(policy).unwrap();
623 assert_eq!(json, "persistent");
624 }
625
626 #[test]
627 fn test_region_serialization() {
628 let region = Region::EMEA;
629 let json = serde_json::to_value(region).unwrap();
630 assert_eq!(json, "EMEA");
631 }
632
633 #[test]
634 fn test_batch_result_summary() {
635 let result: BatchResult<ObjectDetails> = BatchResult {
636 total: 3,
637 succeeded: 2,
638 failed: 1,
639 results: vec![],
640 };
641 assert_eq!(result.total, 3);
642 assert_eq!(result.succeeded, 2);
643 assert_eq!(result.failed, 1);
644 }
645
646 #[test]
647 fn test_batch_item_result_success_and_failure() {
648 let success: BatchItemResult<String> = BatchItemResult {
649 key: "file.txt".to_string(),
650 result: Ok("done".to_string()),
651 };
652 assert!(success.result.is_ok());
653
654 let failure: BatchItemResult<String> = BatchItemResult {
655 key: "missing.txt".to_string(),
656 result: Err("not found".to_string()),
657 };
658 assert!(failure.result.is_err());
659 assert_eq!(failure.result.unwrap_err(), "not found");
660 }
661}