files_sdk/files/files.rs
1//! File operations
2//!
3//! This module provides core file operations including:
4//! - Download files
5//! - Upload files (using the two-stage upload process)
6//! - Update file metadata
7//! - Delete files
8//!
9//! Note: File uploads in Files.com use a two-stage process:
10//! 1. Call `FileActionHandler::begin_upload()` to get upload URLs
11//! 2. Use this handler's `upload_file()` to complete the upload
12
13use crate::files::FileActionHandler;
14use crate::progress::{Progress, ProgressCallback};
15use crate::types::FileEntity;
16use crate::utils::encode_path;
17use crate::{FilesClient, FilesError, Result};
18use serde_json::json;
19use std::collections::HashMap;
20use std::path::Path;
21use std::sync::Arc;
22use walkdir::WalkDir;
23
24/// Default chunk size for streaming operations (64KB)
25///
26/// This provides a good balance between:
27/// - Memory usage per read operation
28/// - Number of syscalls
29/// - Progress update granularity
30const STREAM_CHUNK_SIZE: usize = 65536; // 64KB
31
32/// Handler for file operations
33///
34/// Provides methods for downloading, uploading, updating, and deleting files.
35#[derive(Debug, Clone)]
36pub struct FileHandler {
37 client: FilesClient,
38}
39
40impl FileHandler {
41 /// Creates a new FileHandler
42 ///
43 /// # Arguments
44 ///
45 /// * `client` - FilesClient instance
46 pub fn new(client: FilesClient) -> Self {
47 Self { client }
48 }
49
50 /// Download a file or get file information
51 ///
52 /// # Arguments
53 ///
54 /// * `path` - File path to download
55 ///
56 /// # Returns
57 ///
58 /// Returns a `FileEntity` containing file information including a
59 /// `download_uri` for the actual file download.
60 ///
61 /// # Examples
62 ///
63 /// ```rust,no_run
64 /// use files_sdk::{FilesClient, FileHandler};
65 ///
66 /// # #[tokio::main]
67 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
68 /// let client = FilesClient::builder()
69 /// .api_key("your-api-key")
70 /// .build()?;
71 ///
72 /// let handler = FileHandler::new(client);
73 /// let file = handler.download_file("/path/to/file.txt").await?;
74 ///
75 /// if let Some(uri) = file.download_uri {
76 /// println!("Download from: {}", uri);
77 /// }
78 /// # Ok(())
79 /// # }
80 /// ```
81 pub async fn download_file(&self, path: &str) -> Result<FileEntity> {
82 let encoded_path = encode_path(path);
83 let endpoint = format!("/files{}", encoded_path);
84 let response = self.client.get_raw(&endpoint).await?;
85 Ok(serde_json::from_value(response)?)
86 }
87
88 /// Download the actual file content as bytes
89 ///
90 /// Unlike `download_file()` which returns metadata with a download URL,
91 /// this method fetches and returns the actual file content.
92 ///
93 /// # Arguments
94 ///
95 /// * `path` - File path to download
96 ///
97 /// # Returns
98 ///
99 /// Returns the file content as a `Vec<u8>`
100 ///
101 /// # Examples
102 ///
103 /// ```rust,no_run
104 /// use files_sdk::{FilesClient, FileHandler};
105 ///
106 /// # #[tokio::main]
107 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
108 /// let client = FilesClient::builder()
109 /// .api_key("your-api-key")
110 /// .build()?;
111 ///
112 /// let handler = FileHandler::new(client);
113 /// let content = handler.download_content("/path/to/file.txt").await?;
114 /// println!("Downloaded {} bytes", content.len());
115 /// # Ok(())
116 /// # }
117 /// ```
118 pub async fn download_content(&self, path: &str) -> Result<Vec<u8>> {
119 // First, get the file metadata to obtain the download URI
120 let file = self.download_file(path).await?;
121
122 // Extract the download URI
123 let download_uri = file.download_uri.ok_or_else(|| {
124 FilesError::not_found_resource("No download URI available", "file", path)
125 })?;
126
127 // Fetch the actual file content from the download URI
128 let response = reqwest::get(&download_uri)
129 .await
130 .map_err(FilesError::Request)?;
131
132 let bytes = response.bytes().await.map_err(FilesError::Request)?;
133
134 Ok(bytes.to_vec())
135 }
136
137 /// Download file content and save to a local file
138 ///
139 /// This is a convenience method that downloads the file content and
140 /// writes it to the specified local path.
141 ///
142 /// # Arguments
143 ///
144 /// * `remote_path` - Path to the file on Files.com
145 /// * `local_path` - Local filesystem path where the file should be saved
146 ///
147 /// # Examples
148 ///
149 /// ```rust,no_run
150 /// use files_sdk::{FilesClient, FileHandler};
151 /// use std::path::Path;
152 ///
153 /// # #[tokio::main]
154 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
155 /// let client = FilesClient::builder()
156 /// .api_key("your-api-key")
157 /// .build()?;
158 ///
159 /// let handler = FileHandler::new(client);
160 /// handler.download_to_file(
161 /// "/path/to/remote/file.txt",
162 /// Path::new("./local/file.txt")
163 /// ).await?;
164 /// # Ok(())
165 /// # }
166 /// ```
167 pub async fn download_to_file(
168 &self,
169 remote_path: &str,
170 local_path: &std::path::Path,
171 ) -> Result<()> {
172 let content = self.download_content(remote_path).await?;
173 std::fs::write(local_path, content)
174 .map_err(|e| FilesError::IoError(format!("Failed to write file: {}", e)))?;
175 Ok(())
176 }
177
178 /// Download file content to an async stream
179 ///
180 /// This method is more memory-efficient than [`download_content()`](Self::download_content) for large files
181 /// as it streams the data directly to the writer in chunks instead of loading it into memory.
182 ///
183 /// # Arguments
184 ///
185 /// * `remote_path` - Path to the file on Files.com
186 /// * `writer` - An async writer implementing [`tokio::io::AsyncWrite`]
187 /// * `progress_callback` - Optional callback for progress updates (see [`progress`](crate::progress) module)
188 ///
189 /// # Examples
190 ///
191 /// ## Basic streaming download
192 ///
193 /// ```rust,no_run
194 /// # use files_sdk::{FilesClient, files::FileHandler};
195 /// # use tokio::fs::File;
196 /// # #[tokio::main]
197 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
198 /// # let client = FilesClient::builder().api_key("key").build()?;
199 /// let handler = FileHandler::new(client);
200 ///
201 /// let mut file = File::create("downloaded-large-file.tar.gz").await?;
202 /// handler.download_stream(
203 /// "/remote/large-file.tar.gz",
204 /// &mut file,
205 /// None
206 /// ).await?;
207 /// # Ok(())
208 /// # }
209 /// ```
210 ///
211 /// ## With progress tracking
212 ///
213 /// ```rust,no_run
214 /// # use files_sdk::{FilesClient, files::FileHandler};
215 /// # use files_sdk::progress::{Progress, ProgressCallback, PrintProgressCallback};
216 /// # use tokio::fs::File;
217 /// # use std::sync::Arc;
218 /// # #[tokio::main]
219 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
220 /// # let client = FilesClient::builder().api_key("key").build()?;
221 /// let handler = FileHandler::new(client);
222 /// let callback = Arc::new(PrintProgressCallback);
223 ///
224 /// let mut file = File::create("large-file.tar.gz").await?;
225 /// handler.download_stream(
226 /// "/remote/large-file.tar.gz",
227 /// &mut file,
228 /// Some(callback)
229 /// ).await?;
230 /// # Ok(())
231 /// # }
232 /// ```
233 ///
234 /// ## Streaming to any AsyncWrite destination
235 ///
236 /// ```rust,no_run
237 /// # use files_sdk::{FilesClient, files::FileHandler};
238 /// # #[tokio::main]
239 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
240 /// # let client = FilesClient::builder().api_key("key").build()?;
241 /// let handler = FileHandler::new(client);
242 ///
243 /// // Download to memory
244 /// let mut buffer = Vec::new();
245 /// handler.download_stream(
246 /// "/remote/file.txt",
247 /// &mut buffer,
248 /// None
249 /// ).await?;
250 ///
251 /// println!("Downloaded {} bytes", buffer.len());
252 /// # Ok(())
253 /// # }
254 /// ```
255 pub async fn download_stream<W>(
256 &self,
257 remote_path: &str,
258 writer: &mut W,
259 progress_callback: Option<Arc<dyn ProgressCallback>>,
260 ) -> Result<()>
261 where
262 W: tokio::io::AsyncWrite + Unpin,
263 {
264 use tokio::io::AsyncWriteExt;
265
266 // First, get the file metadata to obtain the download URI and size
267 let file = self.download_file(remote_path).await?;
268
269 // Extract the download URI
270 let download_uri = file.download_uri.ok_or_else(|| {
271 FilesError::not_found_resource("No download URI available", "file", remote_path)
272 })?;
273
274 // Get the total file size for progress tracking
275 let total_bytes = file
276 .size
277 .and_then(|s| if s > 0 { Some(s as u64) } else { None });
278
279 // Stream the file content from the download URI
280 let mut response = reqwest::get(&download_uri)
281 .await
282 .map_err(FilesError::Request)?;
283
284 let mut bytes_transferred = 0u64;
285
286 // Stream chunks to the writer with progress tracking
287 while let Some(chunk) = response.chunk().await.map_err(FilesError::Request)? {
288 writer
289 .write_all(&chunk)
290 .await
291 .map_err(|e| FilesError::IoError(format!("Failed to write to stream: {}", e)))?;
292
293 bytes_transferred += chunk.len() as u64;
294
295 // Report progress
296 if let Some(ref callback) = progress_callback {
297 let progress = Progress::new(bytes_transferred, total_bytes);
298 callback.on_progress(&progress);
299 }
300 }
301
302 // Flush the writer to ensure all data is written
303 writer
304 .flush()
305 .await
306 .map_err(|e| FilesError::IoError(format!("Failed to flush stream: {}", e)))?;
307
308 Ok(())
309 }
310
311 /// Get file metadata only (no download URL, no logging)
312 ///
313 /// This is a convenience method that calls `FileActionHandler::get_metadata()`
314 ///
315 /// # Arguments
316 ///
317 /// * `path` - File path
318 pub async fn get_metadata(&self, path: &str) -> Result<FileEntity> {
319 let file_action = FileActionHandler::new(self.client.clone());
320 file_action.get_metadata(path).await
321 }
322
323 /// Upload a file (complete two-stage upload process)
324 ///
325 /// This method handles the complete upload process:
326 /// 1. Calls begin_upload to get upload URLs
327 /// 2. Uploads the file data
328 /// 3. Finalizes the upload
329 ///
330 /// # Arguments
331 ///
332 /// * `path` - Destination path for the file
333 /// * `data` - File contents as bytes
334 ///
335 /// # Examples
336 ///
337 /// ```rust,no_run
338 /// # use files_sdk::{FilesClient, FileHandler};
339 /// # #[tokio::main]
340 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
341 /// # let client = FilesClient::builder().api_key("key").build()?;
342 /// let handler = FileHandler::new(client);
343 ///
344 /// let data = b"Hello, Files.com!";
345 /// let file = handler.upload_file("/uploads/test.txt", data).await?;
346 /// println!("Uploaded: {:?}", file.path);
347 /// # Ok(())
348 /// # }
349 /// ```
350 pub async fn upload_file(&self, path: &str, data: &[u8]) -> Result<FileEntity> {
351 // Stage 1: Begin upload
352 let file_action = FileActionHandler::new(self.client.clone());
353 let upload_parts = file_action
354 .begin_upload(path, Some(data.len() as i64), true)
355 .await?;
356
357 if upload_parts.is_empty() {
358 return Err(crate::FilesError::ApiError {
359 endpoint: None,
360 code: 500,
361 message: "No upload parts returned from begin_upload".to_string(),
362 });
363 }
364
365 let upload_part = &upload_parts[0];
366
367 // Stage 2: Upload file data to the provided URL
368 // This is an external URL (not Files.com API), typically to cloud storage
369 let _etag = if let Some(upload_uri) = &upload_part.upload_uri {
370 let http_client = reqwest::Client::new();
371 let http_method = upload_part
372 .http_method
373 .as_deref()
374 .unwrap_or("PUT")
375 .to_uppercase();
376
377 let mut request = match http_method.as_str() {
378 "PUT" => http_client.put(upload_uri),
379 "POST" => http_client.post(upload_uri),
380 _ => http_client.put(upload_uri),
381 };
382
383 // Add any custom headers
384 if let Some(headers) = &upload_part.headers {
385 for (key, value) in headers {
386 request = request.header(key, value);
387 }
388 }
389
390 // Set Content-Length header (required by S3, even for empty files)
391 let upload_response = request
392 .header("Content-Length", data.len().to_string())
393 .body(data.to_vec())
394 .send()
395 .await?;
396
397 // Extract ETag from response headers
398 upload_response
399 .headers()
400 .get("etag")
401 .and_then(|v| v.to_str().ok())
402 .map(|s| s.trim_matches('"').to_string())
403 } else {
404 None
405 };
406
407 // Stage 3: Finalize upload with Files.com
408 let encoded_path = encode_path(path);
409 let endpoint = format!("/files{}", encoded_path);
410
411 // Build the finalization request as form data
412 let mut form = vec![("action", "end".to_string())];
413
414 // Add ref (upload reference) - this is required to identify the upload
415 if let Some(ref_value) = &upload_part.ref_ {
416 form.push(("ref", ref_value.clone()));
417 }
418
419 // Note: etags might not be needed when ref is provided
420 // Commenting out for now to test
421 // if let Some(etag_value) = etag {
422 // let part_number = upload_part.part_number.unwrap_or(1);
423 // form.push(("etags[etag]", etag_value));
424 // form.push(("etags[part]", part_number.to_string()));
425 // }
426
427 let response = self.client.post_form(&endpoint, &form).await?;
428 Ok(serde_json::from_value(response)?)
429 }
430
431 /// Upload a file from an async stream
432 ///
433 /// This method is more memory-efficient than [`upload_file()`](Self::upload_file) for large files
434 /// as it reads the data in chunks (8KB) instead of loading it entirely into memory.
435 ///
436 /// # Arguments
437 ///
438 /// * `path` - Destination path for the file on Files.com
439 /// * `reader` - An async reader implementing [`tokio::io::AsyncRead`]
440 /// * `size` - Optional size of the file in bytes (recommended for progress tracking)
441 /// * `progress_callback` - Optional callback for progress updates (see [`progress`](crate::progress) module)
442 ///
443 /// # Returns
444 ///
445 /// Returns a [`FileEntity`] with the uploaded file's metadata.
446 ///
447 /// # Examples
448 ///
449 /// ## Basic streaming upload
450 ///
451 /// ```rust,no_run
452 /// # use files_sdk::{FilesClient, files::FileHandler};
453 /// # use tokio::fs::File;
454 /// # #[tokio::main]
455 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
456 /// # let client = FilesClient::builder().api_key("key").build()?;
457 /// let handler = FileHandler::new(client);
458 ///
459 /// let file = File::open("large-file.tar.gz").await?;
460 /// let metadata = file.metadata().await?;
461 /// let size = metadata.len() as i64;
462 ///
463 /// handler.upload_stream(
464 /// "/uploads/large-file.tar.gz",
465 /// file,
466 /// Some(size),
467 /// None
468 /// ).await?;
469 /// # Ok(())
470 /// # }
471 /// ```
472 ///
473 /// ## With progress tracking
474 ///
475 /// ```rust,no_run
476 /// # use files_sdk::{FilesClient, files::FileHandler};
477 /// # use files_sdk::progress::{Progress, ProgressCallback, PrintProgressCallback};
478 /// # use tokio::fs::File;
479 /// # use std::sync::Arc;
480 /// # #[tokio::main]
481 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
482 /// # let client = FilesClient::builder().api_key("key").build()?;
483 /// let handler = FileHandler::new(client);
484 /// let callback = Arc::new(PrintProgressCallback);
485 ///
486 /// let file = File::open("large-file.tar.gz").await?;
487 /// let size = file.metadata().await?.len() as i64;
488 ///
489 /// handler.upload_stream(
490 /// "/uploads/large-file.tar.gz",
491 /// file,
492 /// Some(size),
493 /// Some(callback)
494 /// ).await?;
495 /// # Ok(())
496 /// # }
497 /// ```
498 ///
499 /// ## Streaming from any AsyncRead source
500 ///
501 /// ```rust,no_run
502 /// # use files_sdk::{FilesClient, files::FileHandler};
503 /// # use std::io::Cursor;
504 /// # #[tokio::main]
505 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
506 /// # let client = FilesClient::builder().api_key("key").build()?;
507 /// let handler = FileHandler::new(client);
508 ///
509 /// // Upload from memory
510 /// let data = b"file contents";
511 /// let cursor = Cursor::new(data.to_vec());
512 ///
513 /// handler.upload_stream(
514 /// "/uploads/file.txt",
515 /// cursor,
516 /// Some(data.len() as i64),
517 /// None
518 /// ).await?;
519 /// # Ok(())
520 /// # }
521 /// ```
522 pub async fn upload_stream<R>(
523 &self,
524 path: &str,
525 mut reader: R,
526 size: Option<i64>,
527 progress_callback: Option<Arc<dyn ProgressCallback>>,
528 ) -> Result<FileEntity>
529 where
530 R: tokio::io::AsyncRead + Unpin,
531 {
532 use tokio::io::AsyncReadExt;
533
534 // Stage 1: Begin upload
535 let file_action = FileActionHandler::new(self.client.clone());
536 let upload_parts = file_action.begin_upload(path, size, true).await?;
537
538 if upload_parts.is_empty() {
539 return Err(crate::FilesError::ApiError {
540 endpoint: None,
541 code: 500,
542 message: "No upload parts returned from begin_upload".to_string(),
543 });
544 }
545
546 let upload_part = &upload_parts[0];
547
548 // Stage 2: Stream file data to the provided URL with progress tracking
549 // Note: Even for empty files (size=0), we must perform the upload stage.
550 // S3 requires the Content-Length header, and the API tracks whether the upload occurred.
551 let _etag = if upload_part.upload_uri.is_some() {
552 let upload_uri = upload_part.upload_uri.as_ref().unwrap();
553 // Read the stream into a buffer with progress tracking
554 // Note: We read in chunks to provide progress updates, but still buffer
555 // the entire file before upload. This is required by the Files.com API
556 // which expects the full file in a single PUT/POST request.
557
558 // Pre-allocate buffer if size is known for better performance
559 let mut buffer = if let Some(s) = size {
560 Vec::with_capacity(s as usize)
561 } else {
562 Vec::new()
563 };
564
565 let mut temp_buffer = vec![0u8; STREAM_CHUNK_SIZE];
566 let total_bytes = size.map(|s| s as u64);
567
568 loop {
569 let bytes_read = reader.read(&mut temp_buffer).await.map_err(|e| {
570 FilesError::IoError(format!("Failed to read from stream: {}", e))
571 })?;
572
573 if bytes_read == 0 {
574 break;
575 }
576
577 buffer.extend_from_slice(&temp_buffer[..bytes_read]);
578
579 // Report progress
580 if let Some(ref callback) = progress_callback {
581 let progress = Progress::new(buffer.len() as u64, total_bytes);
582 callback.on_progress(&progress);
583 }
584 }
585
586 let http_client = reqwest::Client::new();
587 let http_method = upload_part
588 .http_method
589 .as_deref()
590 .unwrap_or("PUT")
591 .to_uppercase();
592
593 let mut request = match http_method.as_str() {
594 "PUT" => http_client.put(upload_uri),
595 "POST" => http_client.post(upload_uri),
596 _ => http_client.put(upload_uri),
597 };
598
599 // Add any custom headers
600 if let Some(headers) = &upload_part.headers {
601 for (key, value) in headers {
602 request = request.header(key, value);
603 }
604 }
605
606 // Set Content-Length header (required by S3, even for empty files)
607 let content_length = buffer.len();
608 let upload_response = request
609 .header("Content-Length", content_length.to_string())
610 .body(buffer)
611 .send()
612 .await?;
613
614 // Extract ETag from response headers
615 upload_response
616 .headers()
617 .get("etag")
618 .and_then(|v| v.to_str().ok())
619 .map(|s| s.trim_matches('"').to_string())
620 } else {
621 None
622 };
623
624 // Stage 3: Finalize upload with Files.com
625 let encoded_path = encode_path(path);
626 let endpoint = format!("/files{}", encoded_path);
627
628 let mut form = vec![("action", "end".to_string())];
629
630 if let Some(ref_value) = &upload_part.ref_ {
631 form.push(("ref", ref_value.clone()));
632 }
633
634 let response = self.client.post_form(&endpoint, &form).await?;
635 Ok(serde_json::from_value(response)?)
636 }
637
638 /// Update file metadata
639 ///
640 /// # Arguments
641 ///
642 /// * `path` - File path
643 /// * `custom_metadata` - Custom metadata key-value pairs (optional)
644 /// * `provided_mtime` - Custom modification time (optional)
645 /// * `priority_color` - Priority color (optional)
646 ///
647 /// # Examples
648 ///
649 /// ```rust,no_run
650 /// # use files_sdk::{FilesClient, FileHandler};
651 /// # use std::collections::HashMap;
652 /// # #[tokio::main]
653 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
654 /// # let client = FilesClient::builder().api_key("key").build()?;
655 /// let handler = FileHandler::new(client);
656 ///
657 /// let mut metadata = HashMap::new();
658 /// metadata.insert("category".to_string(), "reports".to_string());
659 ///
660 /// handler.update_file("/path/to/file.txt", Some(metadata), None, None).await?;
661 /// # Ok(())
662 /// # }
663 /// ```
664 pub async fn update_file(
665 &self,
666 path: &str,
667 custom_metadata: Option<HashMap<String, String>>,
668 provided_mtime: Option<String>,
669 priority_color: Option<String>,
670 ) -> Result<FileEntity> {
671 let mut body = json!({});
672
673 if let Some(metadata) = custom_metadata {
674 body["custom_metadata"] = json!(metadata);
675 }
676
677 if let Some(mtime) = provided_mtime {
678 body["provided_mtime"] = json!(mtime);
679 }
680
681 if let Some(color) = priority_color {
682 body["priority_color"] = json!(color);
683 }
684
685 let encoded_path = encode_path(path);
686 let endpoint = format!("/files{}", encoded_path);
687 let response = self.client.patch_raw(&endpoint, body).await?;
688 Ok(serde_json::from_value(response)?)
689 }
690
691 /// Delete a file
692 ///
693 /// # Arguments
694 ///
695 /// * `path` - File path to delete
696 /// * `recursive` - If path is a folder, delete recursively
697 ///
698 /// # Examples
699 ///
700 /// ```rust,no_run
701 /// # use files_sdk::{FilesClient, FileHandler};
702 /// # #[tokio::main]
703 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
704 /// # let client = FilesClient::builder().api_key("key").build()?;
705 /// let handler = FileHandler::new(client);
706 /// handler.delete_file("/path/to/file.txt", false).await?;
707 /// # Ok(())
708 /// # }
709 /// ```
710 pub async fn delete_file(&self, path: &str, recursive: bool) -> Result<()> {
711 let encoded_path = encode_path(path);
712 let endpoint = if recursive {
713 format!("/files{}?recursive=true", encoded_path)
714 } else {
715 format!("/files{}", encoded_path)
716 };
717
718 self.client.delete_raw(&endpoint).await?;
719 Ok(())
720 }
721
722 /// Copy a file
723 ///
724 /// This is a convenience method that calls `FileActionHandler::copy_file()`
725 ///
726 /// # Arguments
727 ///
728 /// * `source` - Source file path
729 /// * `destination` - Destination path
730 pub async fn copy_file(&self, source: &str, destination: &str) -> Result<()> {
731 let file_action = FileActionHandler::new(self.client.clone());
732 file_action.copy_file(source, destination).await
733 }
734
735 /// Move a file
736 ///
737 /// This is a convenience method that calls `FileActionHandler::move_file()`
738 ///
739 /// # Arguments
740 ///
741 /// * `source` - Source file path
742 /// * `destination` - Destination path
743 pub async fn move_file(&self, source: &str, destination: &str) -> Result<()> {
744 let file_action = FileActionHandler::new(self.client.clone());
745 file_action.move_file(source, destination).await
746 }
747
748 /// Upload an entire directory recursively
749 ///
750 /// Walks through a local directory and uploads all files to Files.com,
751 /// preserving the directory structure.
752 ///
753 /// # Arguments
754 ///
755 /// * `local_dir` - Local directory path to upload
756 /// * `remote_path` - Remote destination path on Files.com
757 /// * `mkdir_parents` - Create parent directories if they don't exist
758 ///
759 /// # Returns
760 ///
761 /// Vector of successfully uploaded remote file paths
762 ///
763 /// # Errors
764 ///
765 /// Returns an error if:
766 /// - Local directory doesn't exist or isn't readable
767 /// - Path contains invalid UTF-8
768 /// - Any file upload fails
769 ///
770 /// # Examples
771 ///
772 /// ```rust,no_run
773 /// use files_sdk::{FilesClient, FileHandler};
774 /// use std::path::Path;
775 ///
776 /// # #[tokio::main]
777 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
778 /// let client = FilesClient::builder().api_key("key").build()?;
779 /// let handler = FileHandler::new(client);
780 ///
781 /// let uploaded = handler.upload_directory(
782 /// Path::new("./local/images"),
783 /// "/remote/uploads",
784 /// true // create parent directories
785 /// ).await?;
786 ///
787 /// println!("Uploaded {} files", uploaded.len());
788 /// # Ok(())
789 /// # }
790 /// ```
791 pub async fn upload_directory(
792 &self,
793 local_dir: &Path,
794 remote_path: &str,
795 mkdir_parents: bool,
796 ) -> Result<Vec<String>> {
797 let mut uploaded = Vec::new();
798
799 for entry in WalkDir::new(local_dir).into_iter().filter_map(|e| e.ok()) {
800 if entry.file_type().is_file() {
801 let local_file = entry.path();
802
803 // Calculate relative path
804 let relative = local_file
805 .strip_prefix(local_dir)
806 .map_err(|e| FilesError::IoError(format!("Failed to strip prefix: {}", e)))?
807 .to_str()
808 .ok_or_else(|| {
809 FilesError::IoError(format!(
810 "Invalid UTF-8 in path: {}",
811 local_file.display()
812 ))
813 })?;
814
815 // Construct remote path (use forward slashes for Files.com)
816 let remote_file = format!(
817 "{}/{}",
818 remote_path.trim_end_matches('/'),
819 relative.replace('\\', "/")
820 );
821
822 // Read and upload
823 let data =
824 std::fs::read(local_file).map_err(|e| FilesError::IoError(e.to_string()))?;
825
826 // Upload using the same two-stage process as upload_file
827 let file_action = FileActionHandler::new(self.client.clone());
828 let upload_parts = file_action
829 .begin_upload(&remote_file, Some(data.len() as i64), mkdir_parents)
830 .await?;
831
832 if !upload_parts.is_empty() {
833 let upload_part = &upload_parts[0];
834 if let Some(upload_uri) = &upload_part.upload_uri {
835 let http_client = reqwest::Client::new();
836 let http_method = upload_part
837 .http_method
838 .as_deref()
839 .unwrap_or("PUT")
840 .to_uppercase();
841
842 let mut request = match http_method.as_str() {
843 "PUT" => http_client.put(upload_uri),
844 "POST" => http_client.post(upload_uri),
845 _ => http_client.put(upload_uri),
846 };
847
848 if let Some(headers) = &upload_part.headers {
849 for (key, value) in headers {
850 request = request.header(key, value);
851 }
852 }
853
854 request.body(data.to_vec()).send().await?;
855 }
856 }
857
858 uploaded.push(remote_file);
859 }
860 }
861
862 Ok(uploaded)
863 }
864
865 /// Upload directory with progress callback
866 ///
867 /// Same as `upload_directory` but calls a progress callback after each file upload.
868 /// Useful for showing upload progress in UIs or logging.
869 ///
870 /// # Arguments
871 ///
872 /// * `local_dir` - Local directory path to upload
873 /// * `remote_path` - Remote destination path on Files.com
874 /// * `mkdir_parents` - Create parent directories if they don't exist
875 /// * `progress` - Callback function called with (current_file_number, total_files)
876 ///
877 /// # Examples
878 ///
879 /// ```rust,no_run
880 /// use files_sdk::{FilesClient, FileHandler};
881 /// use std::path::Path;
882 ///
883 /// # #[tokio::main]
884 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
885 /// let client = FilesClient::builder().api_key("key").build()?;
886 /// let handler = FileHandler::new(client);
887 ///
888 /// handler.upload_directory_with_progress(
889 /// Path::new("./data"),
890 /// "/backups",
891 /// true,
892 /// |current, total| {
893 /// println!("Progress: {}/{} ({:.1}%)",
894 /// current, total, (current as f64 / total as f64) * 100.0);
895 /// }
896 /// ).await?;
897 /// # Ok(())
898 /// # }
899 /// ```
900 pub async fn upload_directory_with_progress<F>(
901 &self,
902 local_dir: &Path,
903 remote_path: &str,
904 mkdir_parents: bool,
905 progress: F,
906 ) -> Result<Vec<String>>
907 where
908 F: Fn(usize, usize),
909 {
910 // Count files first
911 let total_files = WalkDir::new(local_dir)
912 .into_iter()
913 .filter_map(|e| e.ok())
914 .filter(|e| e.file_type().is_file())
915 .count();
916
917 let mut uploaded = Vec::new();
918 let mut current = 0;
919
920 for entry in WalkDir::new(local_dir).into_iter().filter_map(|e| e.ok()) {
921 if entry.file_type().is_file() {
922 let local_file = entry.path();
923
924 // Calculate relative path
925 let relative = local_file
926 .strip_prefix(local_dir)
927 .map_err(|e| FilesError::IoError(format!("Failed to strip prefix: {}", e)))?
928 .to_str()
929 .ok_or_else(|| {
930 FilesError::IoError(format!(
931 "Invalid UTF-8 in path: {}",
932 local_file.display()
933 ))
934 })?;
935
936 // Construct remote path
937 let remote_file = format!(
938 "{}/{}",
939 remote_path.trim_end_matches('/'),
940 relative.replace('\\', "/")
941 );
942
943 // Read and upload
944 let data =
945 std::fs::read(local_file).map_err(|e| FilesError::IoError(e.to_string()))?;
946
947 // Upload using the same two-stage process as upload_file
948 let file_action = FileActionHandler::new(self.client.clone());
949 let upload_parts = file_action
950 .begin_upload(&remote_file, Some(data.len() as i64), mkdir_parents)
951 .await?;
952
953 if !upload_parts.is_empty() {
954 let upload_part = &upload_parts[0];
955 if let Some(upload_uri) = &upload_part.upload_uri {
956 let http_client = reqwest::Client::new();
957 let http_method = upload_part
958 .http_method
959 .as_deref()
960 .unwrap_or("PUT")
961 .to_uppercase();
962
963 let mut request = match http_method.as_str() {
964 "PUT" => http_client.put(upload_uri),
965 "POST" => http_client.post(upload_uri),
966 _ => http_client.put(upload_uri),
967 };
968
969 if let Some(headers) = &upload_part.headers {
970 for (key, value) in headers {
971 request = request.header(key, value);
972 }
973 }
974
975 request.body(data.to_vec()).send().await?;
976 }
977 }
978
979 uploaded.push(remote_file);
980 current += 1;
981 progress(current, total_files);
982 }
983 }
984
985 Ok(uploaded)
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn test_handler_creation() {
995 let client = FilesClient::builder().api_key("test-key").build().unwrap();
996 let _handler = FileHandler::new(client);
997 }
998}