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::{
5    collections::HashMap,
6    path::{Path, PathBuf},
7    process::Stdio,
8};
9
10use anyhow::{Context as _, Result};
11use async_trait::async_trait;
12use derive_builder::Builder;
13use swiftide_core::{Command, CommandError, CommandOutput, Loader, ToolExecutor};
14use swiftide_indexing::loaders::FileLoader;
15use tokio::{
16    io::{AsyncBufReadExt as _, AsyncWriteExt as _},
17    task::JoinSet,
18};
19
20#[derive(Debug, Clone, Builder)]
21pub struct LocalExecutor {
22    #[builder(default = ".".into(), setter(into))]
23    workdir: PathBuf,
24
25    /// Clears env variables before executing commands.
26    #[builder(default)]
27    pub(crate) env_clear: bool,
28    /// Remove these environment variables before executing commands.
29    #[builder(default, setter(into))]
30    pub(crate) env_remove: Vec<String>,
31    ///  Set these environment variables before executing commands.
32    #[builder(default, setter(into))]
33    pub(crate) envs: HashMap<String, String>,
34}
35
36impl Default for LocalExecutor {
37    fn default() -> Self {
38        LocalExecutor {
39            workdir: ".".into(),
40            env_clear: false,
41            env_remove: Vec::new(),
42            envs: HashMap::new(),
43        }
44    }
45}
46
47impl LocalExecutor {
48    pub fn new(workdir: impl Into<PathBuf>) -> Self {
49        LocalExecutor {
50            workdir: workdir.into(),
51            env_clear: false,
52            env_remove: Vec::new(),
53            envs: HashMap::new(),
54        }
55    }
56
57    pub fn builder() -> LocalExecutorBuilder {
58        LocalExecutorBuilder::default()
59    }
60
61    #[allow(clippy::too_many_lines)]
62    async fn exec_shell(&self, cmd: &str) -> Result<CommandOutput, CommandError> {
63        let lines: Vec<&str> = cmd.lines().collect();
64        let mut child = if let Some(first_line) = lines.first()
65            && first_line.starts_with("#!")
66        {
67            let interpreter = first_line.trim_start_matches("#!/usr/bin/env ").trim();
68            tracing::info!(interpreter, "detected shebang; running as script");
69
70            let mut command = tokio::process::Command::new(interpreter);
71
72            if self.env_clear {
73                tracing::info!("clearing environment variables");
74                command.env_clear();
75            }
76
77            for var in &self.env_remove {
78                tracing::info!(var, "clearing environment variable");
79                command.env_remove(var);
80            }
81
82            for (key, value) in &self.envs {
83                tracing::info!(key, "setting environment variable");
84                command.env(key, value);
85            }
86
87            let mut child = command
88                .current_dir(&self.workdir)
89                .stdin(Stdio::piped())
90                .stdout(Stdio::piped())
91                .stderr(Stdio::piped())
92                .spawn()?;
93
94            if let Some(mut stdin) = child.stdin.take() {
95                let body = lines[1..].join("\n");
96                stdin.write_all(body.as_bytes()).await?;
97            }
98
99            child
100        } else {
101            tracing::info!("no shebang detected; running as command");
102
103            let mut command = tokio::process::Command::new("sh");
104
105            // Treat as shell command
106            command.arg("-c").arg(cmd).current_dir(&self.workdir);
107
108            if self.env_clear {
109                tracing::info!("clearing environment variables");
110                command.env_clear();
111            }
112
113            for var in &self.env_remove {
114                tracing::info!(var, "clearing environment variable");
115                command.env_remove(var);
116            }
117
118            for (key, value) in &self.envs {
119                tracing::info!(key, "setting environment variable");
120                command.env(key, value);
121            }
122            command
123                .stdin(Stdio::null())
124                .stdout(Stdio::piped())
125                .stderr(Stdio::piped())
126                .spawn()?
127        };
128        // Run the command in a shell
129
130        let mut joinset = JoinSet::new();
131
132        if let Some(stdout) = child.stdout.take() {
133            joinset.spawn(async move {
134                let mut lines = tokio::io::BufReader::new(stdout).lines();
135                let mut out = Vec::new();
136                while let Ok(Some(line)) = lines.next_line().await {
137                    out.push(line);
138                }
139                out
140            });
141        } else {
142            tracing::warn!("Command has no stdout");
143        }
144
145        if let Some(stderr) = child.stderr.take() {
146            joinset.spawn(async move {
147                let mut lines = tokio::io::BufReader::new(stderr).lines();
148                let mut out = Vec::new();
149                while let Ok(Some(line)) = lines.next_line().await {
150                    out.push(line);
151                }
152                out
153            });
154        } else {
155            tracing::warn!("Command has no stderr");
156        }
157
158        let outputs = joinset.join_all().await;
159        let &[stdout, stderr] = outputs
160            .iter()
161            .map(Vec::as_slice)
162            .collect::<Vec<_>>()
163            .as_slice()
164        else {
165            // This should never happen
166            return Err(anyhow::anyhow!("Failed to get outputs from command").into());
167        };
168
169        // outputs stdout and stderr should be empty
170        let output = child
171            .wait_with_output()
172            .await
173            .map_err(anyhow::Error::from)?;
174
175        let cmd_output = stdout
176            .iter()
177            .chain(stderr.iter())
178            .cloned()
179            .collect::<Vec<_>>()
180            .join("\n")
181            .into();
182
183        if output.status.success() {
184            Ok(cmd_output)
185        } else {
186            Err(CommandError::NonZeroExit(cmd_output))
187        }
188    }
189
190    async fn exec_read_file(&self, path: &Path) -> Result<CommandOutput, CommandError> {
191        let output = fs_err::tokio::read(path).await?;
192
193        Ok(String::from_utf8(output)
194            .context("Failed to parse read file output")?
195            .into())
196    }
197
198    async fn exec_write_file(
199        &self,
200        path: &Path,
201        content: &str,
202    ) -> Result<CommandOutput, CommandError> {
203        if let Some(parent) = path.parent() {
204            let _ = fs_err::tokio::create_dir_all(parent).await;
205        }
206        fs_err::tokio::write(path, content).await?;
207
208        Ok(CommandOutput::empty())
209    }
210}
211#[async_trait]
212impl ToolExecutor for LocalExecutor {
213    /// Execute a `Command` on the local machine
214    #[tracing::instrument(skip_self)]
215    async fn exec_cmd(&self, cmd: &Command) -> Result<swiftide_core::CommandOutput, CommandError> {
216        match cmd {
217            Command::Shell(cmd) => __self.exec_shell(cmd).await,
218            Command::ReadFile(path) => __self.exec_read_file(path).await,
219            Command::WriteFile(path, content) => __self.exec_write_file(path, content).await,
220            _ => unimplemented!("Unsupported command: {cmd:?}"),
221        }
222    }
223
224    async fn stream_files(
225        &self,
226        path: &Path,
227        extensions: Option<Vec<String>>,
228    ) -> Result<swiftide_core::indexing::IndexingStream> {
229        let mut loader = FileLoader::new(path);
230
231        if let Some(extensions) = extensions {
232            loader = loader.with_extensions(&extensions);
233        }
234
235        Ok(loader.into_stream())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use futures_util::StreamExt as _;
243    use indoc::indoc;
244    use swiftide_core::{Command, ToolExecutor};
245    use temp_dir::TempDir;
246
247    #[tokio::test]
248    async fn test_local_executor_write_and_read_file() -> anyhow::Result<()> {
249        // Create a temporary directory
250        let temp_dir = TempDir::new()?;
251        let temp_path = temp_dir.path();
252
253        // Instantiate LocalExecutor with the temporary directory as workdir
254        let executor = LocalExecutor {
255            workdir: temp_path.to_path_buf(),
256            ..Default::default()
257        };
258
259        // Define the file path and content
260        let file_path = temp_path.join("test_file.txt");
261        let file_content = "Hello, world!";
262
263        // Write a shell command to create a file with the specified content
264        let write_cmd =
265            Command::Shell(format!("echo '{}' > {}", file_content, file_path.display()));
266
267        // Execute the write command
268        executor.exec_cmd(&write_cmd).await?;
269
270        // Verify that the file was created successfully
271        assert!(file_path.exists());
272
273        // Write a shell command to read the file's content
274        let read_cmd = Command::Shell(format!("cat {}", file_path.display()));
275
276        // Execute the read command
277        let output = executor.exec_cmd(&read_cmd).await?;
278
279        // Verify that the content read from the file matches the expected content
280        assert_eq!(output.to_string(), format!("{file_content}"));
281
282        Ok(())
283    }
284
285    #[tokio::test]
286    async fn test_local_executor_echo_hello_world() -> anyhow::Result<()> {
287        // Create a temporary directory
288        let temp_dir = TempDir::new()?;
289        let temp_path = temp_dir.path();
290
291        // Instantiate LocalExecutor with the temporary directory as workdir
292        let executor = LocalExecutor {
293            workdir: temp_path.to_path_buf(),
294            ..Default::default()
295        };
296
297        // Define the echo command
298        let echo_cmd = Command::Shell("echo 'hello world'".to_string());
299
300        // Execute the echo command
301        let output = executor.exec_cmd(&echo_cmd).await?;
302
303        // Verify that the output matches the expected content
304        assert_eq!(output.to_string().trim(), "hello world");
305
306        Ok(())
307    }
308
309    #[tokio::test]
310    async fn test_local_executor_clear_env() -> anyhow::Result<()> {
311        // Create a temporary directory
312        let temp_dir = TempDir::new()?;
313        let temp_path = temp_dir.path();
314
315        // Instantiate LocalExecutor with the temporary directory as workdir
316        let executor = LocalExecutor {
317            workdir: temp_path.to_path_buf(),
318            env_clear: true,
319            ..Default::default()
320        };
321
322        // Define the echo command
323        let echo_cmd = Command::Shell("printenv".to_string());
324
325        // Execute the echo command
326        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
327
328        // Verify that the output matches the expected content
329        // assert_eq!(output.to_string().trim(), "");
330        assert!(!output.contains("CARGO_PKG_VERSION"), "{output}");
331
332        Ok(())
333    }
334
335    #[tokio::test]
336    async fn test_local_executor_add_env() -> anyhow::Result<()> {
337        // Create a temporary directory
338        let temp_dir = TempDir::new()?;
339        let temp_path = temp_dir.path();
340
341        // Instantiate LocalExecutor with the temporary directory as workdir
342        let executor = LocalExecutor {
343            workdir: temp_path.to_path_buf(),
344            envs: HashMap::from([("TEST_ENV".to_string(), "HELLO".to_string())]),
345            ..Default::default()
346        };
347
348        // Define the echo command
349        let echo_cmd = Command::Shell("printenv".to_string());
350
351        // Execute the echo command
352        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
353
354        // Verify that the output matches the expected content
355        // assert_eq!(output.to_string().trim(), "");
356        assert!(output.contains("TEST_ENV=HELLO"), "{output}");
357        // Double tap its included by default
358        assert!(output.contains("CARGO_PKG_VERSION"), "{output}");
359
360        Ok(())
361    }
362
363    #[tokio::test]
364    async fn test_local_executor_env_remove() -> anyhow::Result<()> {
365        // Create a temporary directory
366        let temp_dir = TempDir::new()?;
367        let temp_path = temp_dir.path();
368
369        // Instantiate LocalExecutor with the temporary directory as workdir
370        let executor = LocalExecutor {
371            workdir: temp_path.to_path_buf(),
372            env_remove: vec!["CARGO_PKG_VERSION".to_string()],
373            ..Default::default()
374        };
375
376        // Define the echo command
377        let echo_cmd = Command::Shell("printenv".to_string());
378
379        // Execute the echo command
380        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
381
382        // Verify that the output matches the expected content
383        // assert_eq!(output.to_string().trim(), "");
384        assert!(!output.contains("CARGO_PKG_VERSION="), "{output}");
385
386        Ok(())
387    }
388
389    #[tokio::test]
390    async fn test_local_executor_run_shebang() -> anyhow::Result<()> {
391        // Create a temporary directory
392        let temp_dir = TempDir::new()?;
393        let temp_path = temp_dir.path();
394
395        // Instantiate LocalExecutor with the temporary directory as workdir
396        let executor = LocalExecutor {
397            workdir: temp_path.to_path_buf(),
398            ..Default::default()
399        };
400
401        let script = r#"#!/usr/bin/env python3
402print("hello from python")
403print(1 + 2)"#;
404
405        // Execute the echo command
406        let output = executor
407            .exec_cmd(&Command::shell(script))
408            .await?
409            .to_string();
410
411        // Verify that the output matches the expected content
412        assert!(output.contains("hello from python"));
413        assert!(output.contains('3'));
414
415        Ok(())
416    }
417
418    #[tokio::test]
419    async fn test_local_executor_multiline_with_quotes() -> anyhow::Result<()> {
420        // Create a temporary directory
421        let temp_dir = TempDir::new()?;
422        let temp_path = temp_dir.path();
423
424        // Instantiate LocalExecutor with the temporary directory as workdir
425        let executor = LocalExecutor {
426            workdir: temp_path.to_path_buf(),
427            ..Default::default()
428        };
429
430        // Define the file path and content
431        let file_path = "test_file2.txt";
432        let file_content = indoc! {r#"
433            fn main() {
434                println!("Hello, world!");
435            }
436        "#};
437
438        // Write a shell command to create a file with the specified content
439        let write_cmd = Command::Shell(format!("echo '{file_content}' > {file_path}"));
440
441        // Execute the write command
442        executor.exec_cmd(&write_cmd).await?;
443
444        // Write a shell command to read the file's content
445        let read_cmd = Command::Shell(format!("cat {file_path}"));
446
447        // Execute the read command
448        let output = executor.exec_cmd(&read_cmd).await?;
449
450        // Verify that the content read from the file matches the expected content
451        assert_eq!(output.to_string(), format!("{file_content}"));
452
453        Ok(())
454    }
455
456    #[tokio::test]
457    async fn test_local_executor_write_and_read_file_commands() -> anyhow::Result<()> {
458        // Create a temporary directory
459        let temp_dir = TempDir::new()?;
460        let temp_path = temp_dir.path();
461
462        // Instantiate LocalExecutor with the temporary directory as workdir
463        let executor = LocalExecutor {
464            workdir: temp_path.to_path_buf(),
465            ..Default::default()
466        };
467
468        // Define the file path and content
469        let file_path = temp_path.join("test_file.txt");
470        let file_content = "Hello, world!";
471
472        // Assert that the file does not exist and it gives the correct error
473        let cmd = Command::ReadFile(file_path.clone());
474        let result = executor.exec_cmd(&cmd).await;
475
476        if let Err(err) = result {
477            assert!(matches!(err, CommandError::NonZeroExit(..)));
478        } else {
479            panic!("Expected error but got {result:?}");
480        }
481
482        // Create a write command
483        let write_cmd = Command::WriteFile(file_path.clone(), file_content.to_string());
484
485        // Execute the write command
486        executor.exec_cmd(&write_cmd).await?;
487
488        // Verify that the file was created successfully
489        assert!(file_path.exists());
490
491        // Create a read command
492        let read_cmd = Command::ReadFile(file_path.clone());
493
494        // Execute the read command
495        let output = executor.exec_cmd(&read_cmd).await?.output;
496
497        // Verify that the content read from the file matches the expected content
498        assert_eq!(output, file_content);
499
500        Ok(())
501    }
502
503    #[tokio::test]
504    async fn test_local_executor_stream_files() -> anyhow::Result<()> {
505        // Create a temporary directory
506        let temp_dir = TempDir::new()?;
507        let temp_path = temp_dir.path();
508
509        // Create some test files in the temporary directory
510        fs_err::write(temp_path.join("file1.txt"), "Content of file 1")?;
511        fs_err::write(temp_path.join("file2.txt"), "Content of file 2")?;
512        fs_err::write(temp_path.join("file3.rs"), "Content of file 3")?;
513
514        // Instantiate LocalExecutor with the temporary directory as workdir
515        let executor = LocalExecutor {
516            workdir: temp_path.to_path_buf(),
517            ..Default::default()
518        };
519
520        // Stream files with no extensions filter
521        let stream = executor.stream_files(temp_path, None).await?;
522        let files: Vec<_> = stream.collect().await;
523
524        assert_eq!(files.len(), 3);
525
526        // Stream files with a specific extension filter
527        let stream = executor
528            .stream_files(temp_path, Some(vec!["txt".to_string()]))
529            .await?;
530        let txt_files: Vec<_> = stream.collect().await;
531
532        assert_eq!(txt_files.len(), 2);
533
534        Ok(())
535    }
536}