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}