vkteams_bot_cli/
file_utils.rs

1use crate::config::CONFIG;
2use crate::errors::prelude::{CliError, Result as CliResult};
3use crate::progress;
4use crate::utils::{validate_directory_path, validate_file_path};
5use futures::StreamExt;
6use std::fmt::Debug;
7use std::path::PathBuf;
8use tokio::io::AsyncWriteExt;
9use tracing::{debug, info};
10use vkteams_bot::prelude::*;
11
12// Validation functions are now imported from utils/validation module
13
14// TODO: Enable this function when we need streaming file uploads
15// /// Streams a file from disk for uploading
16// ///
17// /// # Errors
18// /// - Returns `CliError::FileError` if the file doesn't exist or cannot be opened
19// pub async fn read_file_stream(file_path: &str) -> CliResult<tokio::fs::File> {
20//     validate_file_path(file_path)?;
21//
22//     let file = tokio::fs::File::open(file_path)
23//         .await
24//         .map_err(|e| CliError::FileError(format!("Failed to open file {file_path}: {e}")))?;
25//
26//     Ok(file)
27// }
28
29/// Stream downloads a file and saves it to disk
30///
31/// # Errors
32/// - Returns `CliError::FileError` if there are issues with file operations
33/// - Returns `CliError::ApiError` if there are issues with the API
34pub async fn download_and_save_file(
35    bot: &Bot,
36    file_id: &str,
37    dir_path: &str,
38) -> CliResult<PathBuf> {
39    let cfg = &CONFIG.files;
40    // Use directory from path or config or current directory
41    let target_dir = if !dir_path.is_empty() {
42        dir_path.to_string()
43    } else if let Some(download_dir) = &cfg.download_dir {
44        download_dir.clone()
45    } else {
46        ".".to_string()
47    };
48
49    validate_directory_path(&target_dir)?;
50
51    debug!("Getting file info for file ID: {}", file_id);
52    let file_info = bot
53        .send_api_request(RequestFilesGetInfo::new(FileId(file_id.to_string())))
54        .await
55        .map_err(CliError::ApiError)?;
56
57    let mut file_path = PathBuf::from(&target_dir);
58    file_path.push(&file_info.file_name);
59
60    debug!("Creating file at path: {}", file_path.display());
61    let file = tokio::fs::File::create(&file_path).await.map_err(|e| {
62        CliError::FileError(format!(
63            "Failed to create file {}: {}",
64            file_path.display(),
65            e
66        ))
67    })?;
68
69    debug!("Starting file download stream");
70    let client = reqwest::Client::new();
71    let url = file_info.url.clone();
72
73    let response = client
74        .get(url)
75        .send()
76        .await
77        .map_err(|e| CliError::FileError(format!("Failed to initiate download: {e}")))?;
78
79    if !response.status().is_success() {
80        return Err(CliError::FileError(format!(
81            "Failed to download file, status code: {}",
82            response.status()
83        )));
84    }
85
86    let total_size = response.content_length().unwrap_or(0);
87
88    if total_size > cfg.max_file_size as u64 {
89        return Err(CliError::FileError(format!(
90            "File size exceeds maximum allowed size of {} bytes",
91            cfg.max_file_size
92        )));
93    }
94
95    let mut file_writer = tokio::io::BufWriter::with_capacity(cfg.buffer_size, file);
96
97    let mut stream = response.bytes_stream();
98    let mut downloaded: u64 = 0;
99
100    // Create a progress bar for the download
101    let progress_bar = progress::create_download_progress_bar(total_size, &file_info.file_name);
102
103    debug!("Streaming file content to disk");
104    while let Some(chunk_result) = stream.next().await {
105        let chunk = chunk_result.map_err(|e| {
106            progress::abandon_progress(&progress_bar, "Download failed");
107            CliError::FileError(format!("Error during download: {e}"))
108        })?;
109
110        file_writer.write_all(&chunk).await.map_err(|e| {
111            progress::abandon_progress(&progress_bar, "Write failed");
112            CliError::FileError(format!("Failed to write to file: {e}"))
113        })?;
114
115        downloaded += chunk.len() as u64;
116        progress::increment_progress(&progress_bar, chunk.len() as u64);
117
118        // Log progress for large files (if progress bar is disabled)
119        if !&CONFIG.ui.show_progress
120            && total_size > 1024 * 1024
121            && downloaded % (1024 * 1024) < chunk.len() as u64
122        {
123            let downloaded_mb = {
124                #[allow(clippy::cast_precision_loss)]
125                let val = (downloaded / 1_048_576) as f64;
126                val
127            };
128            let total_mb = {
129                #[allow(clippy::cast_precision_loss)]
130                let val = (total_size / 1_048_576) as f64;
131                val
132            };
133            info!(
134                "Download progress: {:.1}MB / {:.1}MB",
135                downloaded_mb, total_mb
136            );
137        }
138    }
139
140    debug!("Flushing and finalizing file");
141    file_writer.flush().await.map_err(|e| {
142        progress::abandon_progress(&progress_bar, "File flush failed");
143        CliError::FileError(format!("Failed to flush file data: {e}"))
144    })?;
145
146    progress::finish_progress(
147        &progress_bar,
148        &format!("Downloaded to {}", file_path.display()),
149    );
150    info!("Successfully downloaded file to: {}", file_path.display());
151    Ok(file_path)
152}
153
154/// Stream uploads a file to the API
155///
156/// # Errors
157/// - Returns `CliError::InputError` if no file path provided
158/// - Returns `CliError::FileError` if the file doesn't exist or is not accessible
159/// - Returns `CliError::ApiError` if there are issues with the API
160pub async fn upload_file(
161    bot: &Bot,
162    user_id: &str,
163    file_path: &str,
164) -> CliResult<impl serde::Serialize + Debug> {
165    let cfg = &CONFIG.files;
166    // Use file path from arguments or config
167    let source_path = if !file_path.is_empty() {
168        file_path.to_string()
169    } else if let Some(upload_dir) = &cfg.upload_dir {
170        upload_dir.clone()
171    } else {
172        return Err(CliError::InputError(
173            "No file path provided and no default upload directory configured".to_string(),
174        ));
175    };
176
177    validate_file_path(&source_path)?;
178
179    debug!("Preparing to upload file: {}", source_path);
180
181    // Get the file size for the progress bar
182    let file_size = match progress::calculate_upload_size(&source_path) {
183        Ok(size) => size,
184        Err(e) => {
185            debug!("Could not determine file size: {}", e);
186            0 // If we can't determine size, progress bar will be indeterminate
187        }
188    };
189
190    // Create a progress bar for upload
191    let progress_bar = progress::create_upload_progress_bar(file_size, &source_path);
192
193    // Start the upload
194    let result = match bot
195        .send_api_request(RequestMessagesSendFile::new((
196            ChatId::from_borrowed_str(user_id),
197            MultipartName::FilePath(source_path.to_string()),
198        )))
199        .await
200    {
201        Ok(res) => {
202            progress::finish_progress(&progress_bar, "Upload complete");
203            res
204        }
205        Err(e) => {
206            progress::abandon_progress(&progress_bar, "Upload failed");
207            return Err(CliError::ApiError(e));
208        }
209    };
210
211    info!("Successfully uploaded file: {}", source_path);
212    Ok(result)
213}
214
215/// Stream uploads a voice message to the API
216///
217/// # Errors
218/// - Returns `CliError::InputError` if no file path provided
219/// - Returns `CliError::FileError` if the file doesn't exist or is not accessible
220/// - Returns `CliError::ApiError` if there are issues with the API
221pub async fn upload_voice(
222    bot: &Bot,
223    user_id: &str,
224    file_path: &str,
225) -> CliResult<impl serde::Serialize + Debug> {
226    let cfg = &CONFIG.files;
227    // Use file path from arguments or config
228    let source_path = if !file_path.is_empty() {
229        file_path.to_string()
230    } else if let Some(upload_dir) = &cfg.upload_dir {
231        upload_dir.clone()
232    } else {
233        return Err(CliError::InputError(
234            "No file path provided and no default upload directory configured".to_string(),
235        ));
236    };
237
238    validate_file_path(&source_path)?;
239
240    debug!("Preparing to upload voice message: {}", source_path);
241
242    // Get the file size for the progress bar
243    let file_size = match progress::calculate_upload_size(&source_path) {
244        Ok(size) => size,
245        Err(e) => {
246            debug!("Could not determine file size: {}", e);
247            0 // If we can't determine size, progress bar will be indeterminate
248        }
249    };
250
251    // Create a progress bar for upload
252    let progress_bar = progress::create_upload_progress_bar(file_size, &source_path);
253
254    // Start the voice upload
255    let result = match bot
256        .send_api_request(RequestMessagesSendVoice::new((
257            ChatId::from_borrowed_str(user_id),
258            MultipartName::FilePath(source_path.to_string()),
259        )))
260        .await
261    {
262        Ok(res) => {
263            progress::finish_progress(&progress_bar, "Voice upload complete");
264            res
265        }
266        Err(e) => {
267            progress::abandon_progress(&progress_bar, "Voice upload failed");
268            return Err(CliError::ApiError(e));
269        }
270    };
271
272    info!("Successfully uploaded voice message: {}", source_path);
273    Ok(result)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::utils::create_dummy_bot;
280    use proptest::prelude::*;
281    use std::fs;
282    use tempfile::tempdir;
283    use tokio_test::block_on;
284
285    #[tokio::test]
286    async fn test_upload_file_empty_path() {
287        let bot = create_dummy_bot();
288        let res = upload_file(&bot, "user123", "").await;
289        assert!(res.is_err());
290    }
291
292    #[tokio::test]
293    async fn test_upload_file_nonexistent() {
294        let bot = create_dummy_bot();
295        let res = upload_file(&bot, "user123", "no_such_file.txt").await;
296        assert!(res.is_err());
297    }
298
299    #[tokio::test]
300    async fn test_upload_voice_invalid_format() {
301        let bot = create_dummy_bot();
302        let temp_dir = tempdir().unwrap();
303        let file_path = temp_dir.path().join("voice.txt");
304        fs::write(&file_path, "test").unwrap();
305        let res = upload_voice(&bot, "user123", file_path.to_str().unwrap()).await;
306        assert!(res.is_err());
307    }
308
309    #[tokio::test]
310    async fn test_download_and_save_file_invalid_dir() {
311        let bot = create_dummy_bot();
312        let res = download_and_save_file(&bot, "fileid123", "/no/such/dir").await;
313        assert!(res.is_err());
314    }
315
316    proptest! {
317        #[test]
318        fn prop_upload_file_random_path(user_id in ".{0,32}", file_path in ".{0,128}") {
319            let bot = create_dummy_bot();
320            let fut = upload_file(&bot, &user_id, &file_path);
321            let res = block_on(fut);
322            prop_assert!(res.is_err());
323        }
324
325        #[test]
326        fn prop_upload_voice_random_path(user_id in ".{0,32}", file_path in ".{0,128}") {
327            let bot = create_dummy_bot();
328            let fut = upload_voice(&bot, &user_id, &file_path);
329            let res = block_on(fut);
330            prop_assert!(res.is_err());
331        }
332    }
333}
334
335#[cfg(test)]
336mod more_edge_tests {
337    use super::*;
338    use std::fs::{self, File};
339    use std::io::Write;
340    use std::os::unix::fs::PermissionsExt;
341    use tempfile::tempdir;
342
343    #[tokio::test]
344    async fn test_download_and_save_file_api_error() {
345        let bot =
346            Bot::with_params(&APIVersionUrl::V1, "dummy_token", "https://dummy.api.com").unwrap();
347        let tmp = tempdir().unwrap();
348        let res = download_and_save_file(&bot, "fileid", tmp.path().to_str().unwrap()).await;
349        assert!(res.is_err());
350    }
351
352    #[tokio::test]
353    async fn test_download_and_save_file_write_error() {
354        let bot =
355            Bot::with_params(&APIVersionUrl::V1, "dummy_token", "https://dummy.api.com").unwrap();
356        let tmp = tempdir().unwrap();
357        let dir = tmp.path().join("readonly");
358        fs::create_dir(&dir).unwrap();
359        let mut perms = fs::metadata(&dir).unwrap().permissions();
360        perms.set_mode(0o400); // read-only
361        fs::set_permissions(&dir, perms).unwrap();
362        let res = download_and_save_file(&bot, "fileid", dir.to_str().unwrap()).await;
363        assert!(res.is_err());
364    }
365
366    #[tokio::test]
367    async fn test_upload_file_api_error() {
368        let bot =
369            Bot::with_params(&APIVersionUrl::V1, "dummy_token", "https://dummy.api.com").unwrap();
370        let tmp = tempdir().unwrap();
371        let file_path = tmp.path().join("file.txt");
372        File::create(&file_path).unwrap();
373        let res = upload_file(&bot, "user123", file_path.to_str().unwrap()).await;
374        assert!(res.is_err());
375    }
376
377    #[tokio::test]
378    async fn test_upload_file_too_large() {
379        let bot =
380            Bot::with_params(&APIVersionUrl::V1, "dummy_token", "https://dummy.api.com").unwrap();
381        let tmp = tempdir().unwrap();
382        let file_path = tmp.path().join("bigfile.bin");
383        let mut f = File::create(&file_path).unwrap();
384        f.write_all(&vec![0u8; 200 * 1024 * 1024]).unwrap(); // 200MB
385        let res = upload_file(&bot, "user123", file_path.to_str().unwrap()).await;
386        assert!(res.is_err());
387    }
388}
389
390#[cfg(test)]
391mod happy_path_tests {
392    use super::*;
393    use crate::utils::create_dummy_bot;
394    use std::fs;
395    use tempfile::tempdir;
396
397    #[tokio::test]
398    async fn test_upload_file_success() {
399        let bot = create_dummy_bot();
400        let temp_dir = tempdir().unwrap();
401        let file_path = temp_dir.path().join("file.txt");
402        fs::write(&file_path, "test").unwrap();
403        // This will fail on real API, but for dummy bot we expect an error or Ok depending on mock
404        let _ = upload_file(&bot, "user123", file_path.to_str().unwrap()).await;
405        // No panic means the function handles the flow
406    }
407
408    #[tokio::test]
409    async fn test_upload_voice_success() {
410        let bot = create_dummy_bot();
411        let temp_dir = tempdir().unwrap();
412        let file_path = temp_dir.path().join("voice.ogg");
413        fs::write(&file_path, "test").unwrap();
414        let _ = upload_voice(&bot, "user123", file_path.to_str().unwrap()).await;
415    }
416
417    #[tokio::test]
418    async fn test_download_and_save_file_success() {
419        let bot = create_dummy_bot();
420        let temp_dir = tempdir().unwrap();
421        // File ID is dummy, but function should handle the flow without panic
422        let _ = download_and_save_file(&bot, "fileid123", temp_dir.path().to_str().unwrap()).await;
423    }
424}