Skip to main content

rustant_tools/
compress.rs

1//! Compression tool — create and extract zip archives.
2
3use async_trait::async_trait;
4use rustant_core::error::ToolError;
5use rustant_core::types::{RiskLevel, ToolOutput};
6use serde_json::{Value, json};
7use std::io::{Read, Write};
8use std::path::PathBuf;
9use std::time::Duration;
10
11use crate::registry::Tool;
12
13pub struct CompressTool {
14    workspace: PathBuf,
15}
16
17impl CompressTool {
18    pub fn new(workspace: PathBuf) -> Self {
19        Self { workspace }
20    }
21}
22
23#[async_trait]
24impl Tool for CompressTool {
25    fn name(&self) -> &str {
26        "compress"
27    }
28    fn description(&self) -> &str {
29        "Create and extract zip archives. Actions: create_zip, extract_zip, list_zip."
30    }
31    fn parameters_schema(&self) -> Value {
32        json!({
33            "type": "object",
34            "properties": {
35                "action": {
36                    "type": "string",
37                    "enum": ["create_zip", "extract_zip", "list_zip"],
38                    "description": "Action to perform"
39                },
40                "archive": { "type": "string", "description": "Path to the zip archive" },
41                "files": {
42                    "type": "array",
43                    "items": { "type": "string" },
44                    "description": "Files to add to archive (for create_zip)"
45                },
46                "output_dir": { "type": "string", "description": "Output directory (for extract_zip)" }
47            },
48            "required": ["action", "archive"]
49        })
50    }
51    fn risk_level(&self) -> RiskLevel {
52        RiskLevel::Write
53    }
54    fn timeout(&self) -> Duration {
55        Duration::from_secs(120)
56    }
57
58    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
59        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
60        let archive_str = args.get("archive").and_then(|v| v.as_str()).unwrap_or("");
61        let archive_path = self.workspace.join(archive_str);
62
63        match action {
64            "create_zip" => {
65                let files: Vec<String> = args
66                    .get("files")
67                    .and_then(|v| serde_json::from_value(v.clone()).ok())
68                    .unwrap_or_default();
69                if files.is_empty() {
70                    return Ok(ToolOutput::text(
71                        "Please provide files to add to the archive.",
72                    ));
73                }
74
75                let file = std::fs::File::create(&archive_path).map_err(|e| {
76                    ToolError::ExecutionFailed {
77                        name: "compress".into(),
78                        message: format!("Failed to create archive: {}", e),
79                    }
80                })?;
81                let mut zip = zip::ZipWriter::new(file);
82                let options = zip::write::SimpleFileOptions::default()
83                    .compression_method(zip::CompressionMethod::Deflated);
84
85                let mut added = 0;
86                for file_str in &files {
87                    let file_path = self.workspace.join(file_str);
88                    if !file_path.exists() {
89                        continue;
90                    }
91                    let name = file_path
92                        .strip_prefix(&self.workspace)
93                        .unwrap_or(&file_path)
94                        .to_string_lossy()
95                        .to_string();
96                    if let Ok(mut f) = std::fs::File::open(&file_path) {
97                        let mut buf = Vec::new();
98                        if f.read_to_end(&mut buf).is_ok()
99                            && zip.start_file(&name, options).is_ok()
100                            && zip.write_all(&buf).is_ok()
101                        {
102                            added += 1;
103                        }
104                    }
105                }
106                zip.finish().map_err(|e| ToolError::ExecutionFailed {
107                    name: "compress".into(),
108                    message: format!("Failed to finalize archive: {}", e),
109                })?;
110
111                Ok(ToolOutput::text(format!(
112                    "Created {} with {} files.",
113                    archive_str, added
114                )))
115            }
116            "extract_zip" => {
117                if !archive_path.exists() {
118                    return Ok(ToolOutput::text(format!(
119                        "Archive not found: {}",
120                        archive_str
121                    )));
122                }
123                let output_dir = args
124                    .get("output_dir")
125                    .and_then(|v| v.as_str())
126                    .map(|p| self.workspace.join(p))
127                    .unwrap_or_else(|| self.workspace.clone());
128
129                let file =
130                    std::fs::File::open(&archive_path).map_err(|e| ToolError::ExecutionFailed {
131                        name: "compress".into(),
132                        message: format!("Failed to open archive: {}", e),
133                    })?;
134                let mut archive =
135                    zip::ZipArchive::new(file).map_err(|e| ToolError::ExecutionFailed {
136                        name: "compress".into(),
137                        message: format!("Invalid zip archive: {}", e),
138                    })?;
139
140                let mut extracted = 0;
141                for i in 0..archive.len() {
142                    if let Ok(mut entry) = archive.by_index(i) {
143                        let name = entry.name().to_string();
144                        // Security: prevent path traversal
145                        if name.contains("..") {
146                            continue;
147                        }
148                        let out_path = output_dir.join(&name);
149                        if entry.is_dir() {
150                            std::fs::create_dir_all(&out_path).ok();
151                        } else {
152                            if let Some(parent) = out_path.parent() {
153                                std::fs::create_dir_all(parent).ok();
154                            }
155                            let mut buf = Vec::new();
156                            if entry.read_to_end(&mut buf).is_ok()
157                                && std::fs::write(&out_path, &buf).is_ok()
158                            {
159                                extracted += 1;
160                            }
161                        }
162                    }
163                }
164                Ok(ToolOutput::text(format!(
165                    "Extracted {} files from {}.",
166                    extracted, archive_str
167                )))
168            }
169            "list_zip" => {
170                if !archive_path.exists() {
171                    return Ok(ToolOutput::text(format!(
172                        "Archive not found: {}",
173                        archive_str
174                    )));
175                }
176                let file =
177                    std::fs::File::open(&archive_path).map_err(|e| ToolError::ExecutionFailed {
178                        name: "compress".into(),
179                        message: format!("Failed to open archive: {}", e),
180                    })?;
181                let mut archive =
182                    zip::ZipArchive::new(file).map_err(|e| ToolError::ExecutionFailed {
183                        name: "compress".into(),
184                        message: format!("Invalid zip archive: {}", e),
185                    })?;
186
187                let mut output = format!("Archive: {} ({} entries)\n", archive_str, archive.len());
188                for i in 0..archive.len() {
189                    if let Ok(entry) = archive.by_index_raw(i) {
190                        output.push_str(&format!("  {} ({} bytes)\n", entry.name(), entry.size()));
191                    }
192                }
193                Ok(ToolOutput::text(output))
194            }
195            _ => Ok(ToolOutput::text(format!(
196                "Unknown action: {}. Use: create_zip, extract_zip, list_zip",
197                action
198            ))),
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use tempfile::TempDir;
207
208    #[tokio::test]
209    async fn test_compress_create_extract_roundtrip() {
210        let dir = TempDir::new().unwrap();
211        let workspace = dir.path().canonicalize().unwrap();
212
213        // Create test files
214        std::fs::write(workspace.join("a.txt"), "Hello A").unwrap();
215        std::fs::write(workspace.join("b.txt"), "Hello B").unwrap();
216
217        let tool = CompressTool::new(workspace.clone());
218
219        // Create archive
220        let result = tool
221            .execute(json!({
222                "action": "create_zip",
223                "archive": "test.zip",
224                "files": ["a.txt", "b.txt"]
225            }))
226            .await
227            .unwrap();
228        assert!(result.content.contains("2 files"));
229
230        // List archive
231        let result = tool
232            .execute(json!({"action": "list_zip", "archive": "test.zip"}))
233            .await
234            .unwrap();
235        assert!(result.content.contains("a.txt"));
236        assert!(result.content.contains("b.txt"));
237
238        // Extract to subdir
239        std::fs::create_dir_all(workspace.join("output")).unwrap();
240        let result = tool
241            .execute(json!({
242                "action": "extract_zip",
243                "archive": "test.zip",
244                "output_dir": "output"
245            }))
246            .await
247            .unwrap();
248        assert!(result.content.contains("Extracted 2"));
249
250        // Verify extracted files
251        assert_eq!(
252            std::fs::read_to_string(workspace.join("output/a.txt")).unwrap(),
253            "Hello A"
254        );
255    }
256
257    #[tokio::test]
258    async fn test_compress_nonexistent_archive() {
259        let dir = TempDir::new().unwrap();
260        let workspace = dir.path().canonicalize().unwrap();
261        let tool = CompressTool::new(workspace);
262
263        let result = tool
264            .execute(json!({"action": "list_zip", "archive": "nope.zip"}))
265            .await
266            .unwrap();
267        assert!(result.content.contains("not found"));
268    }
269
270    #[tokio::test]
271    async fn test_compress_schema() {
272        let dir = TempDir::new().unwrap();
273        let tool = CompressTool::new(dir.path().to_path_buf());
274        assert_eq!(tool.name(), "compress");
275    }
276}