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
12pub 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 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 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 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
154pub 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 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 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 }
188 };
189
190 let progress_bar = progress::create_upload_progress_bar(file_size, &source_path);
192
193 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
215pub 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 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 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 }
249 };
250
251 let progress_bar = progress::create_upload_progress_bar(file_size, &source_path);
253
254 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); 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(); 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 let _ = upload_file(&bot, "user123", file_path.to_str().unwrap()).await;
405 }
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 let _ = download_and_save_file(&bot, "fileid123", temp_dir.path().to_str().unwrap()).await;
423 }
424}