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(¶ms, &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
59fn build_command(params: &Value, ctx: &ToolContext) -> 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 = Command::new("ls");
66 if let Some(env) = &ctx.capabilities.env {
67 cmd.env_clear().envs(env.iter().map(|(k, v)| (k, v)));
68 }
69 cmd.arg("-lah").arg(&path);
70 cmd
71}
72
73#[cfg(test)]
74mod tests {
75 use super::super::test_helpers::create_tool_context;
76 use super::*;
77 use crate::tools::Tool;
78 use serde_json::json;
79
80 #[test]
81 fn test_ls_tool_schema() {
82 let tool = LsTool;
83 assert_eq!(tool.name(), "ls");
84 assert!(!tool.description().is_empty());
85
86 let params = tool.parameters();
87 assert_eq!(params["type"], "object");
88 assert!(params["properties"].is_object());
89 assert!(params["required"].is_array());
90 }
91
92 #[tokio::test]
93 async fn test_ls_tool_execution() {
94 let tool = LsTool;
95 let ctx = create_tool_context();
96
97 let dir = std::env::temp_dir().join("synaps_test_ls");
99 std::fs::create_dir_all(&dir).unwrap();
100 std::fs::write(dir.join("hello.txt"), "hi").unwrap();
101
102 let params = json!({
103 "path": dir.to_str().unwrap()
104 });
105
106 let result = tool.execute(params, ctx).await.unwrap();
107 assert!(result.contains("hello.txt"));
108
109 std::fs::remove_dir_all(&dir).ok();
111 }
112
113 #[test]
115 fn leading_dash_path_is_never_an_option() {
116 let ctx = create_tool_context();
117 let cmd = build_command(&json!({"path": "-R"}), &ctx);
118 let args: Vec<String> = cmd
119 .as_std()
120 .get_args()
121 .map(|a| a.to_string_lossy().into_owned())
122 .collect();
123 assert_eq!(args, ["-lah", "./-R"]);
124 }
125
126 #[tokio::test]
127 async fn lists_a_file_whose_name_starts_with_dash() {
128 let dir = tempfile::tempdir().unwrap();
129 std::fs::write(dir.path().join("-x"), "").unwrap();
130 std::fs::write(dir.path().join("other.txt"), "").unwrap();
131 let ctx = create_tool_context();
132 let mut cmd = build_command(&json!({"path": "-x"}), &ctx);
133 cmd.current_dir(dir.path());
134 let out = cmd.output().await.unwrap();
135 assert!(
136 out.status.success(),
137 "{}",
138 String::from_utf8_lossy(&out.stderr)
139 );
140 let stdout = String::from_utf8_lossy(&out.stdout);
142 assert!(stdout.contains("-x"), "{stdout}");
143 assert!(!stdout.contains("other.txt"), "{stdout}");
144 }
145}