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::types::FileEntity;
15use crate::utils::encode_path;
16use crate::{FilesClient, FilesError, Result};
17use serde_json::json;
18use std::collections::HashMap;
19use std::path::Path;
20use walkdir::WalkDir;
21
22/// Handler for file operations
23///
24/// Provides methods for downloading, uploading, updating, and deleting files.
25#[derive(Debug, Clone)]
26pub struct FileHandler {
27    client: FilesClient,
28}
29
30impl FileHandler {
31    /// Creates a new FileHandler
32    ///
33    /// # Arguments
34    ///
35    /// * `client` - FilesClient instance
36    pub fn new(client: FilesClient) -> Self {
37        Self { client }
38    }
39
40    /// Download a file or get file information
41    ///
42    /// # Arguments
43    ///
44    /// * `path` - File path to download
45    ///
46    /// # Returns
47    ///
48    /// Returns a `FileEntity` containing file information including a
49    /// `download_uri` for the actual file download.
50    ///
51    /// # Examples
52    ///
53    /// ```rust,no_run
54    /// use files_sdk::{FilesClient, FileHandler};
55    ///
56    /// # #[tokio::main]
57    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
58    /// let client = FilesClient::builder()
59    ///     .api_key("your-api-key")
60    ///     .build()?;
61    ///
62    /// let handler = FileHandler::new(client);
63    /// let file = handler.download_file("/path/to/file.txt").await?;
64    ///
65    /// if let Some(uri) = file.download_uri {
66    ///     println!("Download from: {}", uri);
67    /// }
68    /// # Ok(())
69    /// # }
70    /// ```
71    pub async fn download_file(&self, path: &str) -> Result<FileEntity> {
72        let encoded_path = encode_path(path);
73        let endpoint = format!("/files{}", encoded_path);
74        let response = self.client.get_raw(&endpoint).await?;
75        Ok(serde_json::from_value(response)?)
76    }
77
78    /// Download the actual file content as bytes
79    ///
80    /// Unlike `download_file()` which returns metadata with a download URL,
81    /// this method fetches and returns the actual file content.
82    ///
83    /// # Arguments
84    ///
85    /// * `path` - File path to download
86    ///
87    /// # Returns
88    ///
89    /// Returns the file content as a `Vec<u8>`
90    ///
91    /// # Examples
92    ///
93    /// ```rust,no_run
94    /// use files_sdk::{FilesClient, FileHandler};
95    ///
96    /// # #[tokio::main]
97    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
98    /// let client = FilesClient::builder()
99    ///     .api_key("your-api-key")
100    ///     .build()?;
101    ///
102    /// let handler = FileHandler::new(client);
103    /// let content = handler.download_content("/path/to/file.txt").await?;
104    /// println!("Downloaded {} bytes", content.len());
105    /// # Ok(())
106    /// # }
107    /// ```
108    pub async fn download_content(&self, path: &str) -> Result<Vec<u8>> {
109        // First, get the file metadata to obtain the download URI
110        let file = self.download_file(path).await?;
111
112        // Extract the download URI
113        let download_uri = file.download_uri.ok_or_else(|| {
114            FilesError::not_found_resource("No download URI available", "file", path)
115        })?;
116
117        // Fetch the actual file content from the download URI
118        let response = reqwest::get(&download_uri)
119            .await
120            .map_err(FilesError::Request)?;
121
122        let bytes = response.bytes().await.map_err(FilesError::Request)?;
123
124        Ok(bytes.to_vec())
125    }
126
127    /// Download file content and save to a local file
128    ///
129    /// This is a convenience method that downloads the file content and
130    /// writes it to the specified local path.
131    ///
132    /// # Arguments
133    ///
134    /// * `remote_path` - Path to the file on Files.com
135    /// * `local_path` - Local filesystem path where the file should be saved
136    ///
137    /// # Examples
138    ///
139    /// ```rust,no_run
140    /// use files_sdk::{FilesClient, FileHandler};
141    /// use std::path::Path;
142    ///
143    /// # #[tokio::main]
144    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
145    /// let client = FilesClient::builder()
146    ///     .api_key("your-api-key")
147    ///     .build()?;
148    ///
149    /// let handler = FileHandler::new(client);
150    /// handler.download_to_file(
151    ///     "/path/to/remote/file.txt",
152    ///     Path::new("./local/file.txt")
153    /// ).await?;
154    /// # Ok(())
155    /// # }
156    /// ```
157    pub async fn download_to_file(
158        &self,
159        remote_path: &str,
160        local_path: &std::path::Path,
161    ) -> Result<()> {
162        let content = self.download_content(remote_path).await?;
163        std::fs::write(local_path, content)
164            .map_err(|e| FilesError::IoError(format!("Failed to write file: {}", e)))?;
165        Ok(())
166    }
167
168    /// Get file metadata only (no download URL, no logging)
169    ///
170    /// This is a convenience method that calls `FileActionHandler::get_metadata()`
171    ///
172    /// # Arguments
173    ///
174    /// * `path` - File path
175    pub async fn get_metadata(&self, path: &str) -> Result<FileEntity> {
176        let file_action = FileActionHandler::new(self.client.clone());
177        file_action.get_metadata(path).await
178    }
179
180    /// Upload a file (complete two-stage upload process)
181    ///
182    /// This method handles the complete upload process:
183    /// 1. Calls begin_upload to get upload URLs
184    /// 2. Uploads the file data
185    /// 3. Finalizes the upload
186    ///
187    /// # Arguments
188    ///
189    /// * `path` - Destination path for the file
190    /// * `data` - File contents as bytes
191    ///
192    /// # Examples
193    ///
194    /// ```rust,no_run
195    /// # use files_sdk::{FilesClient, FileHandler};
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 data = b"Hello, Files.com!";
202    /// let file = handler.upload_file("/uploads/test.txt", data).await?;
203    /// println!("Uploaded: {:?}", file.path);
204    /// # Ok(())
205    /// # }
206    /// ```
207    pub async fn upload_file(&self, path: &str, data: &[u8]) -> Result<FileEntity> {
208        // Stage 1: Begin upload
209        let file_action = FileActionHandler::new(self.client.clone());
210        let upload_parts = file_action
211            .begin_upload(path, Some(data.len() as i64), true)
212            .await?;
213
214        if upload_parts.is_empty() {
215            return Err(crate::FilesError::ApiError {
216                endpoint: None,
217                code: 500,
218                message: "No upload parts returned from begin_upload".to_string(),
219            });
220        }
221
222        let upload_part = &upload_parts[0];
223
224        // Stage 2: Upload file data to the provided URL
225        // This is an external URL (not Files.com API), typically to cloud storage
226        let _etag = if let Some(upload_uri) = &upload_part.upload_uri {
227            let http_client = reqwest::Client::new();
228            let http_method = upload_part
229                .http_method
230                .as_deref()
231                .unwrap_or("PUT")
232                .to_uppercase();
233
234            let mut request = match http_method.as_str() {
235                "PUT" => http_client.put(upload_uri),
236                "POST" => http_client.post(upload_uri),
237                _ => http_client.put(upload_uri),
238            };
239
240            // Add any custom headers
241            if let Some(headers) = &upload_part.headers {
242                for (key, value) in headers {
243                    request = request.header(key, value);
244                }
245            }
246
247            // Upload the file data and capture the response
248            let upload_response = request.body(data.to_vec()).send().await?;
249
250            // Extract ETag from response headers
251            upload_response
252                .headers()
253                .get("etag")
254                .and_then(|v| v.to_str().ok())
255                .map(|s| s.trim_matches('"').to_string())
256        } else {
257            None
258        };
259
260        // Stage 3: Finalize upload with Files.com
261        let encoded_path = encode_path(path);
262        let endpoint = format!("/files{}", encoded_path);
263
264        // Build the finalization request as form data
265        let mut form = vec![("action", "end".to_string())];
266
267        // Add ref (upload reference) - this is required to identify the upload
268        if let Some(ref_value) = &upload_part.ref_ {
269            form.push(("ref", ref_value.clone()));
270        }
271
272        // Note: etags might not be needed when ref is provided
273        // Commenting out for now to test
274        // if let Some(etag_value) = etag {
275        //     let part_number = upload_part.part_number.unwrap_or(1);
276        //     form.push(("etags[etag]", etag_value));
277        //     form.push(("etags[part]", part_number.to_string()));
278        // }
279
280        let response = self.client.post_form(&endpoint, &form).await?;
281        Ok(serde_json::from_value(response)?)
282    }
283
284    /// Update file metadata
285    ///
286    /// # Arguments
287    ///
288    /// * `path` - File path
289    /// * `custom_metadata` - Custom metadata key-value pairs (optional)
290    /// * `provided_mtime` - Custom modification time (optional)
291    /// * `priority_color` - Priority color (optional)
292    ///
293    /// # Examples
294    ///
295    /// ```rust,no_run
296    /// # use files_sdk::{FilesClient, FileHandler};
297    /// # use std::collections::HashMap;
298    /// # #[tokio::main]
299    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
300    /// # let client = FilesClient::builder().api_key("key").build()?;
301    /// let handler = FileHandler::new(client);
302    ///
303    /// let mut metadata = HashMap::new();
304    /// metadata.insert("category".to_string(), "reports".to_string());
305    ///
306    /// handler.update_file("/path/to/file.txt", Some(metadata), None, None).await?;
307    /// # Ok(())
308    /// # }
309    /// ```
310    pub async fn update_file(
311        &self,
312        path: &str,
313        custom_metadata: Option<HashMap<String, String>>,
314        provided_mtime: Option<String>,
315        priority_color: Option<String>,
316    ) -> Result<FileEntity> {
317        let mut body = json!({});
318
319        if let Some(metadata) = custom_metadata {
320            body["custom_metadata"] = json!(metadata);
321        }
322
323        if let Some(mtime) = provided_mtime {
324            body["provided_mtime"] = json!(mtime);
325        }
326
327        if let Some(color) = priority_color {
328            body["priority_color"] = json!(color);
329        }
330
331        let encoded_path = encode_path(path);
332        let endpoint = format!("/files{}", encoded_path);
333        let response = self.client.patch_raw(&endpoint, body).await?;
334        Ok(serde_json::from_value(response)?)
335    }
336
337    /// Delete a file
338    ///
339    /// # Arguments
340    ///
341    /// * `path` - File path to delete
342    /// * `recursive` - If path is a folder, delete recursively
343    ///
344    /// # Examples
345    ///
346    /// ```rust,no_run
347    /// # use files_sdk::{FilesClient, FileHandler};
348    /// # #[tokio::main]
349    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
350    /// # let client = FilesClient::builder().api_key("key").build()?;
351    /// let handler = FileHandler::new(client);
352    /// handler.delete_file("/path/to/file.txt", false).await?;
353    /// # Ok(())
354    /// # }
355    /// ```
356    pub async fn delete_file(&self, path: &str, recursive: bool) -> Result<()> {
357        let encoded_path = encode_path(path);
358        let endpoint = if recursive {
359            format!("/files{}?recursive=true", encoded_path)
360        } else {
361            format!("/files{}", encoded_path)
362        };
363
364        self.client.delete_raw(&endpoint).await?;
365        Ok(())
366    }
367
368    /// Copy a file
369    ///
370    /// This is a convenience method that calls `FileActionHandler::copy_file()`
371    ///
372    /// # Arguments
373    ///
374    /// * `source` - Source file path
375    /// * `destination` - Destination path
376    pub async fn copy_file(&self, source: &str, destination: &str) -> Result<()> {
377        let file_action = FileActionHandler::new(self.client.clone());
378        file_action.copy_file(source, destination).await
379    }
380
381    /// Move a file
382    ///
383    /// This is a convenience method that calls `FileActionHandler::move_file()`
384    ///
385    /// # Arguments
386    ///
387    /// * `source` - Source file path
388    /// * `destination` - Destination path
389    pub async fn move_file(&self, source: &str, destination: &str) -> Result<()> {
390        let file_action = FileActionHandler::new(self.client.clone());
391        file_action.move_file(source, destination).await
392    }
393
394    /// Upload an entire directory recursively
395    ///
396    /// Walks through a local directory and uploads all files to Files.com,
397    /// preserving the directory structure.
398    ///
399    /// # Arguments
400    ///
401    /// * `local_dir` - Local directory path to upload
402    /// * `remote_path` - Remote destination path on Files.com
403    /// * `mkdir_parents` - Create parent directories if they don't exist
404    ///
405    /// # Returns
406    ///
407    /// Vector of successfully uploaded remote file paths
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if:
412    /// - Local directory doesn't exist or isn't readable
413    /// - Path contains invalid UTF-8
414    /// - Any file upload fails
415    ///
416    /// # Examples
417    ///
418    /// ```rust,no_run
419    /// use files_sdk::{FilesClient, FileHandler};
420    /// use std::path::Path;
421    ///
422    /// # #[tokio::main]
423    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
424    /// let client = FilesClient::builder().api_key("key").build()?;
425    /// let handler = FileHandler::new(client);
426    ///
427    /// let uploaded = handler.upload_directory(
428    ///     Path::new("./local/images"),
429    ///     "/remote/uploads",
430    ///     true  // create parent directories
431    /// ).await?;
432    ///
433    /// println!("Uploaded {} files", uploaded.len());
434    /// # Ok(())
435    /// # }
436    /// ```
437    pub async fn upload_directory(
438        &self,
439        local_dir: &Path,
440        remote_path: &str,
441        mkdir_parents: bool,
442    ) -> Result<Vec<String>> {
443        let mut uploaded = Vec::new();
444
445        for entry in WalkDir::new(local_dir).into_iter().filter_map(|e| e.ok()) {
446            if entry.file_type().is_file() {
447                let local_file = entry.path();
448
449                // Calculate relative path
450                let relative = local_file
451                    .strip_prefix(local_dir)
452                    .map_err(|e| FilesError::IoError(format!("Failed to strip prefix: {}", e)))?
453                    .to_str()
454                    .ok_or_else(|| {
455                        FilesError::IoError(format!(
456                            "Invalid UTF-8 in path: {}",
457                            local_file.display()
458                        ))
459                    })?;
460
461                // Construct remote path (use forward slashes for Files.com)
462                let remote_file = format!(
463                    "{}/{}",
464                    remote_path.trim_end_matches('/'),
465                    relative.replace('\\', "/")
466                );
467
468                // Read and upload
469                let data =
470                    std::fs::read(local_file).map_err(|e| FilesError::IoError(e.to_string()))?;
471
472                // Upload using the same two-stage process as upload_file
473                let file_action = FileActionHandler::new(self.client.clone());
474                let upload_parts = file_action
475                    .begin_upload(&remote_file, Some(data.len() as i64), mkdir_parents)
476                    .await?;
477
478                if !upload_parts.is_empty() {
479                    let upload_part = &upload_parts[0];
480                    if let Some(upload_uri) = &upload_part.upload_uri {
481                        let http_client = reqwest::Client::new();
482                        let http_method = upload_part
483                            .http_method
484                            .as_deref()
485                            .unwrap_or("PUT")
486                            .to_uppercase();
487
488                        let mut request = match http_method.as_str() {
489                            "PUT" => http_client.put(upload_uri),
490                            "POST" => http_client.post(upload_uri),
491                            _ => http_client.put(upload_uri),
492                        };
493
494                        if let Some(headers) = &upload_part.headers {
495                            for (key, value) in headers {
496                                request = request.header(key, value);
497                            }
498                        }
499
500                        request.body(data.to_vec()).send().await?;
501                    }
502                }
503
504                uploaded.push(remote_file);
505            }
506        }
507
508        Ok(uploaded)
509    }
510
511    /// Upload directory with progress callback
512    ///
513    /// Same as `upload_directory` but calls a progress callback after each file upload.
514    /// Useful for showing upload progress in UIs or logging.
515    ///
516    /// # Arguments
517    ///
518    /// * `local_dir` - Local directory path to upload
519    /// * `remote_path` - Remote destination path on Files.com
520    /// * `mkdir_parents` - Create parent directories if they don't exist
521    /// * `progress` - Callback function called with (current_file_number, total_files)
522    ///
523    /// # Examples
524    ///
525    /// ```rust,no_run
526    /// use files_sdk::{FilesClient, FileHandler};
527    /// use std::path::Path;
528    ///
529    /// # #[tokio::main]
530    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
531    /// let client = FilesClient::builder().api_key("key").build()?;
532    /// let handler = FileHandler::new(client);
533    ///
534    /// handler.upload_directory_with_progress(
535    ///     Path::new("./data"),
536    ///     "/backups",
537    ///     true,
538    ///     |current, total| {
539    ///         println!("Progress: {}/{} ({:.1}%)",
540    ///             current, total, (current as f64 / total as f64) * 100.0);
541    ///     }
542    /// ).await?;
543    /// # Ok(())
544    /// # }
545    /// ```
546    pub async fn upload_directory_with_progress<F>(
547        &self,
548        local_dir: &Path,
549        remote_path: &str,
550        mkdir_parents: bool,
551        progress: F,
552    ) -> Result<Vec<String>>
553    where
554        F: Fn(usize, usize),
555    {
556        // Count files first
557        let total_files = WalkDir::new(local_dir)
558            .into_iter()
559            .filter_map(|e| e.ok())
560            .filter(|e| e.file_type().is_file())
561            .count();
562
563        let mut uploaded = Vec::new();
564        let mut current = 0;
565
566        for entry in WalkDir::new(local_dir).into_iter().filter_map(|e| e.ok()) {
567            if entry.file_type().is_file() {
568                let local_file = entry.path();
569
570                // Calculate relative path
571                let relative = local_file
572                    .strip_prefix(local_dir)
573                    .map_err(|e| FilesError::IoError(format!("Failed to strip prefix: {}", e)))?
574                    .to_str()
575                    .ok_or_else(|| {
576                        FilesError::IoError(format!(
577                            "Invalid UTF-8 in path: {}",
578                            local_file.display()
579                        ))
580                    })?;
581
582                // Construct remote path
583                let remote_file = format!(
584                    "{}/{}",
585                    remote_path.trim_end_matches('/'),
586                    relative.replace('\\', "/")
587                );
588
589                // Read and upload
590                let data =
591                    std::fs::read(local_file).map_err(|e| FilesError::IoError(e.to_string()))?;
592
593                // Upload using the same two-stage process as upload_file
594                let file_action = FileActionHandler::new(self.client.clone());
595                let upload_parts = file_action
596                    .begin_upload(&remote_file, Some(data.len() as i64), mkdir_parents)
597                    .await?;
598
599                if !upload_parts.is_empty() {
600                    let upload_part = &upload_parts[0];
601                    if let Some(upload_uri) = &upload_part.upload_uri {
602                        let http_client = reqwest::Client::new();
603                        let http_method = upload_part
604                            .http_method
605                            .as_deref()
606                            .unwrap_or("PUT")
607                            .to_uppercase();
608
609                        let mut request = match http_method.as_str() {
610                            "PUT" => http_client.put(upload_uri),
611                            "POST" => http_client.post(upload_uri),
612                            _ => http_client.put(upload_uri),
613                        };
614
615                        if let Some(headers) = &upload_part.headers {
616                            for (key, value) in headers {
617                                request = request.header(key, value);
618                            }
619                        }
620
621                        request.body(data.to_vec()).send().await?;
622                    }
623                }
624
625                uploaded.push(remote_file);
626                current += 1;
627                progress(current, total_files);
628            }
629        }
630
631        Ok(uploaded)
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_handler_creation() {
641        let client = FilesClient::builder().api_key("test-key").build().unwrap();
642        let _handler = FileHandler::new(client);
643    }
644}