rustant_tools/
compress.rs1use 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 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 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 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 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 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 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}