1use crate::types::FileData;
6use runtara_agent_macro::{CapabilityInput, CapabilityOutput, capability};
7use serde::{Deserialize, Serialize};
8use std::io::{Cursor, Read, Write};
9use strum::{Display, EnumString};
10use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
11
12#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, Display, EnumString, PartialEq)]
14#[serde(rename_all = "lowercase")]
15#[strum(serialize_all = "lowercase")]
16pub enum ArchiveFormat {
17 #[default]
18 Zip,
19 }
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum ArchiveDataInput {
26 FileData(FileData),
28 Base64String(String),
30}
31
32impl ArchiveDataInput {
33 pub fn into_file_data(self) -> FileData {
35 match self {
36 ArchiveDataInput::FileData(fd) => fd,
37 ArchiveDataInput::Base64String(s) => FileData {
38 content: s,
39 filename: None,
40 mime_type: None,
41 },
42 }
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, CapabilityInput)]
48#[capability_input(
49 display_name = "Archive File Entry",
50 description = "A file to add to an archive with optional path"
51)]
52#[serde(rename_all = "camelCase")]
53pub struct ArchiveFileEntry {
54 #[field(
56 display_name = "File",
57 description = "The file content to add to the archive"
58 )]
59 pub file: ArchiveDataInput,
60
61 #[field(
63 display_name = "Path",
64 description = "Path within the archive (e.g., 'data/report.csv')"
65 )]
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub path: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, CapabilityInput)]
72#[capability_input(
73 display_name = "Create Archive Input",
74 description = "Input for creating an archive from files"
75)]
76#[serde(rename_all = "camelCase")]
77pub struct CreateArchiveInput {
78 #[field(
80 display_name = "Files",
81 description = "List of files to include in the archive"
82 )]
83 pub files: Vec<ArchiveFileEntry>,
84
85 #[field(
87 display_name = "Format",
88 description = "Archive format: 'zip' (default)",
89 default = "default_format"
90 )]
91 #[serde(default)]
92 pub format: ArchiveFormat,
93
94 #[field(
96 display_name = "Compression Level",
97 description = "Compression level from 0 (none) to 9 (maximum)",
98 default = "default_compression_level"
99 )]
100 #[serde(default = "default_compression_level")]
101 pub compression_level: u8,
102
103 #[field(
105 display_name = "Archive Name",
106 description = "Filename for the output archive (e.g., 'data.zip')"
107 )]
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub archive_name: Option<String>,
110}
111
112fn default_compression_level() -> u8 {
113 6
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, CapabilityInput)]
118#[capability_input(
119 display_name = "Extract Archive Input",
120 description = "Input for extracting all files from an archive"
121)]
122#[serde(rename_all = "camelCase")]
123pub struct ExtractArchiveInput {
124 #[field(display_name = "Archive", description = "The archive file to extract")]
126 pub archive: ArchiveDataInput,
127
128 #[field(
130 display_name = "Format",
131 description = "Archive format (auto-detected from content if not specified)"
132 )]
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub format: Option<ArchiveFormat>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, CapabilityInput)]
139#[capability_input(
140 display_name = "Extract File Input",
141 description = "Input for extracting a single file from an archive"
142)]
143#[serde(rename_all = "camelCase")]
144pub struct ExtractFileInput {
145 #[field(
147 display_name = "Archive",
148 description = "The archive file containing the target file"
149 )]
150 pub archive: ArchiveDataInput,
151
152 #[field(
154 display_name = "File Path",
155 description = "Path of the file to extract (e.g., 'data/report.csv')"
156 )]
157 pub file_path: String,
158
159 #[field(
161 display_name = "Format",
162 description = "Archive format (auto-detected from content if not specified)"
163 )]
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub format: Option<ArchiveFormat>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, CapabilityInput)]
170#[capability_input(
171 display_name = "List Archive Input",
172 description = "Input for listing archive contents"
173)]
174#[serde(rename_all = "camelCase")]
175pub struct ListArchiveInput {
176 #[field(
178 display_name = "Archive",
179 description = "The archive file to list contents of"
180 )]
181 pub archive: ArchiveDataInput,
182
183 #[field(
185 display_name = "Format",
186 description = "Archive format (auto-detected from content if not specified)"
187 )]
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub format: Option<ArchiveFormat>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
194#[capability_output(
195 display_name = "Extracted File",
196 description = "A file extracted from an archive"
197)]
198#[serde(rename_all = "camelCase")]
199pub struct ExtractedFile {
200 #[field(
202 display_name = "File",
203 description = "The extracted file data (base64-encoded)"
204 )]
205 pub file: FileData,
206
207 #[field(
209 display_name = "Path",
210 description = "Original path of the file within the archive"
211 )]
212 pub path: String,
213
214 #[field(display_name = "Size", description = "Uncompressed file size in bytes")]
216 pub size: u64,
217
218 #[field(
220 display_name = "Is Directory",
221 description = "True if this entry is a directory"
222 )]
223 pub is_directory: bool,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
228#[capability_output(
229 display_name = "Extract Archive Output",
230 description = "Result of extracting all files from an archive"
231)]
232#[serde(rename_all = "camelCase")]
233pub struct ExtractArchiveOutput {
234 #[field(display_name = "Files", description = "List of all extracted files")]
236 pub files: Vec<ExtractedFile>,
237
238 #[field(
240 display_name = "Count",
241 description = "Total number of files extracted"
242 )]
243 pub count: usize,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
248#[capability_output(
249 display_name = "Archive Entry Info",
250 description = "Information about a file in an archive"
251)]
252#[serde(rename_all = "camelCase")]
253pub struct ArchiveEntryInfo {
254 #[field(
256 display_name = "Path",
257 description = "Path of the file within the archive"
258 )]
259 pub path: String,
260
261 #[field(display_name = "Size", description = "Uncompressed file size in bytes")]
263 pub size: u64,
264
265 #[field(
267 display_name = "Compressed Size",
268 description = "Compressed file size in bytes"
269 )]
270 pub compressed_size: u64,
271
272 #[field(
274 display_name = "Is Directory",
275 description = "True if this entry is a directory"
276 )]
277 pub is_directory: bool,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, CapabilityOutput)]
282#[capability_output(
283 display_name = "List Archive Output",
284 description = "Contents of an archive"
285)]
286#[serde(rename_all = "camelCase")]
287pub struct ListArchiveOutput {
288 #[field(
290 display_name = "Entries",
291 description = "List of files and directories"
292 )]
293 pub entries: Vec<ArchiveEntryInfo>,
294
295 #[field(display_name = "Total Count", description = "Total number of entries")]
297 pub total_count: usize,
298
299 #[field(
301 display_name = "Total Size",
302 description = "Total uncompressed size in bytes"
303 )]
304 pub total_size: u64,
305
306 #[field(display_name = "Format", description = "Archive format")]
308 pub format: ArchiveFormat,
309}
310
311fn infer_mime_type(path: &str) -> Option<String> {
317 let ext = path.rsplit('.').next()?.to_lowercase();
318 let mime = match ext.as_str() {
319 "csv" => "text/csv",
320 "json" => "application/json",
321 "xml" => "application/xml",
322 "txt" => "text/plain",
323 "html" | "htm" => "text/html",
324 "pdf" => "application/pdf",
325 "png" => "image/png",
326 "jpg" | "jpeg" => "image/jpeg",
327 "gif" => "image/gif",
328 "zip" => "application/zip",
329 "gz" | "gzip" => "application/gzip",
330 "tar" => "application/x-tar",
331 _ => "application/octet-stream",
332 };
333 Some(mime.to_string())
334}
335
336fn filename_from_path(path: &str) -> String {
338 path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
339}
340
341#[capability(
347 module = "compression",
348 display_name = "Create Archive",
349 description = "Create an archive from one or more files"
350)]
351pub fn create_archive(input: CreateArchiveInput) -> Result<FileData, String> {
352 if input.files.is_empty() {
353 return Err("At least one file is required to create an archive".to_string());
354 }
355
356 let compression_level = input.compression_level.min(9);
357
358 match input.format {
359 ArchiveFormat::Zip => {
360 create_zip_archive(&input.files, compression_level, input.archive_name)
361 }
362 }
363}
364
365fn create_zip_archive(
366 files: &[ArchiveFileEntry],
367 compression_level: u8,
368 archive_name: Option<String>,
369) -> Result<FileData, String> {
370 let mut buffer = Cursor::new(Vec::new());
371
372 {
373 let mut zip = ZipWriter::new(&mut buffer);
374
375 let options = SimpleFileOptions::default()
376 .compression_method(if compression_level == 0 {
377 CompressionMethod::Stored
378 } else {
379 CompressionMethod::Deflated
380 })
381 .compression_level(Some(compression_level as i64));
382
383 for entry in files {
384 let file_data = entry.file.clone().into_file_data();
385 let bytes = file_data.decode()?;
386
387 let path = entry
389 .path
390 .clone()
391 .or_else(|| file_data.filename.clone())
392 .unwrap_or_else(|| "file".to_string());
393
394 zip.start_file(&path, options)
395 .map_err(|e| format!("Failed to add file '{}' to archive: {}", path, e))?;
396
397 zip.write_all(&bytes)
398 .map_err(|e| format!("Failed to write file '{}' content: {}", path, e))?;
399 }
400
401 zip.finish()
402 .map_err(|e| format!("Failed to finalize archive: {}", e))?;
403 }
404
405 let archive_bytes = buffer.into_inner();
406 let filename = archive_name.unwrap_or_else(|| "archive.zip".to_string());
407
408 Ok(FileData::from_bytes(
409 archive_bytes,
410 Some(filename),
411 Some("application/zip".to_string()),
412 ))
413}
414
415#[capability(
417 module = "compression",
418 display_name = "Extract Archive",
419 description = "Extract all files from an archive"
420)]
421pub fn extract_archive(input: ExtractArchiveInput) -> Result<ExtractArchiveOutput, String> {
422 let file_data = input.archive.into_file_data();
423 let bytes = file_data.decode()?;
424
425 let _format = input.format.unwrap_or(ArchiveFormat::Zip);
427
428 extract_zip_archive(&bytes)
429}
430
431fn extract_zip_archive(bytes: &[u8]) -> Result<ExtractArchiveOutput, String> {
432 let cursor = Cursor::new(bytes);
433 let mut archive =
434 ZipArchive::new(cursor).map_err(|e| format!("Failed to read archive: {}", e))?;
435
436 let mut files = Vec::new();
437
438 for i in 0..archive.len() {
439 let mut file = archive
440 .by_index(i)
441 .map_err(|e| format!("Failed to read archive entry {}: {}", i, e))?;
442
443 let path = file.name().to_string();
444 let is_directory = file.is_dir();
445 let size = file.size();
446
447 if is_directory {
448 files.push(ExtractedFile {
450 file: FileData {
451 content: String::new(),
452 filename: Some(filename_from_path(&path)),
453 mime_type: None,
454 },
455 path,
456 size: 0,
457 is_directory: true,
458 });
459 } else {
460 let mut contents = Vec::new();
461 file.read_to_end(&mut contents)
462 .map_err(|e| format!("Failed to read file '{}': {}", path, e))?;
463
464 let filename = filename_from_path(&path);
465 let mime_type = infer_mime_type(&path);
466
467 files.push(ExtractedFile {
468 file: FileData::from_bytes(contents, Some(filename), mime_type),
469 path,
470 size,
471 is_directory: false,
472 });
473 }
474 }
475
476 let count = files.len();
477
478 Ok(ExtractArchiveOutput { files, count })
479}
480
481#[capability(
483 module = "compression",
484 display_name = "Extract File",
485 description = "Extract a single file from an archive by its path"
486)]
487pub fn extract_file(input: ExtractFileInput) -> Result<FileData, String> {
488 let file_data = input.archive.into_file_data();
489 let bytes = file_data.decode()?;
490
491 let _format = input.format.unwrap_or(ArchiveFormat::Zip);
493
494 extract_file_from_zip(&bytes, &input.file_path)
495}
496
497fn extract_file_from_zip(bytes: &[u8], file_path: &str) -> Result<FileData, String> {
498 let cursor = Cursor::new(bytes);
499 let mut archive =
500 ZipArchive::new(cursor).map_err(|e| format!("Failed to read archive: {}", e))?;
501
502 let paths_to_try = [
504 file_path.to_string(),
505 file_path.replace('\\', "/"),
506 file_path.trim_start_matches('/').to_string(),
507 ];
508
509 let mut found_file = None;
510 for path in &paths_to_try {
511 if let Ok(file) = archive.by_name(path) {
512 if !file.is_dir() {
513 found_file = Some(path.clone());
515 break;
516 }
517 }
518 }
519
520 let actual_path =
521 found_file.ok_or_else(|| format!("File '{}' not found in archive", file_path))?;
522
523 let cursor = Cursor::new(bytes);
525 let mut archive =
526 ZipArchive::new(cursor).map_err(|e| format!("Failed to read archive: {}", e))?;
527
528 let mut file = archive
529 .by_name(&actual_path)
530 .map_err(|_| format!("File '{}' not found in archive", file_path))?;
531
532 if file.is_dir() {
533 return Err(format!("'{}' is a directory, not a file", file_path));
534 }
535
536 let mut contents = Vec::new();
537 file.read_to_end(&mut contents)
538 .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?;
539
540 let filename = filename_from_path(file_path);
541 let mime_type = infer_mime_type(file_path);
542
543 Ok(FileData::from_bytes(contents, Some(filename), mime_type))
544}
545
546#[capability(
548 module = "compression",
549 display_name = "List Archive",
550 description = "List all files and directories in an archive without extracting"
551)]
552pub fn list_archive(input: ListArchiveInput) -> Result<ListArchiveOutput, String> {
553 let file_data = input.archive.into_file_data();
554 let bytes = file_data.decode()?;
555
556 let format = input.format.unwrap_or(ArchiveFormat::Zip);
558
559 list_zip_archive(&bytes, format)
560}
561
562fn list_zip_archive(bytes: &[u8], format: ArchiveFormat) -> Result<ListArchiveOutput, String> {
563 let cursor = Cursor::new(bytes);
564 let mut archive =
565 ZipArchive::new(cursor).map_err(|e| format!("Failed to read archive: {}", e))?;
566
567 let mut entries = Vec::new();
568 let mut total_size: u64 = 0;
569
570 for i in 0..archive.len() {
571 let file = archive
572 .by_index_raw(i)
573 .map_err(|e| format!("Failed to read archive entry {}: {}", i, e))?;
574
575 let path = file.name().to_string();
576 let size = file.size();
577 let compressed_size = file.compressed_size();
578 let is_directory = file.is_dir();
579
580 if !is_directory {
581 total_size += size;
582 }
583
584 entries.push(ArchiveEntryInfo {
585 path,
586 size,
587 compressed_size,
588 is_directory,
589 });
590 }
591
592 let total_count = entries.len();
593
594 Ok(ListArchiveOutput {
595 entries,
596 total_count,
597 total_size,
598 format,
599 })
600}
601
602#[cfg(test)]
607mod tests {
608 use super::*;
609
610 fn sample_file_data(content: &str, filename: &str) -> FileData {
611 FileData::from_bytes(
612 content.as_bytes().to_vec(),
613 Some(filename.to_string()),
614 Some("text/plain".to_string()),
615 )
616 }
617
618 #[test]
619 fn test_create_archive_single_file() {
620 let input = CreateArchiveInput {
621 files: vec![ArchiveFileEntry {
622 file: ArchiveDataInput::FileData(sample_file_data("Hello, World!", "hello.txt")),
623 path: None,
624 }],
625 format: ArchiveFormat::Zip,
626 compression_level: 6,
627 archive_name: Some("test.zip".to_string()),
628 };
629
630 let result = create_archive(input).unwrap();
631 assert_eq!(result.filename, Some("test.zip".to_string()));
632 assert_eq!(result.mime_type, Some("application/zip".to_string()));
633
634 let bytes = result.decode().unwrap();
636 assert!(!bytes.is_empty());
637 }
638
639 #[test]
640 fn test_create_archive_multiple_files() {
641 let input = CreateArchiveInput {
642 files: vec![
643 ArchiveFileEntry {
644 file: ArchiveDataInput::FileData(sample_file_data("File 1", "file1.txt")),
645 path: Some("dir/file1.txt".to_string()),
646 },
647 ArchiveFileEntry {
648 file: ArchiveDataInput::FileData(sample_file_data("File 2", "file2.txt")),
649 path: Some("dir/file2.txt".to_string()),
650 },
651 ],
652 format: ArchiveFormat::Zip,
653 compression_level: 6,
654 archive_name: None,
655 };
656
657 let result = create_archive(input).unwrap();
658 assert_eq!(result.filename, Some("archive.zip".to_string()));
659 }
660
661 #[test]
662 fn test_create_archive_empty_files_error() {
663 let input = CreateArchiveInput {
664 files: vec![],
665 format: ArchiveFormat::Zip,
666 compression_level: 6,
667 archive_name: None,
668 };
669
670 let result = create_archive(input);
671 assert!(result.is_err());
672 assert!(result.unwrap_err().contains("At least one file"));
673 }
674
675 #[test]
676 fn test_extract_archive() {
677 let input = CreateArchiveInput {
679 files: vec![
680 ArchiveFileEntry {
681 file: ArchiveDataInput::FileData(sample_file_data("Content A", "a.txt")),
682 path: Some("folder/a.txt".to_string()),
683 },
684 ArchiveFileEntry {
685 file: ArchiveDataInput::FileData(sample_file_data("Content B", "b.txt")),
686 path: Some("folder/b.txt".to_string()),
687 },
688 ],
689 format: ArchiveFormat::Zip,
690 compression_level: 6,
691 archive_name: None,
692 };
693
694 let archive = create_archive(input).unwrap();
695
696 let extract_input = ExtractArchiveInput {
698 archive: ArchiveDataInput::FileData(archive),
699 format: None,
700 };
701
702 let result = extract_archive(extract_input).unwrap();
703 assert_eq!(result.count, 2);
704 assert_eq!(result.files.len(), 2);
705
706 let file_a = result
708 .files
709 .iter()
710 .find(|f| f.path == "folder/a.txt")
711 .unwrap();
712 let content_a = file_a.file.decode().unwrap();
713 assert_eq!(String::from_utf8(content_a).unwrap(), "Content A");
714 }
715
716 #[test]
717 fn test_extract_single_file() {
718 let input = CreateArchiveInput {
720 files: vec![
721 ArchiveFileEntry {
722 file: ArchiveDataInput::FileData(sample_file_data(
723 "Target content",
724 "target.csv",
725 )),
726 path: Some("data/target.csv".to_string()),
727 },
728 ArchiveFileEntry {
729 file: ArchiveDataInput::FileData(sample_file_data("Other", "other.txt")),
730 path: Some("data/other.txt".to_string()),
731 },
732 ],
733 format: ArchiveFormat::Zip,
734 compression_level: 6,
735 archive_name: None,
736 };
737
738 let archive = create_archive(input).unwrap();
739
740 let extract_input = ExtractFileInput {
742 archive: ArchiveDataInput::FileData(archive),
743 file_path: "data/target.csv".to_string(),
744 format: None,
745 };
746
747 let result = extract_file(extract_input).unwrap();
748 assert_eq!(result.filename, Some("target.csv".to_string()));
749 assert_eq!(result.mime_type, Some("text/csv".to_string()));
750
751 let content = result.decode().unwrap();
752 assert_eq!(String::from_utf8(content).unwrap(), "Target content");
753 }
754
755 #[test]
756 fn test_extract_file_not_found() {
757 let input = CreateArchiveInput {
758 files: vec![ArchiveFileEntry {
759 file: ArchiveDataInput::FileData(sample_file_data("Content", "file.txt")),
760 path: None,
761 }],
762 format: ArchiveFormat::Zip,
763 compression_level: 6,
764 archive_name: None,
765 };
766
767 let archive = create_archive(input).unwrap();
768
769 let extract_input = ExtractFileInput {
770 archive: ArchiveDataInput::FileData(archive),
771 file_path: "nonexistent.txt".to_string(),
772 format: None,
773 };
774
775 let result = extract_file(extract_input);
776 assert!(result.is_err());
777 assert!(result.unwrap_err().contains("not found"));
778 }
779
780 #[test]
781 fn test_list_archive() {
782 let input = CreateArchiveInput {
783 files: vec![
784 ArchiveFileEntry {
785 file: ArchiveDataInput::FileData(sample_file_data("AAAA", "a.txt")),
786 path: Some("folder/a.txt".to_string()),
787 },
788 ArchiveFileEntry {
789 file: ArchiveDataInput::FileData(sample_file_data("BBBBBBBB", "b.txt")),
790 path: Some("folder/b.txt".to_string()),
791 },
792 ],
793 format: ArchiveFormat::Zip,
794 compression_level: 6,
795 archive_name: None,
796 };
797
798 let archive = create_archive(input).unwrap();
799
800 let list_input = ListArchiveInput {
801 archive: ArchiveDataInput::FileData(archive),
802 format: None,
803 };
804
805 let result = list_archive(list_input).unwrap();
806 assert_eq!(result.total_count, 2);
807 assert_eq!(result.format, ArchiveFormat::Zip);
808 assert_eq!(result.total_size, 12); let entry_a = result
811 .entries
812 .iter()
813 .find(|e| e.path == "folder/a.txt")
814 .unwrap();
815 assert_eq!(entry_a.size, 4);
816 assert!(!entry_a.is_directory);
817 }
818
819 #[test]
820 fn test_mime_type_inference() {
821 assert_eq!(infer_mime_type("file.csv"), Some("text/csv".to_string()));
822 assert_eq!(
823 infer_mime_type("data.json"),
824 Some("application/json".to_string())
825 );
826 assert_eq!(
827 infer_mime_type("doc.xml"),
828 Some("application/xml".to_string())
829 );
830 assert_eq!(
831 infer_mime_type("readme.txt"),
832 Some("text/plain".to_string())
833 );
834 assert_eq!(
835 infer_mime_type("archive.zip"),
836 Some("application/zip".to_string())
837 );
838 assert_eq!(
839 infer_mime_type("unknown.xyz"),
840 Some("application/octet-stream".to_string())
841 );
842 }
843
844 #[test]
845 fn test_base64_string_input() {
846 let content = "Hello from base64";
848 let base64_content = base64::Engine::encode(
849 &base64::engine::general_purpose::STANDARD,
850 content.as_bytes(),
851 );
852
853 let input = CreateArchiveInput {
854 files: vec![ArchiveFileEntry {
855 file: ArchiveDataInput::Base64String(base64_content),
856 path: Some("test.txt".to_string()),
857 }],
858 format: ArchiveFormat::Zip,
859 compression_level: 6,
860 archive_name: None,
861 };
862
863 let archive = create_archive(input).unwrap();
864
865 let extract_input = ExtractFileInput {
866 archive: ArchiveDataInput::FileData(archive),
867 file_path: "test.txt".to_string(),
868 format: None,
869 };
870
871 let result = extract_file(extract_input).unwrap();
872 let decoded = result.decode().unwrap();
873 assert_eq!(String::from_utf8(decoded).unwrap(), content);
874 }
875}