Skip to main content

rustant_tools/
file_organizer.rs

1//! File organizer tool — organize, deduplicate, and clean up files.
2
3use 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        // Validate target is within workspace
76        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                // Group files by extension
84                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                    // Create subdirectories by extension and move files
116                    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}