runtara_agents/agents/
compression.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Compression agent for archive operations (ZIP, with extensibility for other formats)
4
5use 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/// Supported archive formats
13#[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    // Future: Gzip, Tar, TarGz, SevenZip
20}
21
22/// Flexible input for archive data - accepts FileData object or base64 string
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum ArchiveDataInput {
26    /// FileData object with content and optional metadata
27    FileData(FileData),
28    /// Raw base64-encoded archive content
29    Base64String(String),
30}
31
32impl ArchiveDataInput {
33    /// Convert to FileData for uniform handling
34    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/// A file entry to be added to an archive
47#[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    /// The file content (base64-encoded)
55    #[field(
56        display_name = "File",
57        description = "The file content to add to the archive"
58    )]
59    pub file: ArchiveDataInput,
60
61    /// Path within the archive (defaults to filename from FileData, or "file" if none)
62    #[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/// Input for create_archive capability
71#[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    /// Files to add to the archive
79    #[field(
80        display_name = "Files",
81        description = "List of files to include in the archive"
82    )]
83    pub files: Vec<ArchiveFileEntry>,
84
85    /// Archive format (defaults to ZIP)
86    #[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    /// Compression level (0-9, where 0 is no compression, 9 is maximum)
95    #[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    /// Optional name for the output archive
104    #[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/// Input for extract_archive capability
117#[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    /// The archive to extract (base64-encoded)
125    #[field(display_name = "Archive", description = "The archive file to extract")]
126    pub archive: ArchiveDataInput,
127
128    /// Archive format (auto-detected if not specified)
129    #[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/// Input for extract_file capability
138#[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    /// The archive containing the file (base64-encoded)
146    #[field(
147        display_name = "Archive",
148        description = "The archive file containing the target file"
149    )]
150    pub archive: ArchiveDataInput,
151
152    /// Path of the file to extract within the archive
153    #[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    /// Archive format (auto-detected if not specified)
160    #[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/// Input for list_archive capability
169#[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    /// The archive to list (base64-encoded)
177    #[field(
178        display_name = "Archive",
179        description = "The archive file to list contents of"
180    )]
181    pub archive: ArchiveDataInput,
182
183    /// Archive format (auto-detected if not specified)
184    #[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/// An extracted file from an archive
193#[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    /// The extracted file content
201    #[field(
202        display_name = "File",
203        description = "The extracted file data (base64-encoded)"
204    )]
205    pub file: FileData,
206
207    /// Original path within the archive
208    #[field(
209        display_name = "Path",
210        description = "Original path of the file within the archive"
211    )]
212    pub path: String,
213
214    /// Uncompressed size in bytes
215    #[field(display_name = "Size", description = "Uncompressed file size in bytes")]
216    pub size: u64,
217
218    /// Whether this entry is a directory
219    #[field(
220        display_name = "Is Directory",
221        description = "True if this entry is a directory"
222    )]
223    pub is_directory: bool,
224}
225
226/// Output for extract_archive capability
227#[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    /// All extracted files
235    #[field(display_name = "Files", description = "List of all extracted files")]
236    pub files: Vec<ExtractedFile>,
237
238    /// Number of files extracted
239    #[field(
240        display_name = "Count",
241        description = "Total number of files extracted"
242    )]
243    pub count: usize,
244}
245
246/// Information about an archive entry (for listing)
247#[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    /// Path within the archive
255    #[field(
256        display_name = "Path",
257        description = "Path of the file within the archive"
258    )]
259    pub path: String,
260
261    /// Uncompressed size in bytes
262    #[field(display_name = "Size", description = "Uncompressed file size in bytes")]
263    pub size: u64,
264
265    /// Compressed size in bytes
266    #[field(
267        display_name = "Compressed Size",
268        description = "Compressed file size in bytes"
269    )]
270    pub compressed_size: u64,
271
272    /// Whether this entry is a directory
273    #[field(
274        display_name = "Is Directory",
275        description = "True if this entry is a directory"
276    )]
277    pub is_directory: bool,
278}
279
280/// Output for list_archive capability
281#[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    /// List of entries in the archive
289    #[field(
290        display_name = "Entries",
291        description = "List of files and directories"
292    )]
293    pub entries: Vec<ArchiveEntryInfo>,
294
295    /// Total number of entries
296    #[field(display_name = "Total Count", description = "Total number of entries")]
297    pub total_count: usize,
298
299    /// Total uncompressed size in bytes
300    #[field(
301        display_name = "Total Size",
302        description = "Total uncompressed size in bytes"
303    )]
304    pub total_size: u64,
305
306    /// Detected or specified archive format
307    #[field(display_name = "Format", description = "Archive format")]
308    pub format: ArchiveFormat,
309}
310
311// ============================================================================
312// Helper Functions
313// ============================================================================
314
315/// Infer MIME type from file extension
316fn 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
336/// Extract filename from a path
337fn filename_from_path(path: &str) -> String {
338    path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
339}
340
341// ============================================================================
342// Capabilities
343// ============================================================================
344
345/// Create an archive from multiple files
346#[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            // Determine path within archive
388            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/// Extract all files from an archive
416#[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    // Currently only ZIP is supported
426    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            // Include directory entries but with empty content
449            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/// Extract a single file from an archive by path
482#[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    // Currently only ZIP is supported
492    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    // Try different path variations to find the file
503    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                // Re-fetch by name since we consumed the file in the check
514                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    // Re-open the archive to get the file (since by_name consumes)
524    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/// List all entries in an archive without extracting
547#[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    // Currently only ZIP is supported
557    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// ============================================================================
603// Tests
604// ============================================================================
605
606#[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        // Verify we can decode the archive
635        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        // First create an archive
678        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        // Now extract it
697        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        // Verify file contents
707        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        // Create an archive with multiple files
719        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        // Extract just one file
741        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); // 4 + 8 bytes
809
810        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        // Test that raw base64 string input works
847        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}