Skip to main content

phi_core/tools/
list.rs

1//! List files tool — directory exploration.
2/*
3ARCHITECTURE: ListFilesTool — delegate to `find` for directory traversal
4
5Like SearchTool, we delegate to the system's `find` command rather than implementing
6directory walking in Rust. This gives us `-maxdepth`, `-name` glob filtering, and
7automatic exclusion of noisy directories (target/, .git/, node_modules/).
8
9Default exclusions prevent the agent from drowning in build artifacts:
10  target/         — Rust build output
11  .git/           — git internals
12  node_modules/   — npm packages
13
14RUST QUIRK: `.lines().collect()` — splitting output into a Vec<&str>
15  `stdout.lines()` → `Lines<'_>` iterator yielding `&str` slices of each line.
16  `.collect()` → `Vec<&str>` (borrowing slices into `stdout`).
17  The `&str` lifetime is tied to `stdout` (a `String`), which lives in this function.
18  Python analogy: `lines = stdout.splitlines()`
19*/
20
21use crate::types::*;
22use async_trait::async_trait;
23use std::time::Duration;
24use tokio::process::Command;
25
26/// List files and directories. Uses `find` or `fd` for efficient traversal.
27pub struct ListFilesTool {
28    pub max_results: usize,
29    pub timeout: Duration,
30}
31
32impl Default for ListFilesTool {
33    fn default() -> Self {
34        Self {
35            max_results: 200,
36            timeout: Duration::from_secs(10),
37        }
38    }
39}
40
41impl ListFilesTool {
42    pub fn new() -> Self {
43        Self::default()
44    }
45}
46
47#[async_trait]
48impl AgentTool for ListFilesTool {
49    fn name(&self) -> &str {
50        "list_files"
51    }
52
53    fn label(&self) -> &str {
54        "List Files"
55    }
56
57    fn description(&self) -> &str {
58        "List files and directories. Optionally filter by glob pattern. Use to explore project structure before reading specific files."
59    }
60
61    fn parameters_schema(&self) -> serde_json::Value {
62        serde_json::json!({
63            "type": "object",
64            "properties": {
65                "path": {
66                    "type": "string",
67                    "description": "Directory to list (default: current directory)"
68                },
69                "pattern": {
70                    "type": "string",
71                    "description": "Glob pattern to filter files, e.g. '*.rs' (optional)"
72                },
73                "max_depth": {
74                    "type": "integer",
75                    "description": "Maximum directory depth (default: 3)"
76                }
77            }
78        })
79    }
80
81    async fn execute(
82        &self,
83        params: serde_json::Value, // LLM INPUT — expects `{"path"?, "pattern"?, "max_depth"?}`; all optional with sensible defaults
84        ctx: ToolContext, // SYSTEM ENV — ctx.cancel raced against find timeout in tokio::select!
85    ) -> Result<ToolResult, ToolError> {
86        let cancel = ctx.cancel;
87        let path = params["path"].as_str().unwrap_or(".");
88        let pattern = params["pattern"].as_str();
89        let max_depth = params["max_depth"].as_u64().unwrap_or(3);
90
91        if cancel.is_cancelled() {
92            return Err(ToolError::Cancelled);
93        }
94
95        // Check path exists
96        if !std::path::Path::new(path).exists() {
97            return Err(ToolError::Failed(format!(
98                "Directory not found: {}. Check the path and try again.",
99                path
100            )));
101        }
102
103        let mut cmd = Command::new("find");
104        cmd.arg(path);
105        cmd.args(["-maxdepth", &max_depth.to_string()]);
106
107        if let Some(pat) = pattern {
108            cmd.args(["-name", pat]);
109        }
110
111        // Exclude common noise
112        cmd.args(["-not", "-path", "*/target/*"]);
113        cmd.args(["-not", "-path", "*/.git/*"]);
114        cmd.args(["-not", "-path", "*/node_modules/*"]);
115
116        cmd.arg("-type").arg("f");
117        cmd.stdout(std::process::Stdio::piped());
118        cmd.stderr(std::process::Stdio::piped());
119
120        let timeout = self.timeout;
121
122        let result = tokio::select! {
123            _ = cancel.cancelled() => return Err(ToolError::Cancelled),
124            _ = tokio::time::sleep(timeout) => return Err(ToolError::Failed("Listing timed out".into())),
125            result = cmd.output() => {
126                result.map_err(|e| ToolError::Failed(format!("Failed to list: {}", e)))?
127            }
128        };
129
130        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
131        let mut lines: Vec<&str> = stdout.lines().collect();
132        lines.sort();
133
134        let total = lines.len();
135        let truncated = total > self.max_results;
136        if truncated {
137            lines.truncate(self.max_results);
138        }
139
140        let text = if lines.is_empty() {
141            format!("No files found in {}", path)
142        } else if truncated {
143            format!(
144                "{}\n\n... ({} files, showing first {})",
145                lines.join("\n"),
146                total,
147                self.max_results
148            )
149        } else {
150            format!("{}\n\n({} files)", lines.join("\n"), total)
151        };
152
153        Ok(ToolResult {
154            content: vec![Content::Text { text }],
155            details: serde_json::json!({ "total": total, "truncated": truncated }),
156            child_loop_id: None,
157        })
158    }
159}