swiftide_agents/tools/
local_executor.rs1use std::path::{Path, PathBuf};
5
6use anyhow::{Context as _, Result};
7use async_trait::async_trait;
8use derive_builder::Builder;
9use swiftide_core::{Command, CommandError, CommandOutput, Loader, ToolExecutor};
10use swiftide_indexing::loaders::FileLoader;
11
12#[derive(Debug, Clone, Builder)]
13pub struct LocalExecutor {
14 #[builder(default = ".".into(), setter(into))]
15 workdir: PathBuf,
16}
17
18impl Default for LocalExecutor {
19 fn default() -> Self {
20 LocalExecutor {
21 workdir: ".".into(),
22 }
23 }
24}
25
26impl LocalExecutor {
27 pub fn new(workdir: impl Into<PathBuf>) -> Self {
28 LocalExecutor {
29 workdir: workdir.into(),
30 }
31 }
32
33 pub fn builder() -> LocalExecutorBuilder {
34 LocalExecutorBuilder::default()
35 }
36
37 async fn exec_shell(&self, cmd: &str) -> Result<CommandOutput, CommandError> {
38 let output = tokio::process::Command::new("sh")
39 .arg("-c")
40 .arg(cmd)
41 .current_dir(&self.workdir)
42 .output()
43 .await
44 .context("Executor could not run command")?;
45
46 let stdout = String::from_utf8(output.stdout).context("Failed to parse stdout")?;
47 let stderr = String::from_utf8(output.stderr).context("Failed to parse stderr")?;
48 let merged_output = format!("{stdout}{stderr}");
49
50 if output.status.success() {
51 Ok(merged_output.into())
52 } else {
53 Err(CommandError::NonZeroExit(merged_output.into()))
54 }
55 }
56
57 async fn exec_read_file(&self, path: &Path) -> Result<CommandOutput, CommandError> {
58 let output = fs_err::tokio::read(path).await?;
59
60 Ok(String::from_utf8(output)
61 .context("Failed to parse read file output")?
62 .into())
63 }
64
65 async fn exec_write_file(
66 &self,
67 path: &Path,
68 content: &str,
69 ) -> Result<CommandOutput, CommandError> {
70 if let Some(parent) = path.parent() {
71 let _ = fs_err::tokio::create_dir_all(parent).await;
72 }
73 fs_err::tokio::write(path, content).await?;
74
75 Ok(CommandOutput::empty())
76 }
77}
78#[async_trait]
79impl ToolExecutor for LocalExecutor {
80 #[tracing::instrument(skip_self)]
82 async fn exec_cmd(&self, cmd: &Command) -> Result<swiftide_core::CommandOutput, CommandError> {
83 match cmd {
84 Command::Shell(cmd) => __self.exec_shell(cmd).await,
85 Command::ReadFile(path) => __self.exec_read_file(path).await,
86 Command::WriteFile(path, content) => __self.exec_write_file(path, content).await,
87 _ => unimplemented!("Unsupported command: {cmd:?}"),
88 }
89 }
90
91 async fn stream_files(
92 &self,
93 path: &Path,
94 extensions: Option<Vec<String>>,
95 ) -> Result<swiftide_core::indexing::IndexingStream> {
96 let mut loader = FileLoader::new(path);
97
98 if let Some(extensions) = extensions {
99 loader = loader.with_extensions(&extensions);
100 }
101
102 Ok(loader.into_stream())
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use futures_util::StreamExt as _;
110 use indoc::indoc;
111 use swiftide_core::{Command, ToolExecutor};
112 use temp_dir::TempDir;
113
114 #[tokio::test]
115 async fn test_local_executor_write_and_read_file() -> anyhow::Result<()> {
116 let temp_dir = TempDir::new()?;
118 let temp_path = temp_dir.path();
119
120 let executor = LocalExecutor {
122 workdir: temp_path.to_path_buf(),
123 };
124
125 let file_path = temp_path.join("test_file.txt");
127 let file_content = "Hello, world!";
128
129 let write_cmd =
131 Command::Shell(format!("echo '{}' > {}", file_content, file_path.display()));
132
133 executor.exec_cmd(&write_cmd).await?;
135
136 assert!(file_path.exists());
138
139 let read_cmd = Command::Shell(format!("cat {}", file_path.display()));
141
142 let output = executor.exec_cmd(&read_cmd).await?;
144
145 assert_eq!(output.to_string(), format!("{file_content}\n"));
147
148 Ok(())
149 }
150
151 #[tokio::test]
152 async fn test_local_executor_echo_hello_world() -> anyhow::Result<()> {
153 let temp_dir = TempDir::new()?;
155 let temp_path = temp_dir.path();
156
157 let executor = LocalExecutor {
159 workdir: temp_path.to_path_buf(),
160 };
161
162 let echo_cmd = Command::Shell("echo 'hello world'".to_string());
164
165 let output = executor.exec_cmd(&echo_cmd).await?;
167
168 assert_eq!(output.to_string().trim(), "hello world");
170
171 Ok(())
172 }
173
174 #[tokio::test]
175 async fn test_local_executor_multiline_with_quotes() -> anyhow::Result<()> {
176 let temp_dir = TempDir::new()?;
178 let temp_path = temp_dir.path();
179
180 let executor = LocalExecutor {
182 workdir: temp_path.to_path_buf(),
183 };
184
185 let file_path = "test_file2.txt";
187 let file_content = indoc! {r#"
188 fn main() {
189 println!("Hello, world!");
190 }
191 "#};
192
193 let write_cmd = Command::Shell(format!("echo '{file_content}' > {file_path}"));
195
196 executor.exec_cmd(&write_cmd).await?;
198
199 let read_cmd = Command::Shell(format!("cat {file_path}"));
201
202 let output = executor.exec_cmd(&read_cmd).await?;
204
205 assert_eq!(output.to_string(), format!("{file_content}\n"));
207
208 Ok(())
209 }
210
211 #[tokio::test]
212 async fn test_local_executor_write_and_read_file_commands() -> anyhow::Result<()> {
213 let temp_dir = TempDir::new()?;
215 let temp_path = temp_dir.path();
216
217 let executor = LocalExecutor {
219 workdir: temp_path.to_path_buf(),
220 };
221
222 let file_path = temp_path.join("test_file.txt");
224 let file_content = "Hello, world!";
225
226 let cmd = Command::ReadFile(file_path.clone());
228 let result = executor.exec_cmd(&cmd).await;
229
230 if let Err(err) = result {
231 assert!(matches!(err, CommandError::NonZeroExit(..)));
232 } else {
233 panic!("Expected error but got {result:?}");
234 }
235
236 let write_cmd = Command::WriteFile(file_path.clone(), file_content.to_string());
238
239 executor.exec_cmd(&write_cmd).await?;
241
242 assert!(file_path.exists());
244
245 let read_cmd = Command::ReadFile(file_path.clone());
247
248 let output = executor.exec_cmd(&read_cmd).await?.output;
250
251 assert_eq!(output, file_content);
253
254 Ok(())
255 }
256
257 #[tokio::test]
258 async fn test_local_executor_stream_files() -> anyhow::Result<()> {
259 let temp_dir = TempDir::new()?;
261 let temp_path = temp_dir.path();
262
263 fs_err::write(temp_path.join("file1.txt"), "Content of file 1")?;
265 fs_err::write(temp_path.join("file2.txt"), "Content of file 2")?;
266 fs_err::write(temp_path.join("file3.rs"), "Content of file 3")?;
267
268 let executor = LocalExecutor {
270 workdir: temp_path.to_path_buf(),
271 };
272
273 let stream = executor.stream_files(temp_path, None).await?;
275 let files: Vec<_> = stream.collect().await;
276
277 assert_eq!(files.len(), 3);
278
279 let stream = executor
281 .stream_files(temp_path, Some(vec!["txt".to_string()]))
282 .await?;
283 let txt_files: Vec<_> = stream.collect().await;
284
285 assert_eq!(txt_files.len(), 2);
286
287 Ok(())
288 }
289}