1use anyhow::{Context, Result};
16use aws_sdk_s3::config::{Credentials, Region};
17use aws_sdk_s3::primitives::ByteStream;
18use aws_sdk_s3::{Client, Config as S3Config};
19use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
20use serde::{Deserialize, Serialize};
21use std::path::Path;
22use tokio::io::AsyncWriteExt;
23use tracing::{debug, info};
24
25use crate::config::Config;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct BucketInfo {
30 pub name: String,
31 pub creation_date: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ObjectInfo {
37 pub key: String,
38 pub size: Option<i64>,
39 pub last_modified: Option<String>,
40 pub etag: Option<String>,
41 pub storage_class: Option<String>,
42}
43
44#[derive(Debug, Clone, Default)]
46pub struct ListObjectsOptions {
47 pub prefix: Option<String>,
48 pub delimiter: Option<String>,
49 pub max_keys: Option<i32>,
50 pub continuation_token: Option<String>,
51 pub start_after: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ListObjectsResult {
57 pub objects: Vec<ObjectInfo>,
58 pub common_prefixes: Vec<String>,
59 pub is_truncated: bool,
60 pub next_continuation_token: Option<String>,
61 pub max_keys: Option<i32>,
62 pub key_count: i32,
63}
64
65#[derive(Debug, Clone, Default)]
67pub struct UploadFileOptions {
68 pub content_type: Option<String>,
69 pub metadata: Option<std::collections::HashMap<String, String>>,
70 pub storage_class: Option<String>,
71 pub server_side_encryption: Option<String>,
72 pub cache_control: Option<String>,
73 pub content_disposition: Option<String>,
74 pub content_encoding: Option<String>,
75 pub content_language: Option<String>,
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct GetObjectOptions {
81 pub version_id: Option<String>,
82 pub range: Option<String>,
83 pub if_modified_since: Option<String>,
84 pub if_unmodified_since: Option<String>,
85 pub max_content_size: Option<usize>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub enum DetectedFileType {
91 Text,
92 NonText(String), }
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct GetObjectResult {
98 pub bucket: String,
99 pub key: String,
100 pub content_type: String,
101 pub content_length: u64,
102 pub last_modified: Option<String>,
103 pub etag: Option<String>,
104 pub version_id: Option<String>,
105 pub detected_type: DetectedFileType,
106 pub content: Option<Vec<u8>>, pub text_content: Option<String>, }
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct UploadResult {
113 pub bucket: String,
114 pub key: String,
115 pub etag: String,
116 pub location: String,
117 pub version_id: Option<String>,
118 pub file_size: u64,
119 pub content_type: String,
120 pub upload_id: Option<String>,
121}
122#[derive(Debug, Clone)]
123pub struct S3Client {
124 client: Client,
125}
126
127impl S3Client {
128 pub async fn new(config: &Config) -> Result<Self> {
130 info!("Initializing S3 client from configuration");
131
132 let access_key = config.access_key_id();
133 let secret_key = config.secret_access_key();
134
135 debug!("Using AWS region: {}", config.region);
136 if let Some(ref endpoint) = config.endpoint_url {
137 debug!("Using custom endpoint: {}", endpoint);
138 }
139
140 let credentials = Credentials::new(access_key, secret_key, None, None, "rustfs-mcp-server");
141
142 let mut config_builder = S3Config::builder()
143 .credentials_provider(credentials)
144 .region(Region::new(config.region.clone()))
145 .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest());
146
147 if config
148 .endpoint_url
149 .as_deref()
150 .is_some_and(|endpoint| endpoint.starts_with("http://"))
151 {
152 config_builder =
153 config_builder.http_client(SmithyHttpClientBuilder::new().build_http());
154 }
155
156 let should_force_path_style = config.endpoint_url.is_some() || config.force_path_style;
158 if should_force_path_style {
159 config_builder = config_builder.force_path_style(true);
160 }
161
162 if let Some(endpoint) = &config.endpoint_url {
163 config_builder = config_builder.endpoint_url(endpoint);
164 }
165
166 let s3_config = config_builder.build();
167 let client = Client::from_conf(s3_config);
168
169 info!("S3 client initialized successfully");
170
171 Ok(Self { client })
172 }
173
174 pub async fn create_bucket(&self, bucket_name: &str) -> Result<BucketInfo> {
176 info!("Creating S3 bucket: {}", bucket_name);
177
178 self.client
179 .create_bucket()
180 .bucket(bucket_name)
181 .send()
182 .await
183 .context(format!("Failed to create S3 bucket: {bucket_name}"))?;
184
185 info!("Bucket '{}' created successfully", bucket_name);
186 Ok(BucketInfo {
187 name: bucket_name.to_string(),
188 creation_date: None, })
190 }
191
192 pub async fn delete_bucket(&self, bucket_name: &str) -> Result<()> {
194 info!("Deleting S3 bucket: {}", bucket_name);
195 self.client
196 .delete_bucket()
197 .bucket(bucket_name)
198 .send()
199 .await
200 .context(format!("Failed to delete S3 bucket: {bucket_name}"))?;
201
202 info!("Bucket '{}' deleted successfully", bucket_name);
203 Ok(())
204 }
205
206 pub async fn list_buckets(&self) -> Result<Vec<BucketInfo>> {
208 debug!("Listing S3 buckets");
209
210 let response = self
211 .client
212 .list_buckets()
213 .send()
214 .await
215 .context("Failed to list S3 buckets")?;
216
217 let buckets: Vec<BucketInfo> = response
218 .buckets()
219 .iter()
220 .map(|bucket| {
221 let name = bucket.name().unwrap_or("unknown").to_string();
222 let creation_date = bucket.creation_date().map(|dt| {
223 dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
224 .unwrap()
225 });
226
227 BucketInfo {
228 name,
229 creation_date,
230 }
231 })
232 .collect();
233
234 debug!("Found {} buckets", buckets.len());
235 Ok(buckets)
236 }
237
238 pub async fn list_objects_v2(
240 &self,
241 bucket_name: &str,
242 options: ListObjectsOptions,
243 ) -> Result<ListObjectsResult> {
244 debug!(
245 "Listing objects in bucket '{}' with options: {:?}",
246 bucket_name, options
247 );
248
249 let mut request = self.client.list_objects_v2().bucket(bucket_name);
250
251 if let Some(prefix) = options.prefix {
252 request = request.prefix(prefix);
253 }
254
255 if let Some(delimiter) = options.delimiter {
256 request = request.delimiter(delimiter);
257 }
258
259 if let Some(max_keys) = options.max_keys {
260 request = request.max_keys(max_keys);
261 }
262
263 if let Some(continuation_token) = options.continuation_token {
264 request = request.continuation_token(continuation_token);
265 }
266
267 if let Some(start_after) = options.start_after {
268 request = request.start_after(start_after);
269 }
270
271 let response = request
272 .send()
273 .await
274 .context(format!("Failed to list objects in bucket '{bucket_name}'"))?;
275
276 let objects: Vec<ObjectInfo> = response
277 .contents()
278 .iter()
279 .map(|obj| {
280 let key = obj.key().unwrap_or("unknown").to_string();
281 let size = obj.size();
282 let last_modified = obj.last_modified().map(|dt| {
283 dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
284 .unwrap()
285 });
286 let etag = obj.e_tag().map(|e| e.to_string());
287 let storage_class = obj.storage_class().map(|sc| sc.as_str().to_string());
288
289 ObjectInfo {
290 key,
291 size,
292 last_modified,
293 etag,
294 storage_class,
295 }
296 })
297 .collect();
298
299 let common_prefixes: Vec<String> = response
300 .common_prefixes()
301 .iter()
302 .filter_map(|cp| cp.prefix())
303 .map(|p| p.to_string())
304 .collect();
305
306 let result = ListObjectsResult {
307 objects,
308 common_prefixes,
309 is_truncated: response.is_truncated().unwrap_or(false),
310 next_continuation_token: response.next_continuation_token().map(|t| t.to_string()),
311 max_keys: response.max_keys(),
312 key_count: response.key_count().unwrap_or(0),
313 };
314
315 debug!(
316 "Found {} objects and {} common prefixes in bucket '{}'",
317 result.objects.len(),
318 result.common_prefixes.len(),
319 bucket_name
320 );
321
322 Ok(result)
323 }
324
325 pub async fn upload_file(
327 &self,
328 local_path: &str,
329 bucket_name: &str,
330 object_key: &str,
331 options: UploadFileOptions,
332 ) -> Result<UploadResult> {
333 info!(
334 "Starting file upload: '{}' -> s3://{}/{}",
335 local_path, bucket_name, object_key
336 );
337
338 let path = Path::new(local_path);
339 let canonical_path = path
340 .canonicalize()
341 .context(format!("Failed to resolve file path: {local_path}"))?;
342
343 if !canonical_path.exists() {
344 anyhow::bail!("File does not exist: {local_path}");
345 }
346
347 if !canonical_path.is_file() {
348 anyhow::bail!("Path is not a file: {local_path}");
349 }
350
351 let metadata = tokio::fs::metadata(&canonical_path)
352 .await
353 .context(format!("Failed to read file metadata: {local_path}"))?;
354
355 let file_size = metadata.len();
356 debug!("File size: {file_size} bytes");
357
358 let content_type = options.content_type.unwrap_or_else(|| {
359 let detected = mime_guess::from_path(&canonical_path)
360 .first_or_octet_stream()
361 .to_string();
362 debug!("Auto-detected content type: {detected}");
363 detected
364 });
365
366 let file_content = tokio::fs::read(&canonical_path)
367 .await
368 .context(format!("Failed to read file content: {local_path}"))?;
369
370 let byte_stream = ByteStream::from(file_content);
371
372 let mut request = self
373 .client
374 .put_object()
375 .bucket(bucket_name)
376 .key(object_key)
377 .body(byte_stream)
378 .content_type(&content_type)
379 .content_length(file_size as i64);
380
381 if let Some(storage_class) = &options.storage_class {
382 request = request.storage_class(storage_class.as_str().into());
383 }
384
385 if let Some(cache_control) = &options.cache_control {
386 request = request.cache_control(cache_control);
387 }
388
389 if let Some(content_disposition) = &options.content_disposition {
390 request = request.content_disposition(content_disposition);
391 }
392
393 if let Some(content_encoding) = &options.content_encoding {
394 request = request.content_encoding(content_encoding);
395 }
396
397 if let Some(content_language) = &options.content_language {
398 request = request.content_language(content_language);
399 }
400
401 if let Some(sse) = &options.server_side_encryption {
402 request = request.server_side_encryption(sse.as_str().into());
403 }
404
405 if let Some(metadata_map) = &options.metadata {
406 for (key, value) in metadata_map {
407 request = request.metadata(key, value);
408 }
409 }
410
411 debug!("Executing S3 put_object request");
412 let response = request.send().await.context(format!(
413 "Failed to upload file to s3://{bucket_name}/{object_key}"
414 ))?;
415
416 let etag = response.e_tag().unwrap_or("unknown").to_string();
417 let version_id = response.version_id().map(|v| v.to_string());
418
419 let location = format!("s3://{bucket_name}/{object_key}");
420
421 let upload_result = UploadResult {
422 bucket: bucket_name.to_string(),
423 key: object_key.to_string(),
424 etag,
425 location,
426 version_id,
427 file_size,
428 content_type,
429 upload_id: None,
430 };
431
432 info!(
433 "File upload completed successfully: {} bytes uploaded to s3://{}/{}",
434 file_size, bucket_name, object_key
435 );
436
437 Ok(upload_result)
438 }
439
440 pub async fn get_object(
442 &self,
443 bucket_name: &str,
444 object_key: &str,
445 options: GetObjectOptions,
446 ) -> Result<GetObjectResult> {
447 info!("Getting object: s3://{}/{}", bucket_name, object_key);
448
449 let mut request = self.client.get_object().bucket(bucket_name).key(object_key);
450
451 if let Some(version_id) = &options.version_id {
452 request = request.version_id(version_id);
453 }
454
455 if let Some(range) = &options.range {
456 request = request.range(range);
457 }
458
459 if let Some(if_modified_since) = &options.if_modified_since {
460 request = request.if_modified_since(
461 aws_sdk_s3::primitives::DateTime::from_str(
462 if_modified_since,
463 aws_sdk_s3::primitives::DateTimeFormat::DateTime,
464 )
465 .context("Failed to parse if_modified_since date")?,
466 );
467 }
468
469 debug!("Executing S3 get_object request");
470 let response = request.send().await.context(format!(
471 "Failed to get object from s3://{bucket_name}/{object_key}"
472 ))?;
473
474 let content_type = response
475 .content_type()
476 .unwrap_or("application/octet-stream")
477 .to_string();
478 let content_length = response.content_length().unwrap_or(0) as u64;
479 let last_modified = response.last_modified().map(|dt| {
480 dt.fmt(aws_sdk_s3::primitives::DateTimeFormat::DateTime)
481 .unwrap()
482 });
483 let etag = response.e_tag().map(|e| e.to_string());
484 let version_id = response.version_id().map(|v| v.to_string());
485
486 let max_size = options.max_content_size.unwrap_or(10 * 1024 * 1024);
487 let mut content = Vec::new();
488 let mut byte_stream = response.body;
489 let mut total_read = 0;
490
491 while let Some(bytes_result) = byte_stream
492 .try_next()
493 .await
494 .context("Failed to read object content")?
495 {
496 if total_read + bytes_result.len() > max_size {
497 anyhow::bail!("Object size exceeds maximum allowed size of {max_size} bytes");
498 }
499 content.extend_from_slice(&bytes_result);
500 total_read += bytes_result.len();
501 }
502
503 debug!("Read {} bytes from object", content.len());
504
505 let detected_type = Self::detect_file_type(Some(&content_type), &content);
506 debug!("Detected file type: {detected_type:?}");
507
508 let text_content = match &detected_type {
509 DetectedFileType::Text => match std::str::from_utf8(&content) {
510 Ok(text) => Some(text.to_string()),
511 Err(_) => {
512 debug!("Failed to decode content as UTF-8, treating as binary");
513 None
514 }
515 },
516 _ => None,
517 };
518
519 let result = GetObjectResult {
520 bucket: bucket_name.to_string(),
521 key: object_key.to_string(),
522 content_type,
523 content_length,
524 last_modified,
525 etag,
526 version_id,
527 detected_type,
528 content: Some(content),
529 text_content,
530 };
531
532 info!(
533 "Object retrieved successfully: {} bytes from s3://{}/{}",
534 result.content_length, bucket_name, object_key
535 );
536
537 Ok(result)
538 }
539
540 fn detect_file_type(content_type: Option<&str>, content_bytes: &[u8]) -> DetectedFileType {
541 if let Some(ct) = content_type {
542 let ct_lower = ct.to_lowercase();
543
544 if ct_lower.starts_with("text/")
545 || ct_lower == "application/json"
546 || ct_lower == "application/xml"
547 || ct_lower == "application/yaml"
548 || ct_lower == "application/javascript"
549 || ct_lower == "application/x-yaml"
550 || ct_lower == "application/x-sh"
551 || ct_lower == "application/x-shellscript"
552 || ct_lower.contains("script")
553 || ct_lower.contains("xml")
554 || ct_lower.contains("json")
555 {
556 return DetectedFileType::Text;
557 }
558
559 return DetectedFileType::NonText(ct.to_string());
560 }
561
562 if content_bytes.len() >= 4 {
563 match &content_bytes[0..4] {
564 [0x89, 0x50, 0x4E, 0x47] => {
566 return DetectedFileType::NonText("image/png".to_string());
567 }
568 [0xFF, 0xD8, 0xFF, _] => {
570 return DetectedFileType::NonText("image/jpeg".to_string());
571 }
572 [0x47, 0x49, 0x46, 0x38] => {
574 return DetectedFileType::NonText("image/gif".to_string());
575 }
576 [0x42, 0x4D, _, _] => return DetectedFileType::NonText("image/bmp".to_string()),
578 [0x52, 0x49, 0x46, 0x46] if content_bytes.len() >= 12 => {
580 if &content_bytes[8..12] == b"WEBP" {
581 return DetectedFileType::NonText("image/webp".to_string());
582 } else if &content_bytes[8..12] == b"WAVE" {
583 return DetectedFileType::NonText("audio/wav".to_string());
584 }
585 return DetectedFileType::NonText("application/octet-stream".to_string());
586 }
587 _ => {}
588 }
589 }
590
591 if std::str::from_utf8(content_bytes).is_ok() {
593 let non_printable_count = content_bytes
595 .iter()
596 .filter(|&&b| b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) .count();
598 let total_chars = content_bytes.len();
599
600 if total_chars > 0 && (non_printable_count as f64 / total_chars as f64) < 0.05 {
602 return DetectedFileType::Text;
603 }
604 }
605
606 DetectedFileType::NonText("application/octet-stream".to_string())
608 }
609
610 pub async fn download_object_to_file(
612 &self,
613 bucket_name: &str,
614 object_key: &str,
615 local_path: &str,
616 options: GetObjectOptions,
617 ) -> Result<(u64, String)> {
618 info!(
619 "Downloading object: s3://{}/{} -> {}",
620 bucket_name, object_key, local_path
621 );
622
623 let mut request = self.client.get_object().bucket(bucket_name).key(object_key);
624
625 if let Some(version_id) = &options.version_id {
626 request = request.version_id(version_id);
627 }
628
629 if let Some(range) = &options.range {
630 request = request.range(range);
631 }
632
633 if let Some(if_modified_since) = &options.if_modified_since {
634 request = request.if_modified_since(
635 aws_sdk_s3::primitives::DateTime::from_str(
636 if_modified_since,
637 aws_sdk_s3::primitives::DateTimeFormat::DateTime,
638 )
639 .context("Failed to parse if_modified_since date")?,
640 );
641 }
642
643 debug!("Executing S3 get_object request for download");
644 let response = request.send().await.context(format!(
645 "Failed to get object from s3://{bucket_name}/{object_key}"
646 ))?;
647
648 let local_file_path = Path::new(local_path);
649
650 if let Some(parent) = local_file_path.parent() {
651 tokio::fs::create_dir_all(parent).await.context(format!(
652 "Failed to create parent directories for {local_path}"
653 ))?;
654 }
655
656 let mut file = tokio::fs::File::create(local_file_path)
657 .await
658 .context(format!("Failed to create local file: {local_path}"))?;
659
660 let mut byte_stream = response.body;
661 let mut total_bytes = 0u64;
662
663 while let Some(bytes_result) = byte_stream
664 .try_next()
665 .await
666 .context("Failed to read object content")?
667 {
668 file.write_all(&bytes_result)
669 .await
670 .context(format!("Failed to write to local file: {local_path}"))?;
671 total_bytes += bytes_result.len() as u64;
672 }
673
674 file.flush().await.context("Failed to flush file to disk")?;
675
676 let absolute_path = local_file_path
677 .canonicalize()
678 .unwrap_or_else(|_| local_file_path.to_path_buf())
679 .to_string_lossy()
680 .to_string();
681
682 info!(
683 "Object downloaded successfully: {} bytes from s3://{}/{} to {}",
684 total_bytes, bucket_name, object_key, absolute_path
685 );
686
687 Ok((total_bytes, absolute_path))
688 }
689
690 pub async fn health_check(&self) -> Result<()> {
692 debug!("Performing S3 health check");
693
694 self.client
695 .list_buckets()
696 .send()
697 .await
698 .context("S3 health check failed")?;
699
700 debug!("S3 health check passed");
701 Ok(())
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[tokio::test]
710 #[ignore] async fn test_s3_client_creation() {
712 let config = Config {
713 access_key_id: Some("test_key".to_string()),
714 secret_access_key: Some("test_secret".to_string()),
715 region: "us-east-1".to_string(),
716 ..Config::default()
717 };
718
719 let result = S3Client::new(&config).await;
720 assert!(result.is_ok());
721 }
722
723 #[test]
724 fn test_bucket_info_serialization() {
725 let bucket = BucketInfo {
726 name: "test-bucket".to_string(),
727 creation_date: Some("2024-01-01T00:00:00Z".to_string()),
728 };
729
730 let json = serde_json::to_string(&bucket).unwrap();
731 let deserialized: BucketInfo = serde_json::from_str(&json).unwrap();
732
733 assert_eq!(bucket.name, deserialized.name);
734 assert_eq!(bucket.creation_date, deserialized.creation_date);
735 }
736
737 #[test]
738 fn test_detect_file_type_text_content_type() {
739 let test_cases = vec![
740 ("text/plain", "Hello world"),
741 ("text/html", "<html></html>"),
742 ("application/json", r#"{"key": "value"}"#),
743 ("application/xml", "<xml></xml>"),
744 ("application/yaml", "key: value"),
745 ("application/javascript", "console.log('hello');"),
746 ];
747
748 for (content_type, content) in test_cases {
749 let result = S3Client::detect_file_type(Some(content_type), content.as_bytes());
750 match result {
751 DetectedFileType::Text => {}
752 _ => panic!("Expected Text for content type {content_type}"),
753 }
754 }
755 }
756
757 #[test]
758 fn test_detect_file_type_non_text_content_type() {
759 let test_cases = vec![
761 ("image/png", "image/png"),
762 ("image/jpeg", "image/jpeg"),
763 ("audio/mp3", "audio/mp3"),
764 ("video/mp4", "video/mp4"),
765 ("application/pdf", "application/pdf"),
766 ];
767
768 for (content_type, expected_mime) in test_cases {
769 let result = S3Client::detect_file_type(Some(content_type), b"some content");
770 match result {
771 DetectedFileType::NonText(mime_type) => {
772 assert_eq!(mime_type, expected_mime);
773 }
774 _ => panic!("Expected NonText for content type {content_type}"),
775 }
776 }
777 }
778
779 #[test]
780 fn test_detect_file_type_magic_bytes_simplified() {
781 let test_cases = vec![
783 (
785 vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
786 "image/png",
787 ),
788 (vec![0xFF, 0xD8, 0xFF, 0xE0], "image/jpeg"),
790 (vec![0x47, 0x49, 0x46, 0x38, 0x37, 0x61], "image/gif"),
792 ];
793
794 for (content, expected_mime) in test_cases {
795 let result = S3Client::detect_file_type(None, &content);
796 match result {
797 DetectedFileType::NonText(mime_type) => {
798 assert_eq!(mime_type, expected_mime);
799 }
800 _ => panic!("Expected NonText for magic bytes: {content:?}"),
801 }
802 }
803 }
804
805 #[test]
806 fn test_detect_file_type_webp_magic_bytes() {
807 let mut webp_content = vec![0x52, 0x49, 0x46, 0x46]; webp_content.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); webp_content.extend_from_slice(b"WEBP"); let result = S3Client::detect_file_type(None, &webp_content);
813 match result {
814 DetectedFileType::NonText(mime_type) => {
815 assert_eq!(mime_type, "image/webp");
816 }
817 _ => panic!("Expected WebP NonText detection"),
818 }
819 }
820
821 #[test]
822 fn test_detect_file_type_wav_magic_bytes() {
823 let mut wav_content = vec![0x52, 0x49, 0x46, 0x46]; wav_content.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); wav_content.extend_from_slice(b"WAVE"); let result = S3Client::detect_file_type(None, &wav_content);
829 match result {
830 DetectedFileType::NonText(mime_type) => {
831 assert_eq!(mime_type, "audio/wav");
832 }
833 _ => panic!("Expected WAV NonText detection"),
834 }
835 }
836
837 #[test]
838 fn test_detect_file_type_utf8_text() {
839 let utf8_content = "Hello, World! 🌍".as_bytes();
841 let result = S3Client::detect_file_type(None, utf8_content);
842 match result {
843 DetectedFileType::Text => {}
844 _ => panic!("Expected Text for UTF-8 content"),
845 }
846
847 let ascii_content = b"Hello, world! This is ASCII text.";
849 let result = S3Client::detect_file_type(None, ascii_content);
850 match result {
851 DetectedFileType::Text => {}
852 _ => panic!("Expected Text for ASCII content"),
853 }
854 }
855
856 #[test]
857 fn test_detect_file_type_binary() {
858 let binary_content = vec![0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD, 0xFC];
860 let result = S3Client::detect_file_type(None, &binary_content);
861 match result {
862 DetectedFileType::NonText(mime_type) => {
863 assert_eq!(mime_type, "application/octet-stream");
864 }
865 _ => panic!("Expected NonText for binary content"),
866 }
867 }
868
869 #[test]
870 fn test_detect_file_type_priority() {
871 let png_magic_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
873
874 let result = S3Client::detect_file_type(Some("text/plain"), &png_magic_bytes);
876 match result {
877 DetectedFileType::Text => {}
878 _ => panic!("Expected Text due to content-type priority"),
879 }
880 }
881
882 #[test]
883 fn test_get_object_options_default() {
884 let options = GetObjectOptions::default();
885 assert!(options.version_id.is_none());
886 assert!(options.range.is_none());
887 assert!(options.if_modified_since.is_none());
888 assert!(options.if_unmodified_since.is_none());
889 assert!(options.max_content_size.is_none());
890 }
891
892 #[test]
893 fn test_detected_file_type_serialization() {
894 let test_cases = vec![
895 DetectedFileType::Text,
896 DetectedFileType::NonText("image/png".to_string()),
897 DetectedFileType::NonText("audio/mpeg".to_string()),
898 DetectedFileType::NonText("application/octet-stream".to_string()),
899 ];
900
901 for file_type in test_cases {
902 let json = serde_json::to_string(&file_type).unwrap();
903 let deserialized: DetectedFileType = serde_json::from_str(&json).unwrap();
904
905 match (&file_type, &deserialized) {
906 (DetectedFileType::Text, DetectedFileType::Text) => {}
907 (DetectedFileType::NonText(a), DetectedFileType::NonText(b)) => assert_eq!(a, b),
908 _ => panic!("Serialization/deserialization mismatch"),
909 }
910 }
911 }
912}