rustant_tools/
file_organizer.rs1use async_trait::async_trait;
4use rustant_core::error::ToolError;
5use rustant_core::types::{RiskLevel, ToolOutput};
6use serde_json::{Value, json};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::time::Duration;
11use walkdir::WalkDir;
12
13use crate::registry::Tool;
14
15pub struct FileOrganizerTool {
16 workspace: PathBuf,
17}
18
19impl FileOrganizerTool {
20 pub fn new(workspace: PathBuf) -> Self {
21 Self { workspace }
22 }
23
24 fn hash_file(path: &std::path::Path) -> Option<String> {
25 let data = std::fs::read(path).ok()?;
26 let mut hasher = Sha256::new();
27 hasher.update(&data);
28 Some(format!("{:x}", hasher.finalize()))
29 }
30}
31
32#[async_trait]
33impl Tool for FileOrganizerTool {
34 fn name(&self) -> &str {
35 "file_organizer"
36 }
37 fn description(&self) -> &str {
38 "Organize, deduplicate, and clean up files. Actions: organize, dedup, cleanup, preview."
39 }
40 fn parameters_schema(&self) -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "action": {
45 "type": "string",
46 "enum": ["organize", "dedup", "cleanup", "preview"],
47 "description": "Action to perform"
48 },
49 "path": { "type": "string", "description": "Target directory path" },
50 "pattern": { "type": "string", "description": "File glob pattern for cleanup (e.g., '*.tmp')" },
51 "dry_run": { "type": "boolean", "description": "Preview changes without applying (default: true)", "default": true }
52 },
53 "required": ["action"]
54 })
55 }
56 fn risk_level(&self) -> RiskLevel {
57 RiskLevel::Write
58 }
59 fn timeout(&self) -> Duration {
60 Duration::from_secs(120)
61 }
62
63 async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
64 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
65 let target = args
66 .get("path")
67 .and_then(|v| v.as_str())
68 .map(|p| self.workspace.join(p))
69 .unwrap_or_else(|| self.workspace.clone());
70 let dry_run = args
71 .get("dry_run")
72 .and_then(|v| v.as_bool())
73 .unwrap_or(true);
74
75 let canonical = target.canonicalize().unwrap_or_else(|_| target.clone());
77 if !canonical.starts_with(&self.workspace) {
78 return Ok(ToolOutput::text("Error: Path must be within workspace."));
79 }
80
81 match action {
82 "organize" => {
83 let mut by_ext: HashMap<String, Vec<String>> = HashMap::new();
85 for entry in WalkDir::new(&target)
86 .max_depth(1)
87 .into_iter()
88 .filter_map(|e| e.ok())
89 {
90 if entry.file_type().is_file() {
91 let ext = entry
92 .path()
93 .extension()
94 .and_then(|e| e.to_str())
95 .unwrap_or("no_extension")
96 .to_lowercase();
97 by_ext
98 .entry(ext)
99 .or_default()
100 .push(entry.file_name().to_string_lossy().to_string());
101 }
102 }
103 let mut output = String::from("File organization preview:\n");
104 for (ext, files) in &by_ext {
105 output.push_str(&format!(
106 " .{} ({} files): {}\n",
107 ext,
108 files.len(),
109 files.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
110 ));
111 }
112 if dry_run {
113 output.push_str("\n(Dry run — no changes made. Set dry_run=false to apply.)");
114 } else {
115 let mut moved = 0;
117 for (ext, files) in &by_ext {
118 let ext_dir = target.join(ext);
119 std::fs::create_dir_all(&ext_dir).ok();
120 for file in files {
121 let src = target.join(file);
122 let dst = ext_dir.join(file);
123 if src != dst && std::fs::rename(&src, &dst).is_ok() {
124 moved += 1;
125 }
126 }
127 }
128 output.push_str(&format!(
129 "\nMoved {} files into extension-based folders.",
130 moved
131 ));
132 }
133 Ok(ToolOutput::text(output))
134 }
135 "dedup" => {
136 let mut hashes: HashMap<String, Vec<PathBuf>> = HashMap::new();
137 let mut file_count = 0;
138 for entry in WalkDir::new(&target)
139 .max_depth(3)
140 .into_iter()
141 .filter_map(|e| e.ok())
142 {
143 if entry.file_type().is_file() {
144 file_count += 1;
145 if let Some(hash) = Self::hash_file(entry.path()) {
146 hashes
147 .entry(hash)
148 .or_default()
149 .push(entry.path().to_path_buf());
150 }
151 }
152 }
153 let dups: Vec<_> = hashes.values().filter(|v| v.len() > 1).collect();
154 if dups.is_empty() {
155 return Ok(ToolOutput::text(format!(
156 "No duplicates found among {} files.",
157 file_count
158 )));
159 }
160 let mut output = format!(
161 "Found {} duplicate groups among {} files:\n",
162 dups.len(),
163 file_count
164 );
165 for (i, group) in dups.iter().enumerate().take(20) {
166 output.push_str(&format!(" Group {}:\n", i + 1));
167 for path in *group {
168 let rel = path.strip_prefix(&self.workspace).unwrap_or(path);
169 output.push_str(&format!(" {}\n", rel.display()));
170 }
171 }
172 if dry_run {
173 output.push_str("\n(Dry run — no files deleted.)");
174 }
175 Ok(ToolOutput::text(output))
176 }
177 "cleanup" => {
178 let pattern = args
179 .get("pattern")
180 .and_then(|v| v.as_str())
181 .unwrap_or("*.tmp");
182 let glob = globset::GlobBuilder::new(pattern)
183 .build()
184 .map(|g| g.compile_matcher())
185 .ok();
186 let mut matches = Vec::new();
187 for entry in WalkDir::new(&target)
188 .max_depth(3)
189 .into_iter()
190 .filter_map(|e| e.ok())
191 {
192 if entry.file_type().is_file() {
193 let name = entry.file_name().to_string_lossy();
194 if let Some(ref glob) = glob
195 && glob.is_match(name.as_ref())
196 {
197 matches.push(entry.path().to_path_buf());
198 }
199 }
200 }
201 if matches.is_empty() {
202 return Ok(ToolOutput::text(format!(
203 "No files matching '{}'.",
204 pattern
205 )));
206 }
207 let mut output = format!("Found {} files matching '{}':\n", matches.len(), pattern);
208 for path in &matches {
209 let rel = path.strip_prefix(&self.workspace).unwrap_or(path);
210 output.push_str(&format!(" {}\n", rel.display()));
211 }
212 if dry_run {
213 output.push_str("\n(Dry run — no files deleted.)");
214 } else {
215 let mut deleted = 0;
216 for path in &matches {
217 if std::fs::remove_file(path).is_ok() {
218 deleted += 1;
219 }
220 }
221 output.push_str(&format!("\nDeleted {} files.", deleted));
222 }
223 Ok(ToolOutput::text(output))
224 }
225 "preview" => {
226 let mut total_files = 0;
227 let mut total_size: u64 = 0;
228 let mut by_ext: HashMap<String, (usize, u64)> = HashMap::new();
229 for entry in WalkDir::new(&target)
230 .max_depth(3)
231 .into_iter()
232 .filter_map(|e| e.ok())
233 {
234 if entry.file_type().is_file() {
235 total_files += 1;
236 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
237 total_size += size;
238 let ext = entry
239 .path()
240 .extension()
241 .and_then(|e| e.to_str())
242 .unwrap_or("none")
243 .to_lowercase();
244 let entry = by_ext.entry(ext).or_insert((0, 0));
245 entry.0 += 1;
246 entry.1 += size;
247 }
248 }
249 let mut output = format!(
250 "Directory preview: {} files, {:.1} MB\n",
251 total_files,
252 total_size as f64 / 1_048_576.0
253 );
254 let mut sorted: Vec<_> = by_ext.iter().collect();
255 sorted.sort_by(|a, b| b.1.1.cmp(&a.1.1));
256 for (ext, (count, size)) in sorted.iter().take(15) {
257 output.push_str(&format!(
258 " .{:<10} {:>5} files {:>8.1} KB\n",
259 ext,
260 count,
261 *size as f64 / 1024.0
262 ));
263 }
264 Ok(ToolOutput::text(output))
265 }
266 _ => Ok(ToolOutput::text(format!(
267 "Unknown action: {}. Use: organize, dedup, cleanup, preview",
268 action
269 ))),
270 }
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use tempfile::TempDir;
278
279 #[tokio::test]
280 async fn test_file_organizer_preview() {
281 let dir = TempDir::new().unwrap();
282 let workspace = dir.path().canonicalize().unwrap();
283 std::fs::write(workspace.join("test.txt"), "hello").unwrap();
284 std::fs::write(workspace.join("data.csv"), "a,b").unwrap();
285
286 let tool = FileOrganizerTool::new(workspace);
287 let result = tool.execute(json!({"action": "preview"})).await.unwrap();
288 assert!(result.content.contains("files"));
289 }
290
291 #[tokio::test]
292 async fn test_file_organizer_dedup() {
293 let dir = TempDir::new().unwrap();
294 let workspace = dir.path().canonicalize().unwrap();
295 std::fs::write(workspace.join("a.txt"), "same content").unwrap();
296 std::fs::write(workspace.join("b.txt"), "same content").unwrap();
297 std::fs::write(workspace.join("c.txt"), "different").unwrap();
298
299 let tool = FileOrganizerTool::new(workspace);
300 let result = tool
301 .execute(json!({"action": "dedup", "dry_run": true}))
302 .await
303 .unwrap();
304 assert!(result.content.contains("duplicate"));
305 }
306
307 #[tokio::test]
308 async fn test_file_organizer_no_dupes() {
309 let dir = TempDir::new().unwrap();
310 let workspace = dir.path().canonicalize().unwrap();
311 std::fs::write(workspace.join("a.txt"), "unique a").unwrap();
312 std::fs::write(workspace.join("b.txt"), "unique b").unwrap();
313
314 let tool = FileOrganizerTool::new(workspace);
315 let result = tool.execute(json!({"action": "dedup"})).await.unwrap();
316 assert!(result.content.contains("No duplicates"));
317 }
318
319 #[tokio::test]
320 async fn test_file_organizer_schema() {
321 let dir = TempDir::new().unwrap();
322 let tool = FileOrganizerTool::new(dir.path().to_path_buf());
323 assert_eq!(tool.name(), "file_organizer");
324 }
325}