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}