swiftide_agents/tools/
local_executor.rs

1//! Local executor for running tools on the local machine.
2//!
3//! By default will use the current directory as the working directory.
4use 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    /// Execute a `Command` on the local machine
81    #[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        // Create a temporary directory
117        let temp_dir = TempDir::new()?;
118        let temp_path = temp_dir.path();
119
120        // Instantiate LocalExecutor with the temporary directory as workdir
121        let executor = LocalExecutor {
122            workdir: temp_path.to_path_buf(),
123        };
124
125        // Define the file path and content
126        let file_path = temp_path.join("test_file.txt");
127        let file_content = "Hello, world!";
128
129        // Write a shell command to create a file with the specified content
130        let write_cmd =
131            Command::Shell(format!("echo '{}' > {}", file_content, file_path.display()));
132
133        // Execute the write command
134        executor.exec_cmd(&write_cmd).await?;
135
136        // Verify that the file was created successfully
137        assert!(file_path.exists());
138
139        // Write a shell command to read the file's content
140        let read_cmd = Command::Shell(format!("cat {}", file_path.display()));
141
142        // Execute the read command
143        let output = executor.exec_cmd(&read_cmd).await?;
144
145        // Verify that the content read from the file matches the expected content
146        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        // Create a temporary directory
154        let temp_dir = TempDir::new()?;
155        let temp_path = temp_dir.path();
156
157        // Instantiate LocalExecutor with the temporary directory as workdir
158        let executor = LocalExecutor {
159            workdir: temp_path.to_path_buf(),
160        };
161
162        // Define the echo command
163        let echo_cmd = Command::Shell("echo 'hello world'".to_string());
164
165        // Execute the echo command
166        let output = executor.exec_cmd(&echo_cmd).await?;
167
168        // Verify that the output matches the expected content
169        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        // Create a temporary directory
177        let temp_dir = TempDir::new()?;
178        let temp_path = temp_dir.path();
179
180        // Instantiate LocalExecutor with the temporary directory as workdir
181        let executor = LocalExecutor {
182            workdir: temp_path.to_path_buf(),
183        };
184
185        // Define the file path and content
186        let file_path = "test_file2.txt";
187        let file_content = indoc! {r#"
188            fn main() {
189                println!("Hello, world!");
190            }
191        "#};
192
193        // Write a shell command to create a file with the specified content
194        let write_cmd = Command::Shell(format!("echo '{file_content}' > {file_path}"));
195
196        // Execute the write command
197        executor.exec_cmd(&write_cmd).await?;
198
199        // Write a shell command to read the file's content
200        let read_cmd = Command::Shell(format!("cat {file_path}"));
201
202        // Execute the read command
203        let output = executor.exec_cmd(&read_cmd).await?;
204
205        // Verify that the content read from the file matches the expected content
206        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        // Create a temporary directory
214        let temp_dir = TempDir::new()?;
215        let temp_path = temp_dir.path();
216
217        // Instantiate LocalExecutor with the temporary directory as workdir
218        let executor = LocalExecutor {
219            workdir: temp_path.to_path_buf(),
220        };
221
222        // Define the file path and content
223        let file_path = temp_path.join("test_file.txt");
224        let file_content = "Hello, world!";
225
226        // Assert that the file does not exist and it gives the correct error
227        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        // Create a write command
237        let write_cmd = Command::WriteFile(file_path.clone(), file_content.to_string());
238
239        // Execute the write command
240        executor.exec_cmd(&write_cmd).await?;
241
242        // Verify that the file was created successfully
243        assert!(file_path.exists());
244
245        // Create a read command
246        let read_cmd = Command::ReadFile(file_path.clone());
247
248        // Execute the read command
249        let output = executor.exec_cmd(&read_cmd).await?.output;
250
251        // Verify that the content read from the file matches the expected content
252        assert_eq!(output, file_content);
253
254        Ok(())
255    }
256
257    #[tokio::test]
258    async fn test_local_executor_stream_files() -> anyhow::Result<()> {
259        // Create a temporary directory
260        let temp_dir = TempDir::new()?;
261        let temp_path = temp_dir.path();
262
263        // Create some test files in the temporary directory
264        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        // Instantiate LocalExecutor with the temporary directory as workdir
269        let executor = LocalExecutor {
270            workdir: temp_path.to_path_buf(),
271        };
272
273        // Stream files with no extensions filter
274        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        // Stream files with a specific extension filter
280        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}