vkteams_bot_cli/commands/
files.rs

1//! File upload and management commands
2
3use crate::commands::{Command, OutputFormat};
4use crate::errors::prelude::{CliError, Result as CliResult};
5use crate::output::{CliResponse, OutputFormatter};
6use async_trait::async_trait;
7use base64::Engine;
8use clap::{Args, Subcommand};
9use serde_json::json;
10use vkteams_bot::prelude::*;
11
12#[derive(Debug, Clone, Subcommand)]
13pub enum FileCommands {
14    /// Upload file from base64 content
15    Upload(UploadFileArgs),
16    /// Upload text content as file
17    UploadText(UploadTextArgs),
18    /// Upload JSON data as file
19    UploadJson(UploadJsonArgs),
20    /// Get file information
21    Info(FileInfoArgs),
22}
23
24#[derive(Debug, Clone, Args)]
25pub struct UploadFileArgs {
26    /// File name with extension
27    #[arg(long)]
28    pub name: String,
29
30    /// Base64 encoded file content
31    #[arg(long)]
32    pub content_base64: String,
33
34    /// Optional caption/text message
35    #[arg(long)]
36    pub caption: Option<String>,
37
38    /// Reply to message ID
39    #[arg(long)]
40    pub reply_msg_id: Option<String>,
41
42    /// Chat ID (will use default from config if not provided)
43    #[arg(long)]
44    pub chat_id: Option<String>,
45}
46
47#[derive(Debug, Clone, Args)]
48pub struct UploadTextArgs {
49    /// File name with extension
50    #[arg(long)]
51    pub name: String,
52
53    /// Text content to save as file
54    #[arg(long)]
55    pub content: String,
56
57    /// Optional caption/description
58    #[arg(long)]
59    pub caption: Option<String>,
60
61    /// Reply to message ID
62    #[arg(long)]
63    pub reply_msg_id: Option<String>,
64
65    /// Chat ID (will use default from config if not provided)
66    #[arg(long)]
67    pub chat_id: Option<String>,
68}
69
70#[derive(Debug, Clone, Args)]
71pub struct UploadJsonArgs {
72    /// File name (will add .json extension if missing)
73    #[arg(long)]
74    pub name: String,
75
76    /// JSON data as string
77    #[arg(long)]
78    pub json_data: String,
79
80    /// Pretty print JSON
81    #[arg(long, default_value = "true")]
82    pub pretty: bool,
83
84    /// Optional caption/description
85    #[arg(long)]
86    pub caption: Option<String>,
87
88    /// Reply to message ID
89    #[arg(long)]
90    pub reply_msg_id: Option<String>,
91
92    /// Chat ID (will use default from config if not provided)
93    #[arg(long)]
94    pub chat_id: Option<String>,
95}
96
97#[derive(Debug, Clone, Args)]
98pub struct FileInfoArgs {
99    /// File ID to get information about
100    #[arg(long)]
101    pub file_id: String,
102}
103
104impl FileCommands {
105    pub async fn execute_with_output(
106        &self,
107        bot: &Bot,
108        output_format: &OutputFormat,
109    ) -> CliResult<()> {
110        let response = match self {
111            FileCommands::Upload(args) => self.handle_upload(bot, args).await,
112            FileCommands::UploadText(args) => self.handle_upload_text(bot, args).await,
113            FileCommands::UploadJson(args) => self.handle_upload_json(bot, args).await,
114            FileCommands::Info(args) => self.handle_file_info(bot, args).await,
115        };
116
117        OutputFormatter::print(&response, output_format)?;
118
119        if !response.success {
120            std::process::exit(1);
121        }
122
123        Ok(())
124    }
125
126    async fn handle_upload(
127        &self,
128        bot: &Bot,
129        args: &UploadFileArgs,
130    ) -> CliResponse<serde_json::Value> {
131        // Decode base64 content
132        let file_content = match base64::engine::general_purpose::STANDARD
133            .decode(&args.content_base64)
134        {
135            Ok(content) => content,
136            Err(e) => {
137                return CliResponse::error("upload-file", format!("Invalid base64 content: {e}"));
138            }
139        };
140
141        // Validate file size (100MB limit)
142        if file_content.len() > 100 * 1024 * 1024 {
143            return CliResponse::error("upload-file", "File too large (max 100MB)");
144        }
145
146        let chat_id = match &args.chat_id {
147            Some(id) => ChatId::from_borrowed_str(id),
148            None => {
149                // Get default chat ID from environment or config
150                match std::env::var("VKTEAMS_BOT_CHAT_ID") {
151                    Ok(id) => ChatId::from_borrowed_str(&id),
152                    Err(_) => {
153                        return CliResponse::error(
154                            "upload-file",
155                            "No chat ID provided and VKTEAMS_BOT_CHAT_ID not set",
156                        );
157                    }
158                }
159            }
160        };
161
162        let mut req = RequestMessagesSendFile::new((
163            chat_id,
164            MultipartName::FileContent {
165                filename: args.name.clone(),
166                content: file_content.clone(),
167            },
168        ));
169
170        if let Some(caption) = &args.caption {
171            req = req.with_text(caption.clone());
172        }
173
174        if let Some(reply_msg_id) = &args.reply_msg_id {
175            req = req.with_reply_msg_id(MsgId(reply_msg_id.clone()));
176        }
177
178        match bot.send_api_request(req).await {
179            Ok(response) => {
180                let data = json!({
181                    "message_id": response.msg_id,
182                    "file_name": args.name,
183                    "file_size": file_content.len(),
184                    "file_size_formatted": format_file_size(file_content.len()),
185                    "caption": args.caption
186                });
187                CliResponse::success("upload-file", data)
188            }
189            Err(e) => CliResponse::error("upload-file", format!("Failed to upload file: {e}")),
190        }
191    }
192
193    async fn handle_upload_text(
194        &self,
195        bot: &Bot,
196        args: &UploadTextArgs,
197    ) -> CliResponse<serde_json::Value> {
198        let file_content = args.content.as_bytes().to_vec();
199
200        let chat_id = match &args.chat_id {
201            Some(id) => ChatId::from_borrowed_str(id),
202            None => match std::env::var("VKTEAMS_BOT_CHAT_ID") {
203                Ok(id) => ChatId::from_borrowed_str(&id),
204                Err(_) => {
205                    return CliResponse::error(
206                        "upload-text",
207                        "No chat ID provided and VKTEAMS_BOT_CHAT_ID not set",
208                    );
209                }
210            },
211        };
212
213        let mut req = RequestMessagesSendFile::new((
214            chat_id,
215            MultipartName::FileContent {
216                filename: args.name.clone(),
217                content: file_content.clone(),
218            },
219        ));
220
221        if let Some(caption) = &args.caption {
222            req = req.with_text(caption.clone());
223        }
224
225        if let Some(reply_msg_id) = &args.reply_msg_id {
226            req = req.with_reply_msg_id(MsgId(reply_msg_id.clone()));
227        }
228
229        match bot.send_api_request(req).await {
230            Ok(response) => {
231                let data = json!({
232                    "message_id": response.msg_id,
233                    "file_name": args.name,
234                    "file_size": file_content.len(),
235                    "content_preview": if args.content.len() > 100 {
236                        format!("{}...", &args.content[..100])
237                    } else {
238                        args.content.clone()
239                    },
240                    "caption": args.caption
241                });
242                CliResponse::success("upload-text", data)
243            }
244            Err(e) => CliResponse::error("upload-text", format!("Failed to upload text file: {e}")),
245        }
246    }
247
248    async fn handle_upload_json(
249        &self,
250        bot: &Bot,
251        args: &UploadJsonArgs,
252    ) -> CliResponse<serde_json::Value> {
253        // Parse and format JSON
254        let json_value: serde_json::Value = match serde_json::from_str(&args.json_data) {
255            Ok(value) => value,
256            Err(e) => {
257                return CliResponse::error("upload-json", format!("Invalid JSON data: {e}"));
258            }
259        };
260
261        let formatted_json = if args.pretty {
262            match serde_json::to_string_pretty(&json_value) {
263                Ok(s) => s,
264                Err(e) => {
265                    return CliResponse::error(
266                        "upload-json",
267                        format!("Failed to format JSON: {e}"),
268                    );
269                }
270            }
271        } else {
272            match serde_json::to_string(&json_value) {
273                Ok(s) => s,
274                Err(e) => {
275                    return CliResponse::error(
276                        "upload-json",
277                        format!("Failed to serialize JSON: {e}"),
278                    );
279                }
280            }
281        };
282
283        let final_filename = if args.name.ends_with(".json") {
284            args.name.clone()
285        } else {
286            format!("{}.json", args.name)
287        };
288
289        let file_content = formatted_json.as_bytes().to_vec();
290
291        let chat_id = match &args.chat_id {
292            Some(id) => ChatId::from_borrowed_str(id),
293            None => match std::env::var("VKTEAMS_BOT_CHAT_ID") {
294                Ok(id) => ChatId::from_borrowed_str(&id),
295                Err(_) => {
296                    return CliResponse::error(
297                        "upload-json",
298                        "No chat ID provided and VKTEAMS_BOT_CHAT_ID not set",
299                    );
300                }
301            },
302        };
303
304        let mut req = RequestMessagesSendFile::new((
305            chat_id,
306            MultipartName::FileContent {
307                filename: final_filename.clone(),
308                content: file_content.clone(),
309            },
310        ));
311
312        if let Some(caption) = &args.caption {
313            req = req.with_text(caption.clone());
314        }
315
316        if let Some(reply_msg_id) = &args.reply_msg_id {
317            req = req.with_reply_msg_id(MsgId(reply_msg_id.clone()));
318        }
319
320        match bot.send_api_request(req).await {
321            Ok(response) => {
322                let data = json!({
323                    "message_id": response.msg_id,
324                    "file_name": final_filename,
325                    "file_size": file_content.len(),
326                    "pretty_formatted": args.pretty,
327                    "json_valid": true,
328                    "caption": args.caption
329                });
330                CliResponse::success("upload-json", data)
331            }
332            Err(e) => CliResponse::error("upload-json", format!("Failed to upload JSON file: {e}")),
333        }
334    }
335
336    async fn handle_file_info(
337        &self,
338        bot: &Bot,
339        args: &FileInfoArgs,
340    ) -> CliResponse<serde_json::Value> {
341        let req = RequestFilesGetInfo::new(FileId(args.file_id.clone()));
342
343        match bot.send_api_request(req).await {
344            Ok(response) => {
345                let data = json!({
346                    "file_type": response.file_type,
347                    "file_size": response.file_size,
348                    "file_name": response.file_name,
349                    "url": response.url
350                });
351                CliResponse::success("file-info", data)
352            }
353            Err(e) => CliResponse::error("file-info", format!("Failed to get file info: {e}")),
354        }
355    }
356}
357
358#[async_trait]
359impl Command for FileCommands {
360    async fn execute(&self, bot: &Bot) -> CliResult<()> {
361        // Default to pretty format for backward compatibility
362        self.execute_with_output(bot, &OutputFormat::Pretty).await
363    }
364
365    fn name(&self) -> &'static str {
366        match self {
367            FileCommands::Upload(_) => "upload-file",
368            FileCommands::UploadText(_) => "upload-text",
369            FileCommands::UploadJson(_) => "upload-json",
370            FileCommands::Info(_) => "file-info",
371        }
372    }
373
374    fn validate(&self) -> CliResult<()> {
375        match self {
376            FileCommands::Upload(args) => {
377                if args.name.is_empty() {
378                    return Err(CliError::InputError(
379                        "File name cannot be empty".to_string(),
380                    ));
381                }
382                if args.content_base64.is_empty() {
383                    return Err(CliError::InputError(
384                        "File content cannot be empty".to_string(),
385                    ));
386                }
387            }
388            FileCommands::UploadText(args) => {
389                if args.name.is_empty() {
390                    return Err(CliError::InputError(
391                        "File name cannot be empty".to_string(),
392                    ));
393                }
394            }
395            FileCommands::UploadJson(args) => {
396                if args.name.is_empty() {
397                    return Err(CliError::InputError(
398                        "File name cannot be empty".to_string(),
399                    ));
400                }
401                if args.json_data.is_empty() {
402                    return Err(CliError::InputError(
403                        "JSON data cannot be empty".to_string(),
404                    ));
405                }
406            }
407            FileCommands::Info(args) => {
408                if args.file_id.is_empty() {
409                    return Err(CliError::InputError("File ID cannot be empty".to_string()));
410                }
411            }
412        }
413        Ok(())
414    }
415}
416
417fn format_file_size(size: usize) -> String {
418    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
419    let mut size = size as f64;
420    let mut unit_index = 0;
421
422    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
423        size /= 1024.0;
424        unit_index += 1;
425    }
426
427    if unit_index == 0 {
428        format!("{} {}", size as usize, UNITS[unit_index])
429    } else {
430        format!("{:.1} {}", size, UNITS[unit_index])
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_format_file_size() {
440        assert_eq!(format_file_size(0), "0 B");
441        assert_eq!(format_file_size(512), "512 B");
442        assert_eq!(format_file_size(1024), "1.0 KB");
443        assert_eq!(format_file_size(1536), "1.5 KB");
444        assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
445        assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
446    }
447
448    #[test]
449    fn test_upload_args_validation() {
450        let args = UploadFileArgs {
451            name: "".to_string(),
452            content_base64: "content".to_string(),
453            caption: None,
454            reply_msg_id: None,
455            chat_id: None,
456        };
457
458        let cmd = FileCommands::Upload(args);
459        assert!(cmd.validate().is_err());
460    }
461
462    #[test]
463    fn test_json_filename_extension() {
464        let args = UploadJsonArgs {
465            name: "test".to_string(),
466            json_data: "{}".to_string(),
467            pretty: true,
468            caption: None,
469            reply_msg_id: None,
470            chat_id: None,
471        };
472
473        // In real usage, this would be handled in handle_upload_json
474        let expected_filename = if args.name.ends_with(".json") {
475            args.name.clone()
476        } else {
477            format!("{}.json", args.name)
478        };
479
480        assert_eq!(expected_filename, "test.json");
481    }
482
483    #[test]
484    fn test_file_commands_name() {
485        let upload_cmd = FileCommands::Upload(UploadFileArgs {
486            name: "test.txt".to_string(),
487            content_base64: "dGVzdA==".to_string(),
488            caption: None,
489            reply_msg_id: None,
490            chat_id: None,
491        });
492        assert_eq!(upload_cmd.name(), "upload-file");
493
494        let upload_text_cmd = FileCommands::UploadText(UploadTextArgs {
495            name: "test.txt".to_string(),
496            content: "test content".to_string(),
497            caption: None,
498            reply_msg_id: None,
499            chat_id: None,
500        });
501        assert_eq!(upload_text_cmd.name(), "upload-text");
502
503        let upload_json_cmd = FileCommands::UploadJson(UploadJsonArgs {
504            name: "test.json".to_string(),
505            json_data: "{}".to_string(),
506            pretty: true,
507            caption: None,
508            reply_msg_id: None,
509            chat_id: None,
510        });
511        assert_eq!(upload_json_cmd.name(), "upload-json");
512
513        let info_cmd = FileCommands::Info(FileInfoArgs {
514            file_id: "test_file_id".to_string(),
515        });
516        assert_eq!(info_cmd.name(), "file-info");
517    }
518
519    #[test]
520    fn test_upload_file_validation() {
521        // Valid upload file args
522        let valid_args = UploadFileArgs {
523            name: "test.txt".to_string(),
524            content_base64: "dGVzdA==".to_string(),
525            caption: None,
526            reply_msg_id: None,
527            chat_id: None,
528        };
529        let cmd = FileCommands::Upload(valid_args);
530        assert!(cmd.validate().is_ok());
531
532        // Empty name
533        let invalid_name_args = UploadFileArgs {
534            name: "".to_string(),
535            content_base64: "dGVzdA==".to_string(),
536            caption: None,
537            reply_msg_id: None,
538            chat_id: None,
539        };
540        let cmd = FileCommands::Upload(invalid_name_args);
541        assert!(cmd.validate().is_err());
542
543        // Empty content
544        let invalid_content_args = UploadFileArgs {
545            name: "test.txt".to_string(),
546            content_base64: "".to_string(),
547            caption: None,
548            reply_msg_id: None,
549            chat_id: None,
550        };
551        let cmd = FileCommands::Upload(invalid_content_args);
552        assert!(cmd.validate().is_err());
553    }
554
555    #[test]
556    fn test_upload_text_validation() {
557        // Valid upload text args
558        let valid_args = UploadTextArgs {
559            name: "test.txt".to_string(),
560            content: "test content".to_string(),
561            caption: None,
562            reply_msg_id: None,
563            chat_id: None,
564        };
565        let cmd = FileCommands::UploadText(valid_args);
566        assert!(cmd.validate().is_ok());
567
568        // Empty name
569        let invalid_args = UploadTextArgs {
570            name: "".to_string(),
571            content: "test content".to_string(),
572            caption: None,
573            reply_msg_id: None,
574            chat_id: None,
575        };
576        let cmd = FileCommands::UploadText(invalid_args);
577        assert!(cmd.validate().is_err());
578    }
579
580    #[test]
581    fn test_upload_json_validation() {
582        // Valid upload json args
583        let valid_args = UploadJsonArgs {
584            name: "test.json".to_string(),
585            json_data: "{}".to_string(),
586            pretty: true,
587            caption: None,
588            reply_msg_id: None,
589            chat_id: None,
590        };
591        let cmd = FileCommands::UploadJson(valid_args);
592        assert!(cmd.validate().is_ok());
593
594        // Empty name
595        let invalid_name_args = UploadJsonArgs {
596            name: "".to_string(),
597            json_data: "{}".to_string(),
598            pretty: true,
599            caption: None,
600            reply_msg_id: None,
601            chat_id: None,
602        };
603        let cmd = FileCommands::UploadJson(invalid_name_args);
604        assert!(cmd.validate().is_err());
605
606        // Empty JSON data
607        let invalid_json_args = UploadJsonArgs {
608            name: "test.json".to_string(),
609            json_data: "".to_string(),
610            pretty: true,
611            caption: None,
612            reply_msg_id: None,
613            chat_id: None,
614        };
615        let cmd = FileCommands::UploadJson(invalid_json_args);
616        assert!(cmd.validate().is_err());
617    }
618
619    #[test]
620    fn test_file_info_validation() {
621        // Valid file info args
622        let valid_args = FileInfoArgs {
623            file_id: "test_file_id".to_string(),
624        };
625        let cmd = FileCommands::Info(valid_args);
626        assert!(cmd.validate().is_ok());
627
628        // Empty file ID
629        let invalid_args = FileInfoArgs {
630            file_id: "".to_string(),
631        };
632        let cmd = FileCommands::Info(invalid_args);
633        assert!(cmd.validate().is_err());
634    }
635
636    #[test]
637    fn test_json_filename_with_extension() {
638        // Test filename that already has .json extension
639        let args = UploadJsonArgs {
640            name: "test.json".to_string(),
641            json_data: "{}".to_string(),
642            pretty: true,
643            caption: None,
644            reply_msg_id: None,
645            chat_id: None,
646        };
647
648        let final_filename = if args.name.ends_with(".json") {
649            args.name.clone()
650        } else {
651            format!("{}.json", args.name)
652        };
653
654        assert_eq!(final_filename, "test.json");
655    }
656
657    #[test]
658    fn test_json_filename_without_extension() {
659        // Test filename without .json extension
660        let args = UploadJsonArgs {
661            name: "test".to_string(),
662            json_data: "{}".to_string(),
663            pretty: true,
664            caption: None,
665            reply_msg_id: None,
666            chat_id: None,
667        };
668
669        let final_filename = if args.name.ends_with(".json") {
670            args.name.clone()
671        } else {
672            format!("{}.json", args.name)
673        };
674
675        assert_eq!(final_filename, "test.json");
676    }
677
678    #[test]
679    fn test_format_file_size_edge_cases() {
680        // Test large file sizes
681        assert_eq!(
682            format_file_size(1024 * 1024 * 1024 + 512 * 1024 * 1024),
683            "1.5 GB"
684        );
685        assert_eq!(format_file_size(2048), "2.0 KB");
686        assert_eq!(format_file_size(1023), "1023 B");
687        assert_eq!(format_file_size(1025), "1.0 KB");
688    }
689
690    #[test]
691    fn test_execute_with_default_format() {
692        // Test that execute() uses Pretty format by default through Command trait
693        let cmd = FileCommands::Info(FileInfoArgs {
694            file_id: "test_file_id".to_string(),
695        });
696
697        // We can't test the actual execution without mocking the network layer,
698        // but we can test that the method exists and the default format is used
699        assert_eq!(cmd.name(), "file-info");
700
701        // Test validation for coverage
702        assert!(cmd.validate().is_ok());
703    }
704
705    #[test]
706    fn test_upload_file_args_structure() {
707        let args = UploadFileArgs {
708            name: "test.txt".to_string(),
709            content_base64: "dGVzdA==".to_string(),
710            caption: Some("Test caption".to_string()),
711            reply_msg_id: Some("msg_123".to_string()),
712            chat_id: Some("chat_456".to_string()),
713        };
714
715        assert_eq!(args.name, "test.txt");
716        assert_eq!(args.content_base64, "dGVzdA==");
717        assert_eq!(args.caption, Some("Test caption".to_string()));
718        assert_eq!(args.reply_msg_id, Some("msg_123".to_string()));
719        assert_eq!(args.chat_id, Some("chat_456".to_string()));
720    }
721
722    #[test]
723    fn test_upload_text_args_structure() {
724        let args = UploadTextArgs {
725            name: "test.txt".to_string(),
726            content: "test content".to_string(),
727            caption: Some("Test caption".to_string()),
728            reply_msg_id: Some("msg_123".to_string()),
729            chat_id: Some("chat_456".to_string()),
730        };
731
732        assert_eq!(args.name, "test.txt");
733        assert_eq!(args.content, "test content");
734        assert_eq!(args.caption, Some("Test caption".to_string()));
735        assert_eq!(args.reply_msg_id, Some("msg_123".to_string()));
736        assert_eq!(args.chat_id, Some("chat_456".to_string()));
737    }
738
739    #[test]
740    fn test_upload_json_args_structure() {
741        let args = UploadJsonArgs {
742            name: "test.json".to_string(),
743            json_data: r#"{"key": "value"}"#.to_string(),
744            pretty: false,
745            caption: Some("Test caption".to_string()),
746            reply_msg_id: Some("msg_123".to_string()),
747            chat_id: Some("chat_456".to_string()),
748        };
749
750        assert_eq!(args.name, "test.json");
751        assert_eq!(args.json_data, r#"{"key": "value"}"#);
752        assert!(!args.pretty);
753        assert_eq!(args.caption, Some("Test caption".to_string()));
754        assert_eq!(args.reply_msg_id, Some("msg_123".to_string()));
755        assert_eq!(args.chat_id, Some("chat_456".to_string()));
756    }
757
758    #[test]
759    fn test_file_info_args_structure() {
760        let args = FileInfoArgs {
761            file_id: "test_file_id_123".to_string(),
762        };
763
764        assert_eq!(args.file_id, "test_file_id_123");
765    }
766
767    #[test]
768    fn test_file_commands_debug_and_clone() {
769        let cmd = FileCommands::Upload(UploadFileArgs {
770            name: "test.txt".to_string(),
771            content_base64: "dGVzdA==".to_string(),
772            caption: None,
773            reply_msg_id: None,
774            chat_id: None,
775        });
776
777        // Test Debug trait
778        let debug_str = format!("{cmd:?}");
779        assert!(debug_str.contains("Upload"));
780        assert!(debug_str.contains("test.txt"));
781
782        // Test Clone trait
783        let cloned_cmd = cmd.clone();
784        assert_eq!(cloned_cmd.name(), cmd.name());
785    }
786
787    #[test]
788    fn test_args_debug_and_clone() {
789        let upload_args = UploadFileArgs {
790            name: "test.txt".to_string(),
791            content_base64: "dGVzdA==".to_string(),
792            caption: None,
793            reply_msg_id: None,
794            chat_id: None,
795        };
796
797        // Test Debug and Clone traits
798        let debug_str = format!("{upload_args:?}");
799        assert!(debug_str.contains("test.txt"));
800
801        let cloned_args = upload_args.clone();
802        assert_eq!(cloned_args.name, upload_args.name);
803    }
804}