Skip to main content

agent_engine/tools/
ls.rs

1use super::util::path_arg_never_option;
2use super::{resolve_path_in, Tool, ToolContext};
3use crate::{Result, RuntimeError};
4use serde_json::{json, Value};
5use tokio::process::Command;
6
7pub struct LsTool;
8
9#[async_trait::async_trait]
10impl Tool for LsTool {
11    fn effect(&self) -> crate::tools::catalog::ToolEffect {
12        crate::tools::catalog::ToolEffect::ReadOnly
13    }
14
15    fn origin(&self) -> crate::tools::ToolOrigin {
16        crate::tools::ToolOrigin::Builtin
17    }
18
19    fn name(&self) -> &str {
20        "ls"
21    }
22
23    fn description(&self) -> &str {
24        "List directory contents with details (permissions, size, modification date). Defaults to current directory."
25    }
26
27    fn parameters(&self) -> Value {
28        json!({
29            "type": "object",
30            "properties": {
31                "path": {
32                    "type": "string",
33                    "description": "Directory path to list (default: current directory)"
34                }
35            },
36            "required": []
37        })
38    }
39
40    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
41        let output = build_command(&params, &ctx)?
42            .output()
43            .await
44            .map_err(|e| RuntimeError::Tool(format!("Failed to execute ls: {}", e)))?;
45
46        let stdout = String::from_utf8_lossy(&output.stdout);
47        let stderr = String::from_utf8_lossy(&output.stderr);
48
49        if !output.status.success() {
50            Err(RuntimeError::Tool(format!("ls failed: {}", stderr)))
51        } else if stdout.is_empty() {
52            Ok("Directory is empty.".to_string())
53        } else {
54            Ok(stdout.to_string())
55        }
56    }
57}
58
59/// Build the `ls` argv (split out for tests).
60fn build_command(params: &Value, ctx: &ToolContext) -> Result<Command> {
61    let path = path_arg_never_option(resolve_path_in(
62        params["path"].as_str().unwrap_or("."),
63        ctx.capabilities.cwd.as_deref(),
64    ));
65    let mut cmd = super::unix_programs::unix_tool_command("ls", ctx)?;
66    cmd.arg("-lah").arg(&path);
67    Ok(cmd)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::super::test_helpers::create_tool_context;
73    use super::*;
74    use crate::tools::Tool;
75    use serde_json::json;
76
77    #[test]
78    fn test_ls_tool_schema() {
79        let tool = LsTool;
80        assert_eq!(tool.name(), "ls");
81        assert!(!tool.description().is_empty());
82
83        let params = tool.parameters();
84        assert_eq!(params["type"], "object");
85        assert!(params["properties"].is_object());
86        assert!(params["required"].is_array());
87    }
88
89    #[tokio::test]
90    async fn test_ls_tool_execution() {
91        let tool = LsTool;
92        let ctx = create_tool_context();
93
94        // Use a dedicated temp dir to avoid races with other tests
95        let dir = std::env::temp_dir().join("synaps_test_ls");
96        std::fs::create_dir_all(&dir).unwrap();
97        std::fs::write(dir.join("hello.txt"), "hi").unwrap();
98
99        let params = json!({
100            "path": dir.to_str().unwrap()
101        });
102
103        let result = tool.execute(params, ctx).await.unwrap();
104        assert!(result.contains("hello.txt"));
105
106        // Cleanup
107        std::fs::remove_dir_all(&dir).ok();
108    }
109
110    /// 01 I3 audit: an option-like `path` must reach `ls` as a path.
111    #[test]
112    fn leading_dash_path_is_never_an_option() {
113        let ctx = create_tool_context();
114        let cmd = build_command(&json!({"path": "-R"}), &ctx).unwrap();
115        let args: Vec<String> = cmd
116            .as_std()
117            .get_args()
118            .map(|a| a.to_string_lossy().into_owned())
119            .collect();
120        let dashed = format!(".{}-R", std::path::MAIN_SEPARATOR);
121        assert_eq!(args, ["-lah", dashed.as_str()]);
122    }
123
124    #[tokio::test]
125    async fn lists_a_file_whose_name_starts_with_dash() {
126        let dir = tempfile::tempdir().unwrap();
127        std::fs::write(dir.path().join("-x"), "").unwrap();
128        std::fs::write(dir.path().join("other.txt"), "").unwrap();
129        let ctx = create_tool_context();
130        let mut cmd = build_command(&json!({"path": "-x"}), &ctx).unwrap();
131        cmd.current_dir(dir.path());
132        let out = cmd.output().await.unwrap();
133        assert!(
134            out.status.success(),
135            "{}",
136            String::from_utf8_lossy(&out.stderr)
137        );
138        // `-x` is listed as a file, not taken as `ls -x` over the whole dir.
139        let stdout = String::from_utf8_lossy(&out.stdout);
140        assert!(stdout.contains("-x"), "{stdout}");
141        assert!(!stdout.contains("other.txt"), "{stdout}");
142    }
143}