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    time::Duration,
9};
10
11use anyhow::{Context as _, Result};
12use async_trait::async_trait;
13use derive_builder::Builder;
14use swiftide_core::{Command, CommandError, CommandOutput, Loader, ToolExecutor};
15use swiftide_indexing::loaders::FileLoader;
16use tokio::{
17    io::{AsyncBufReadExt as _, AsyncWriteExt as _},
18    task::JoinHandle,
19    time,
20};
21
22#[derive(Debug, Clone, Builder)]
23pub struct LocalExecutor {
24    #[builder(default = ".".into(), setter(into))]
25    workdir: PathBuf,
26
27    #[builder(default)]
28    default_timeout: Option<Duration>,
29
30    /// Clears env variables before executing commands.
31    #[builder(default)]
32    pub(crate) env_clear: bool,
33    /// Remove these environment variables before executing commands.
34    #[builder(default, setter(into))]
35    pub(crate) env_remove: Vec<String>,
36    ///  Set these environment variables before executing commands.
37    #[builder(default, setter(into))]
38    pub(crate) envs: HashMap<String, String>,
39}
40
41impl Default for LocalExecutor {
42    fn default() -> Self {
43        LocalExecutor {
44            workdir: ".".into(),
45            default_timeout: None,
46            env_clear: false,
47            env_remove: Vec::new(),
48            envs: HashMap::new(),
49        }
50    }
51}
52
53impl LocalExecutor {
54    pub fn new(workdir: impl Into<PathBuf>) -> Self {
55        LocalExecutor {
56            workdir: workdir.into(),
57            default_timeout: None,
58            env_clear: false,
59            env_remove: Vec::new(),
60            envs: HashMap::new(),
61        }
62    }
63
64    pub fn builder() -> LocalExecutorBuilder {
65        LocalExecutorBuilder::default()
66    }
67
68    fn resolve_workdir(&self, cmd: &Command) -> PathBuf {
69        match cmd.current_dir_path() {
70            Some(path) if path.is_absolute() => path.to_path_buf(),
71            Some(path) => self.workdir.join(path),
72            None => self.workdir.clone(),
73        }
74    }
75
76    fn resolve_timeout(&self, cmd: &Command) -> Option<Duration> {
77        cmd.timeout_duration().copied().or(self.default_timeout)
78    }
79
80    #[allow(clippy::too_many_lines)]
81    async fn exec_shell(
82        &self,
83        cmd: &str,
84        workdir: &Path,
85        timeout: Option<Duration>,
86    ) -> Result<CommandOutput, CommandError> {
87        let lines: Vec<&str> = cmd.lines().collect();
88        let mut child = if let Some(first_line) = lines.first()
89            && first_line.starts_with("#!")
90        {
91            let interpreter = first_line.trim_start_matches("#!/usr/bin/env ").trim();
92            tracing::info!(interpreter, "detected shebang; running as script");
93
94            let mut command = tokio::process::Command::new(interpreter);
95
96            if self.env_clear {
97                tracing::info!("clearing environment variables");
98                command.env_clear();
99            }
100
101            for var in &self.env_remove {
102                tracing::info!(var, "clearing environment variable");
103                command.env_remove(var);
104            }
105
106            for (key, value) in &self.envs {
107                tracing::info!(key, "setting environment variable");
108                command.env(key, value);
109            }
110
111            let mut child = command
112                .current_dir(workdir)
113                .stdin(Stdio::piped())
114                .stdout(Stdio::piped())
115                .stderr(Stdio::piped())
116                .spawn()?;
117
118            if let Some(mut stdin) = child.stdin.take() {
119                let body = lines[1..].join("\n");
120                stdin.write_all(body.as_bytes()).await?;
121            }
122
123            child
124        } else {
125            tracing::info!("no shebang detected; running as command");
126
127            let mut command = tokio::process::Command::new("sh");
128
129            // Treat as shell command
130            command.arg("-c").arg(cmd).current_dir(workdir);
131
132            if self.env_clear {
133                tracing::info!("clearing environment variables");
134                command.env_clear();
135            }
136
137            for var in &self.env_remove {
138                tracing::info!(var, "clearing environment variable");
139                command.env_remove(var);
140            }
141
142            for (key, value) in &self.envs {
143                tracing::info!(key, "setting environment variable");
144                command.env(key, value);
145            }
146            command
147                .current_dir(workdir)
148                .stdin(Stdio::null())
149                .stdout(Stdio::piped())
150                .stderr(Stdio::piped())
151                .spawn()?
152        };
153
154        let stdout_task = if let Some(stdout) = child.stdout.take() {
155            Some(tokio::spawn(async move {
156                let mut lines = tokio::io::BufReader::new(stdout).lines();
157                let mut out = Vec::new();
158                while let Ok(Some(line)) = lines.next_line().await {
159                    out.push(line);
160                }
161                out
162            }))
163        } else {
164            tracing::warn!("Command has no stdout");
165            None
166        };
167
168        let stderr_task = if let Some(stderr) = child.stderr.take() {
169            Some(tokio::spawn(async move {
170                let mut lines = tokio::io::BufReader::new(stderr).lines();
171                let mut out = Vec::new();
172                while let Ok(Some(line)) = lines.next_line().await {
173                    out.push(line);
174                }
175                out
176            }))
177        } else {
178            tracing::warn!("Command has no stderr");
179            None
180        };
181
182        let status = match timeout {
183            Some(limit) => {
184                if let Ok(result) = time::timeout(limit, child.wait()).await {
185                    result.map_err(|err| CommandError::ExecutorError(err.into()))?
186                } else {
187                    tracing::warn!(?limit, "command exceeded timeout; terminating");
188                    if let Err(err) = child.start_kill() {
189                        tracing::warn!(?err, "failed to start kill on timed out command");
190                    }
191                    if let Err(err) = child.wait().await {
192                        tracing::warn!(?err, "failed to reap command after timeout");
193                    }
194
195                    let (stdout, stderr) =
196                        Self::collect_process_output(stdout_task, stderr_task).await;
197                    let cmd_output = Self::merge_output(&stdout, &stderr);
198
199                    return Err(CommandError::TimedOut {
200                        timeout: limit,
201                        output: cmd_output,
202                    });
203                }
204            }
205            None => child
206                .wait()
207                .await
208                .map_err(|err| CommandError::ExecutorError(err.into()))?,
209        };
210
211        let (stdout, stderr) = Self::collect_process_output(stdout_task, stderr_task).await;
212        let cmd_output = Self::merge_output(&stdout, &stderr);
213
214        if status.success() {
215            Ok(cmd_output)
216        } else {
217            Err(CommandError::NonZeroExit(cmd_output))
218        }
219    }
220
221    async fn exec_read_file(
222        &self,
223        workdir: &Path,
224        path: &Path,
225        timeout: Option<Duration>,
226    ) -> Result<CommandOutput, CommandError> {
227        let path = if path.is_absolute() {
228            path.to_path_buf()
229        } else {
230            workdir.join(path)
231        };
232        let read_future = fs_err::tokio::read(&path);
233        let output = match timeout {
234            Some(limit) => match time::timeout(limit, read_future).await {
235                Ok(result) => result?,
236                Err(_) => {
237                    return Err(CommandError::TimedOut {
238                        timeout: limit,
239                        output: CommandOutput::empty(),
240                    });
241                }
242            },
243            None => read_future.await?,
244        };
245
246        Ok(String::from_utf8(output)
247            .context("Failed to parse read file output")?
248            .into())
249    }
250
251    async fn exec_write_file(
252        &self,
253        workdir: &Path,
254        path: &Path,
255        content: &str,
256        timeout: Option<Duration>,
257    ) -> Result<CommandOutput, CommandError> {
258        let path = if path.is_absolute() {
259            path.to_path_buf()
260        } else {
261            workdir.join(path)
262        };
263        if let Some(parent) = path.parent() {
264            let _ = fs_err::tokio::create_dir_all(parent).await;
265        }
266        let write_future = fs_err::tokio::write(&path, content);
267        match timeout {
268            Some(limit) => match time::timeout(limit, write_future).await {
269                Ok(result) => result?,
270                Err(_) => {
271                    return Err(CommandError::TimedOut {
272                        timeout: limit,
273                        output: CommandOutput::empty(),
274                    });
275                }
276            },
277            None => write_future.await?,
278        }
279
280        Ok(CommandOutput::empty())
281    }
282
283    async fn collect_process_output(
284        stdout_task: Option<JoinHandle<Vec<String>>>,
285        stderr_task: Option<JoinHandle<Vec<String>>>,
286    ) -> (Vec<String>, Vec<String>) {
287        let stdout = match stdout_task {
288            Some(task) => match task.await {
289                Ok(lines) => lines,
290                Err(err) => {
291                    tracing::warn!(?err, "failed to collect stdout from command");
292                    Vec::new()
293                }
294            },
295            None => Vec::new(),
296        };
297
298        let stderr = match stderr_task {
299            Some(task) => match task.await {
300                Ok(lines) => lines,
301                Err(err) => {
302                    tracing::warn!(?err, "failed to collect stderr from command");
303                    Vec::new()
304                }
305            },
306            None => Vec::new(),
307        };
308
309        (stdout, stderr)
310    }
311
312    fn merge_output(stdout: &[String], stderr: &[String]) -> CommandOutput {
313        stdout
314            .iter()
315            .chain(stderr.iter())
316            .cloned()
317            .collect::<Vec<_>>()
318            .join("\n")
319            .into()
320    }
321}
322#[async_trait]
323impl ToolExecutor for LocalExecutor {
324    /// Execute a `Command` on the local machine
325    #[tracing::instrument(skip_self)]
326    async fn exec_cmd(&self, cmd: &Command) -> Result<swiftide_core::CommandOutput, CommandError> {
327        let workdir = __self.resolve_workdir(cmd);
328        let timeout = __self.resolve_timeout(cmd);
329        match cmd {
330            Command::Shell { command, .. } => __self.exec_shell(command, &workdir, timeout).await,
331            Command::ReadFile { path, .. } => __self.exec_read_file(&workdir, path, timeout).await,
332            Command::WriteFile { path, content, .. } => {
333                __self
334                    .exec_write_file(&workdir, path, content, timeout)
335                    .await
336            }
337            _ => unimplemented!("Unsupported command: {cmd:?}"),
338        }
339    }
340
341    async fn stream_files(
342        &self,
343        path: &Path,
344        extensions: Option<Vec<String>>,
345    ) -> Result<swiftide_core::indexing::IndexingStream<String>> {
346        let mut loader = FileLoader::new(path);
347
348        if let Some(extensions) = extensions {
349            loader = loader.with_extensions(&extensions);
350        }
351
352        Ok(loader.into_stream())
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use futures_util::StreamExt as _;
360    use indoc::indoc;
361    use std::{path::Path, sync::Arc, time::Duration};
362    use swiftide_core::{Command, ExecutorExt, ToolExecutor};
363    use temp_dir::TempDir;
364
365    #[tokio::test]
366    async fn test_local_executor_write_and_read_file() -> anyhow::Result<()> {
367        // Create a temporary directory
368        let temp_dir = TempDir::new()?;
369        let temp_path = temp_dir.path();
370
371        // Instantiate LocalExecutor with the temporary directory as workdir
372        let executor = LocalExecutor {
373            workdir: temp_path.to_path_buf(),
374            ..Default::default()
375        };
376
377        // Define the file path and content
378        let file_path = temp_path.join("test_file.txt");
379        let file_content = "Hello, world!";
380
381        // Write a shell command to create a file with the specified content
382        let write_cmd =
383            Command::shell(format!("echo '{}' > {}", file_content, file_path.display()));
384
385        // Execute the write command
386        executor.exec_cmd(&write_cmd).await?;
387
388        // Verify that the file was created successfully
389        assert!(file_path.exists());
390
391        // Write a shell command to read the file's content
392        let read_cmd = Command::shell(format!("cat {}", file_path.display()));
393
394        // Execute the read command
395        let output = executor.exec_cmd(&read_cmd).await?;
396
397        // Verify that the content read from the file matches the expected content
398        assert_eq!(output.to_string(), format!("{file_content}"));
399
400        let output = executor
401            .exec_cmd(&Command::read_file(&file_path))
402            .await
403            .unwrap();
404        assert_eq!(output.to_string(), format!("{file_content}\n"));
405
406        Ok(())
407    }
408
409    #[tokio::test]
410    async fn test_local_executor_echo_hello_world() -> anyhow::Result<()> {
411        // Create a temporary directory
412        let temp_dir = TempDir::new()?;
413        let temp_path = temp_dir.path();
414
415        // Instantiate LocalExecutor with the temporary directory as workdir
416        let executor = LocalExecutor {
417            workdir: temp_path.to_path_buf(),
418            ..Default::default()
419        };
420
421        // Define the echo command
422        let echo_cmd = Command::shell("echo 'hello world'");
423
424        // Execute the echo command
425        let output = executor.exec_cmd(&echo_cmd).await?;
426
427        // Verify that the output matches the expected content
428        assert_eq!(output.to_string().trim(), "hello world");
429
430        Ok(())
431    }
432
433    #[tokio::test]
434    async fn test_local_executor_shell_timeout() -> anyhow::Result<()> {
435        let temp_dir = TempDir::new()?;
436        let temp_path = temp_dir.path();
437
438        let executor = LocalExecutor {
439            workdir: temp_path.to_path_buf(),
440            ..Default::default()
441        };
442
443        let mut cmd = Command::shell("echo ready && sleep 1 && echo done");
444        cmd.timeout(Duration::from_millis(100));
445
446        match executor.exec_cmd(&cmd).await {
447            Err(CommandError::TimedOut { timeout, output }) => {
448                assert_eq!(timeout, Duration::from_millis(100));
449                assert!(output.to_string().contains("ready"));
450            }
451            other => anyhow::bail!("expected timeout error, got {other:?}"),
452        }
453
454        Ok(())
455    }
456
457    #[tokio::test]
458    async fn test_local_executor_default_timeout_applies() -> anyhow::Result<()> {
459        let temp_dir = TempDir::new()?;
460        let temp_path = temp_dir.path();
461
462        let executor = LocalExecutorBuilder::default()
463            .workdir(temp_path.to_path_buf())
464            .default_timeout(Some(Duration::from_millis(100)))
465            .build()?;
466
467        match executor.exec_cmd(&Command::shell("sleep 1")).await {
468            Err(CommandError::TimedOut { timeout, output }) => {
469                assert_eq!(timeout, Duration::from_millis(100));
470                assert!(output.to_string().is_empty());
471            }
472            other => anyhow::bail!("expected default timeout, got {other:?}"),
473        }
474
475        Ok(())
476    }
477
478    #[tokio::test]
479    async fn test_local_executor_clear_env() -> anyhow::Result<()> {
480        // Create a temporary directory
481        let temp_dir = TempDir::new()?;
482        let temp_path = temp_dir.path();
483
484        // Instantiate LocalExecutor with the temporary directory as workdir
485        let executor = LocalExecutor {
486            workdir: temp_path.to_path_buf(),
487            env_clear: true,
488            ..Default::default()
489        };
490
491        // Define the echo command
492        let echo_cmd = Command::shell("printenv");
493
494        // Execute the echo command
495        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
496
497        // Verify that the output matches the expected content
498        // assert_eq!(output.to_string().trim(), "");
499        assert!(!output.contains("CARGO_PKG_VERSION"), "{output}");
500
501        Ok(())
502    }
503
504    #[tokio::test]
505    async fn test_local_executor_add_env() -> anyhow::Result<()> {
506        // Create a temporary directory
507        let temp_dir = TempDir::new()?;
508        let temp_path = temp_dir.path();
509
510        // Instantiate LocalExecutor with the temporary directory as workdir
511        let executor = LocalExecutor {
512            workdir: temp_path.to_path_buf(),
513            envs: HashMap::from([("TEST_ENV".to_string(), "HELLO".to_string())]),
514            ..Default::default()
515        };
516
517        // Define the echo command
518        let echo_cmd = Command::shell("printenv");
519
520        // Execute the echo command
521        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
522
523        // Verify that the output matches the expected content
524        // assert_eq!(output.to_string().trim(), "");
525        assert!(output.contains("TEST_ENV=HELLO"), "{output}");
526        // Double tap its included by default
527        assert!(output.contains("CARGO_PKG_VERSION"), "{output}");
528
529        Ok(())
530    }
531
532    #[tokio::test]
533    async fn test_local_executor_env_remove() -> anyhow::Result<()> {
534        // Create a temporary directory
535        let temp_dir = TempDir::new()?;
536        let temp_path = temp_dir.path();
537
538        // Instantiate LocalExecutor with the temporary directory as workdir
539        let executor = LocalExecutor {
540            workdir: temp_path.to_path_buf(),
541            env_remove: vec!["CARGO_PKG_VERSION".to_string()],
542            ..Default::default()
543        };
544
545        // Define the echo command
546        let echo_cmd = Command::shell("printenv");
547
548        // Execute the echo command
549        let output = executor.exec_cmd(&echo_cmd).await?.to_string();
550
551        // Verify that the output matches the expected content
552        // assert_eq!(output.to_string().trim(), "");
553        assert!(!output.contains("CARGO_PKG_VERSION="), "{output}");
554
555        Ok(())
556    }
557
558    #[tokio::test]
559    async fn test_local_executor_run_shebang() -> anyhow::Result<()> {
560        // Create a temporary directory
561        let temp_dir = TempDir::new()?;
562        let temp_path = temp_dir.path();
563
564        // Instantiate LocalExecutor with the temporary directory as workdir
565        let executor = LocalExecutor {
566            workdir: temp_path.to_path_buf(),
567            ..Default::default()
568        };
569
570        let script = r#"#!/usr/bin/env python3
571print("hello from python")
572print(1 + 2)"#;
573
574        // Execute the echo command
575        let output = executor
576            .exec_cmd(&Command::shell(script))
577            .await?
578            .to_string();
579
580        // Verify that the output matches the expected content
581        assert!(output.contains("hello from python"));
582        assert!(output.contains('3'));
583
584        Ok(())
585    }
586
587    #[tokio::test]
588    async fn test_local_executor_multiline_with_quotes() -> anyhow::Result<()> {
589        // Create a temporary directory
590        let temp_dir = TempDir::new()?;
591        let temp_path = temp_dir.path();
592
593        // Instantiate LocalExecutor with the temporary directory as workdir
594        let executor = LocalExecutor {
595            workdir: temp_path.to_path_buf(),
596            ..Default::default()
597        };
598
599        // Define the file path and content
600        let file_path = "test_file2.txt";
601        let file_content = indoc! {r#"
602            fn main() {
603                println!("Hello, world!");
604            }
605        "#};
606
607        // Write a shell command to create a file with the specified content
608        let write_cmd = Command::shell(format!("echo '{file_content}' > {file_path}"));
609
610        // Execute the write command
611        executor.exec_cmd(&write_cmd).await?;
612
613        // Write a shell command to read the file's content
614        let read_cmd = Command::shell(format!("cat {file_path}"));
615
616        // Execute the read command
617        let output = executor.exec_cmd(&read_cmd).await?;
618
619        // Verify that the content read from the file matches the expected content
620        assert_eq!(output.to_string(), format!("{file_content}"));
621
622        Ok(())
623    }
624
625    #[tokio::test]
626    async fn test_local_executor_write_and_read_file_commands() -> anyhow::Result<()> {
627        // Create a temporary directory
628        let temp_dir = TempDir::new()?;
629        let temp_path = temp_dir.path();
630
631        // Instantiate LocalExecutor with the temporary directory as workdir
632        let executor = LocalExecutor {
633            workdir: temp_path.to_path_buf(),
634            ..Default::default()
635        };
636
637        // Define the file path and content
638        let file_path = temp_path.join("test_file.txt");
639        let file_content = "Hello, world!";
640
641        // Assert that the file does not exist and it gives the correct error
642        let cmd = Command::read_file(file_path.clone());
643        let result = executor.exec_cmd(&cmd).await;
644
645        if let Err(err) = result {
646            assert!(matches!(err, CommandError::NonZeroExit(..)));
647        } else {
648            panic!("Expected error but got {result:?}");
649        }
650
651        // Create a write command
652        let write_cmd = Command::write_file(file_path.clone(), file_content.to_string());
653
654        // Execute the write command
655        executor.exec_cmd(&write_cmd).await?;
656
657        // Verify that the file was created successfully
658        assert!(file_path.exists());
659
660        // Create a read command
661        let read_cmd = Command::read_file(file_path.clone());
662
663        // Execute the read command
664        let output = executor.exec_cmd(&read_cmd).await?.output;
665
666        // Verify that the content read from the file matches the expected content
667        assert_eq!(output, file_content);
668
669        Ok(())
670    }
671
672    #[tokio::test]
673    async fn test_local_executor_stream_files() -> anyhow::Result<()> {
674        // Create a temporary directory
675        let temp_dir = TempDir::new()?;
676        let temp_path = temp_dir.path();
677
678        // Create some test files in the temporary directory
679        fs_err::write(temp_path.join("file1.txt"), "Content of file 1")?;
680        fs_err::write(temp_path.join("file2.txt"), "Content of file 2")?;
681        fs_err::write(temp_path.join("file3.rs"), "Content of file 3")?;
682
683        // Instantiate LocalExecutor with the temporary directory as workdir
684        let executor = LocalExecutor {
685            workdir: temp_path.to_path_buf(),
686            ..Default::default()
687        };
688
689        // Stream files with no extensions filter
690        let stream = executor.stream_files(temp_path, None).await?;
691        let files: Vec<_> = stream.collect().await;
692
693        assert_eq!(files.len(), 3);
694
695        // Stream files with a specific extension filter
696        let stream = executor
697            .stream_files(temp_path, Some(vec!["txt".to_string()]))
698            .await?;
699        let txt_files: Vec<_> = stream.collect().await;
700
701        assert_eq!(txt_files.len(), 2);
702
703        Ok(())
704    }
705
706    #[tokio::test]
707    async fn test_local_executor_honors_workdir() -> anyhow::Result<()> {
708        use std::fs;
709        use temp_dir::TempDir;
710
711        // 1. Create a temp dir and instantiate executor
712        let temp_dir = TempDir::new()?;
713        let temp_path = temp_dir.path();
714
715        let executor = LocalExecutor {
716            workdir: temp_path.to_path_buf(),
717            ..Default::default()
718        };
719
720        // 2. Run a shell command in workdir and check output is workdir
721        let pwd_cmd = Command::shell("pwd");
722        let pwd_output = executor.exec_cmd(&pwd_cmd).await?.to_string();
723        let pwd_path = std::fs::canonicalize(pwd_output.trim())?;
724        let temp_path = std::fs::canonicalize(temp_path)?;
725        assert_eq!(pwd_path, temp_path);
726
727        // 3. Write a file using WriteFile (should land in workdir)
728        let fname = "workdir_check.txt";
729        let write_cmd = Command::write_file(fname, "test123");
730        executor.exec_cmd(&write_cmd).await?;
731
732        // 4. Assert file exists in workdir, not current dir
733        let expected_path = temp_path.join(fname);
734        assert!(expected_path.exists());
735        assert!(!Path::new(fname).exists());
736
737        // 5. Write/read using ReadFile
738        let read_cmd = Command::read_file(fname);
739        let read_output = executor.exec_cmd(&read_cmd).await?.to_string();
740        assert_eq!(read_output.trim(), "test123");
741
742        // 6. Clean up
743        fs::remove_file(&expected_path)?;
744
745        Ok(())
746    }
747
748    #[tokio::test]
749    async fn test_local_executor_command_current_dir() -> anyhow::Result<()> {
750        use std::fs;
751        use temp_dir::TempDir;
752
753        let temp_dir = TempDir::new()?;
754        let base_path = temp_dir.path();
755
756        let executor = LocalExecutor {
757            workdir: base_path.to_path_buf(),
758            ..Default::default()
759        };
760
761        let nested_dir = base_path.join("nested");
762        fs::create_dir_all(&nested_dir)?;
763
764        let mut pwd_cmd = Command::shell("pwd");
765        pwd_cmd.current_dir(Path::new("nested"));
766        let pwd_output = executor.exec_cmd(&pwd_cmd).await?.to_string();
767        let pwd_path = std::fs::canonicalize(pwd_output.trim())?;
768        assert_eq!(pwd_path, std::fs::canonicalize(&nested_dir)?);
769
770        let mut write_cmd = Command::write_file("file.txt", "hello");
771        write_cmd.current_dir(Path::new("nested"));
772        executor.exec_cmd(&write_cmd).await?;
773
774        assert!(!base_path.join("file.txt").exists());
775        assert!(nested_dir.join("file.txt").exists());
776
777        let mut read_cmd = Command::read_file("file.txt");
778        read_cmd.current_dir(Path::new("nested"));
779        let read_output = executor.exec_cmd(&read_cmd).await?.to_string();
780        assert_eq!(read_output.trim(), "hello");
781
782        Ok(())
783    }
784
785    #[tokio::test]
786    async fn test_local_executor_current_dir() -> anyhow::Result<()> {
787        let temp_dir = TempDir::new()?;
788        let base_path = temp_dir.path();
789
790        let executor = LocalExecutor {
791            workdir: base_path.to_path_buf(),
792            ..Default::default()
793        };
794
795        let nested = executor.scoped("nested");
796        nested
797            .exec_cmd(&Command::write_file("file.txt", "hello"))
798            .await?;
799
800        assert!(!base_path.join("file.txt").exists());
801        assert!(base_path.join("nested").join("file.txt").exists());
802        assert_eq!(executor.workdir, base_path);
803
804        Ok(())
805    }
806
807    #[tokio::test]
808    async fn test_local_executor_current_dir_dyn() -> anyhow::Result<()> {
809        let temp_dir = TempDir::new()?;
810        let base_path = temp_dir.path();
811
812        let executor = LocalExecutor {
813            workdir: base_path.to_path_buf(),
814            ..Default::default()
815        };
816
817        let dyn_exec: Arc<dyn swiftide_core::ToolExecutor> = Arc::new(executor.clone());
818        let nested = dyn_exec.scoped("nested");
819
820        nested
821            .exec_cmd(&Command::write_file("nested_file.txt", "hello"))
822            .await?;
823
824        assert!(base_path.join("nested").join("nested_file.txt").exists());
825        assert!(!base_path.join("nested_file.txt").exists());
826
827        Ok(())
828    }
829}