cats/utils/
mod.rs

1//! Utility tools for project analysis and task completion
2
3use crate::core::{Tool, ToolArgs, ToolError, ToolResult};
4use crate::state::ToolState;
5use anyhow::Result;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use walkdir::WalkDir;
10
11use crate::search::ConfigurableFilter;
12
13mod count_tokens;
14
15pub use count_tokens::CountTokensTool;
16
17/// Tool for task classification
18pub struct ClassifyTaskTool {
19    name: String,
20}
21
22impl ClassifyTaskTool {
23    pub fn new() -> Self {
24        Self {
25            name: "classify_task".to_string(),
26        }
27    }
28}
29
30impl Tool for ClassifyTaskTool {
31    fn name(&self) -> &str {
32        &self.name
33    }
34
35    fn description(&self) -> &str {
36        "Classify a task request into one of the supported categories: bug_fix, feature, maintenance, or query"
37    }
38
39    fn signature(&self) -> &str {
40        "classify_task <task_type>"
41    }
42
43    fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
44        if args.is_empty() {
45            return Err(ToolError::InvalidArgs {
46                message: "Usage: classify_task <task_type>. Valid types: bug_fix, feature, maintenance, query".to_string(),
47            });
48        }
49
50        let task_type = args.get_arg(0).unwrap();
51        match task_type.as_str() {
52            "bug_fix" | "feature" | "maintenance" | "query" => Ok(()),
53            _ => Err(ToolError::InvalidArgs {
54                message: format!(
55                    "Invalid task type: {}. Valid types: bug_fix, feature, maintenance, query",
56                    task_type
57                ),
58            }),
59        }
60    }
61
62    fn execute(&mut self, args: &ToolArgs, state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
63        let task_type = args.get_arg(0).unwrap();
64
65        // Update state with classification result
66        {
67            let mut state_guard = state
68                .lock()
69                .map_err(|e| anyhow::anyhow!("Failed to lock state: {}", e))?;
70            state_guard.push_history(format!("Classified task as: {}", task_type));
71        }
72
73        Ok(ToolResult::success_with_data(
74            format!("Task classified as: {}", task_type),
75            serde_json::json!({
76                "task_type": task_type,
77                "action": "classify_task"
78            }),
79        ))
80    }
81
82    fn get_parameters_schema(&self) -> serde_json::Value {
83        serde_json::json!({
84            "type": "object",
85            "properties": {
86                "task_type": {
87                    "type": "string",
88                    "description": "The task type classification",
89                    "enum": ["bug_fix", "feature", "maintenance", "query"]
90                }
91            },
92            "required": ["task_type"]
93        })
94    }
95}
96
97pub struct FilemapTool {
98    name: String,
99}
100
101impl FilemapTool {
102    pub fn new() -> Self {
103        Self {
104            name: "filemap".to_string(),
105        }
106    }
107
108    /// Check if a file should be included in the filemap
109    #[allow(dead_code)]
110    fn should_include_file(path: &Path) -> bool {
111        if let Some(name) = path.file_name() {
112            let name_str = name.to_string_lossy();
113
114            // Skip hidden files and directories
115            if name_str.starts_with('.') {
116                return false;
117            }
118
119            // Skip common build/cache directories
120            if matches!(
121                name_str.as_ref(),
122                "target"
123                    | "node_modules"
124                    | "__pycache__"
125                    | "dist"
126                    | "build"
127                    | ".git"
128                    | ".svn"
129                    | ".hg"
130                    | "venv"
131                    | "env"
132                    | ".venv"
133            ) {
134                return false;
135            }
136
137            // Skip binary and cache files
138            if let Some(ext) = path.extension() {
139                let ext_str = ext.to_string_lossy().to_lowercase();
140                if matches!(
141                    ext_str.as_str(),
142                    "exe"
143                        | "dll"
144                        | "so"
145                        | "dylib"
146                        | "a"
147                        | "o"
148                        | "pyc"
149                        | "png"
150                        | "jpg"
151                        | "jpeg"
152                        | "gif"
153                        | "bmp"
154                        | "ico"
155                        | "mp3"
156                        | "mp4"
157                        | "avi"
158                        | "mov"
159                        | "wav"
160                        | "pdf"
161                        | "zip"
162                        | "tar"
163                        | "gz"
164                        | "rar"
165                        | "7z"
166                ) {
167                    return false;
168                }
169            }
170        }
171
172        true
173    }
174
175    /// Generate a tree-like directory structure
176    fn generate_tree(path: &Path, max_depth: usize) -> Result<String> {
177        let mut result = String::new();
178
179        if path.is_file() {
180            // If it's a file, just show its content summary
181            return Self::show_file_content(path);
182        }
183
184        result.push_str(&format!("📁 {}\n", path.display()));
185
186        // Create a single ConfigurableFilter instance to avoid re-reading config per entry
187        let filter = ConfigurableFilter::new(None);
188
189        // Use `filter_entry` to prevent walking into ignored directories (e.g. `.git`, `target`).
190        // We still `filter_map` the iterator to ignore IO errors and then skip the root path itself.
191        let mut entries: Vec<_> = WalkDir::new(path)
192            .max_depth(max_depth)
193            .into_iter()
194            .filter_entry(|e| filter.should_include_path(e.path()))
195            .filter_map(|e| e.ok())
196            .filter(|e| e.path() != path)
197            .collect();
198
199        // Sort entries by full path so items that belong to the same directory
200        // are listed next to each other. This ensures files appear directly
201        // under their parent directory instead of printing all directories
202        // first which separates files from their directory context.
203        entries.sort_by(|a, b| a.path().cmp(b.path()));
204
205        let mut file_count = 0;
206        let mut dir_count = 0;
207
208        for entry in entries.iter().take(100) {
209            // Limit to first 100 entries
210            let depth = entry.depth();
211            let indent = "  ".repeat(depth);
212            let name = entry.file_name().to_string_lossy();
213
214            if entry.file_type().is_dir() {
215                result.push_str(&format!("{}📁 {}/\n", indent, name));
216                dir_count += 1;
217            } else {
218                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
219                result.push_str(&format!("{}📄 {} ({} bytes)\n", indent, name, size));
220                file_count += 1;
221            }
222        }
223
224        if entries.len() > 100 {
225            result.push_str(&format!("... and {} more items\n", entries.len() - 100));
226        }
227
228        result.push_str(&format!(
229            "\nSummary: {} directories, {} files\n",
230            dir_count, file_count
231        ));
232
233        Ok(result)
234    }
235
236    /// Show abbreviated file content (similar to SWE-agent's filemap for Python)
237    fn show_file_content(path: &Path) -> Result<String> {
238        let content =
239            fs::read_to_string(path).map_err(|e| anyhow::anyhow!("Failed to read file: {}", e))?;
240
241        let lines: Vec<&str> = content.lines().collect();
242        let mut result = String::new();
243
244        result.push_str(&format!("📄 {} ({} lines)\n", path.display(), lines.len()));
245
246        // Check if it's a Python file for special handling
247        if let Some(ext) = path.extension() {
248            if ext == "py" {
249                return Self::show_python_file_content(path, &lines);
250            }
251        }
252
253        // For non-Python files, show with elision of large blocks
254        let mut current_line = 0;
255        while current_line < lines.len() {
256            let line = lines[current_line];
257
258            // Check if this is the start of a large block (function, class, etc.)
259            if Self::is_block_start(line) {
260                // Find the end of this block
261                let block_end = Self::find_block_end(&lines, current_line);
262
263                // If block is more than 5 lines, elide it
264                if block_end - current_line > 5 {
265                    result.push_str(&format!("{:4} | {}\n", current_line + 1, line));
266                    result.push_str(&format!(
267                        "     | ... eliding lines {}-{} ...\n",
268                        current_line + 2,
269                        block_end
270                    ));
271                    current_line = block_end;
272                } else {
273                    // Show the small block normally
274                    for i in current_line..=block_end {
275                        if i < lines.len() {
276                            result.push_str(&format!("{:4} | {}\n", i + 1, lines[i]));
277                        }
278                    }
279                    current_line = block_end + 1;
280                }
281            } else {
282                result.push_str(&format!("{:4} | {}\n", current_line + 1, line));
283                current_line += 1;
284            }
285
286            // Limit total output
287            if result.len() > 50000 {
288                result.push_str("... (output truncated) ...\n");
289                break;
290            }
291        }
292
293        Ok(result)
294    }
295
296    /// Show Python file content with function/class elision
297    fn show_python_file_content(path: &Path, lines: &[&str]) -> Result<String> {
298        let mut result = String::new();
299        result.push_str(&format!("📄 {} ({} lines)\n", path.display(), lines.len()));
300
301        let mut current_line = 0;
302        while current_line < lines.len() {
303            let line = lines[current_line];
304            let trimmed = line.trim_start();
305
306            // Check for Python function or class definitions
307            if trimmed.starts_with("def ") || trimmed.starts_with("class ") {
308                let indent_level = line.len() - line.trim_start().len();
309
310                // Find the end of this function/class
311                let mut end_line = current_line + 1;
312                let mut found_body = false;
313
314                while end_line < lines.len() {
315                    let next_line = lines[end_line];
316                    let next_trimmed = next_line.trim();
317
318                    // Skip empty lines and comments
319                    if next_trimmed.is_empty() || next_trimmed.starts_with('#') {
320                        end_line += 1;
321                        continue;
322                    }
323
324                    let next_indent = next_line.len() - next_line.trim_start().len();
325
326                    // If we find content at the same or lower indentation level, we've reached the end
327                    if next_indent <= indent_level && found_body {
328                        break;
329                    }
330
331                    if next_indent > indent_level {
332                        found_body = true;
333                    }
334
335                    end_line += 1;
336                }
337
338                // Show the definition line
339                result.push_str(&format!("{:4} | {}\n", current_line + 1, line));
340
341                // If the function/class body is more than 5 lines, elide it
342                if end_line - current_line > 5 {
343                    result.push_str(&format!(
344                        "     | ... eliding lines {}-{} ...\n",
345                        current_line + 2,
346                        end_line
347                    ));
348                } else {
349                    // Show the small function/class normally
350                    for i in (current_line + 1)..end_line {
351                        if i < lines.len() {
352                            result.push_str(&format!("{:4} | {}\n", i + 1, lines[i]));
353                        }
354                    }
355                }
356
357                current_line = end_line;
358            } else {
359                result.push_str(&format!("{:4} | {}\n", current_line + 1, line));
360                current_line += 1;
361            }
362
363            // Limit total output
364            if result.len() > 50000 {
365                result.push_str("... (output truncated) ...\n");
366                break;
367            }
368        }
369
370        Ok(result)
371    }
372
373    /// Check if a line starts a block that should be elided
374    fn is_block_start(line: &str) -> bool {
375        let trimmed = line.trim_start();
376        trimmed.starts_with("def ") || 
377        trimmed.starts_with("class ") ||
378        trimmed.starts_with("fn ") ||  // Rust functions
379        trimmed.starts_with("function ") ||  // JavaScript functions
380        trimmed.starts_with("impl ") ||  // Rust impl blocks
381        (trimmed.starts_with("if ") && line.trim_end().ends_with(":")) ||  // Python if blocks
382        (trimmed.starts_with("for ") && line.trim_end().ends_with(":")) ||  // Python loops
383        (trimmed.starts_with("while ") && line.trim_end().ends_with(":")) // Python while loops
384    }
385
386    /// Find the end of a code block
387    fn find_block_end(lines: &[&str], start: usize) -> usize {
388        if start >= lines.len() {
389            return start;
390        }
391
392        let start_line = lines[start];
393        let indent_level = start_line.len() - start_line.trim_start().len();
394
395        let mut end_line = start + 1;
396        let mut found_body = false;
397
398        while end_line < lines.len() {
399            let line = lines[end_line];
400            let trimmed = line.trim();
401
402            // Skip empty lines and comments
403            if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('#') {
404                end_line += 1;
405                continue;
406            }
407
408            let line_indent = line.len() - line.trim_start().len();
409
410            // If we find content at the same or lower indentation level after finding body, we've reached the end
411            if line_indent <= indent_level && found_body {
412                break;
413            }
414
415            if line_indent > indent_level {
416                found_body = true;
417            }
418
419            end_line += 1;
420        }
421
422        end_line.saturating_sub(1)
423    }
424}
425
426impl Tool for FilemapTool {
427    fn name(&self) -> &str {
428        &self.name
429    }
430
431    fn description(&self) -> &str {
432        "Generate a file structure map or show file contents with abbreviated view"
433    }
434
435    fn signature(&self) -> &str {
436        "filemap <file_path>"
437    }
438
439    fn validate_args(&self, args: &ToolArgs) -> Result<(), ToolError> {
440        if args.is_empty() {
441            return Err(ToolError::InvalidArgs {
442                message: "Usage: filemap <file_path>".to_string(),
443            });
444        }
445        Ok(())
446    }
447
448    fn execute(&mut self, args: &ToolArgs, state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
449        let file_path = args.get_arg(0).unwrap();
450        let path_buf = PathBuf::from(file_path);
451
452        // Check if path exists
453        if !path_buf.exists() {
454            return Ok(ToolResult::error(format!("Path not found: {}", file_path)));
455        }
456
457        // Generate the filemap
458        let content = Self::generate_tree(&path_buf, 3)?; // Max depth of 3
459
460        // Update state
461        {
462            let mut state_guard = state
463                .lock()
464                .map_err(|e| anyhow::anyhow!("Failed to lock state: {}", e))?;
465            state_guard.push_history(format!("Generated filemap for: {}", file_path));
466        }
467
468        Ok(ToolResult::success_with_data(
469            content,
470            serde_json::json!({
471                "path": file_path,
472                "type": if path_buf.is_file() { "file" } else { "directory" },
473                "action": "filemap"
474            }),
475        ))
476    }
477
478    fn get_parameters_schema(&self) -> serde_json::Value {
479        serde_json::json!({
480            "type": "object",
481            "properties": {
482                "file_path": {
483                    "type": "string",
484                    "description": "The path to the file or directory to map"
485                }
486            },
487            "required": ["file_path"]
488        })
489    }
490}
491
492pub struct SubmitTool {
493    name: String,
494}
495
496impl SubmitTool {
497    pub fn new() -> Self {
498        Self {
499            name: "submit".to_string(),
500        }
501    }
502}
503
504impl Tool for SubmitTool {
505    fn name(&self) -> &str {
506        &self.name
507    }
508
509    fn description(&self) -> &str {
510        "Submit your completed task or solution"
511    }
512
513    fn signature(&self) -> &str {
514        "submit"
515    }
516
517    fn validate_args(&self, _args: &ToolArgs) -> Result<(), ToolError> {
518        Ok(()) // Submit takes no arguments
519    }
520
521    fn execute(&mut self, _args: &ToolArgs, state: &Arc<Mutex<ToolState>>) -> Result<ToolResult> {
522        // Update state
523        {
524            let mut state_guard = state
525                .lock()
526                .map_err(|e| anyhow::anyhow!("Failed to lock state: {}", e))?;
527            state_guard.push_history("Task submitted".to_string());
528        }
529
530        Ok(ToolResult::success_with_data(
531            "Task has been submitted successfully".to_string(),
532            serde_json::json!({
533                "action": "submit",
534                "status": "completed"
535            }),
536        ))
537    }
538
539    fn get_parameters_schema(&self) -> serde_json::Value {
540        serde_json::json!({
541            "type": "object",
542            "properties": {},
543            "required": []
544        })
545    }
546}