1use 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 #[builder(default)]
32 pub(crate) env_clear: bool,
33 #[builder(default, setter(into))]
35 pub(crate) env_remove: Vec<String>,
36 #[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 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 #[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 let temp_dir = TempDir::new()?;
369 let temp_path = temp_dir.path();
370
371 let executor = LocalExecutor {
373 workdir: temp_path.to_path_buf(),
374 ..Default::default()
375 };
376
377 let file_path = temp_path.join("test_file.txt");
379 let file_content = "Hello, world!";
380
381 let write_cmd =
383 Command::shell(format!("echo '{}' > {}", file_content, file_path.display()));
384
385 executor.exec_cmd(&write_cmd).await?;
387
388 assert!(file_path.exists());
390
391 let read_cmd = Command::shell(format!("cat {}", file_path.display()));
393
394 let output = executor.exec_cmd(&read_cmd).await?;
396
397 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 let temp_dir = TempDir::new()?;
413 let temp_path = temp_dir.path();
414
415 let executor = LocalExecutor {
417 workdir: temp_path.to_path_buf(),
418 ..Default::default()
419 };
420
421 let echo_cmd = Command::shell("echo 'hello world'");
423
424 let output = executor.exec_cmd(&echo_cmd).await?;
426
427 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 let temp_dir = TempDir::new()?;
482 let temp_path = temp_dir.path();
483
484 let executor = LocalExecutor {
486 workdir: temp_path.to_path_buf(),
487 env_clear: true,
488 ..Default::default()
489 };
490
491 let echo_cmd = Command::shell("printenv");
493
494 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
496
497 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 let temp_dir = TempDir::new()?;
508 let temp_path = temp_dir.path();
509
510 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 let echo_cmd = Command::shell("printenv");
519
520 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
522
523 assert!(output.contains("TEST_ENV=HELLO"), "{output}");
526 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 let temp_dir = TempDir::new()?;
536 let temp_path = temp_dir.path();
537
538 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 let echo_cmd = Command::shell("printenv");
547
548 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
550
551 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 let temp_dir = TempDir::new()?;
562 let temp_path = temp_dir.path();
563
564 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 let output = executor
576 .exec_cmd(&Command::shell(script))
577 .await?
578 .to_string();
579
580 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 let temp_dir = TempDir::new()?;
591 let temp_path = temp_dir.path();
592
593 let executor = LocalExecutor {
595 workdir: temp_path.to_path_buf(),
596 ..Default::default()
597 };
598
599 let file_path = "test_file2.txt";
601 let file_content = indoc! {r#"
602 fn main() {
603 println!("Hello, world!");
604 }
605 "#};
606
607 let write_cmd = Command::shell(format!("echo '{file_content}' > {file_path}"));
609
610 executor.exec_cmd(&write_cmd).await?;
612
613 let read_cmd = Command::shell(format!("cat {file_path}"));
615
616 let output = executor.exec_cmd(&read_cmd).await?;
618
619 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 let temp_dir = TempDir::new()?;
629 let temp_path = temp_dir.path();
630
631 let executor = LocalExecutor {
633 workdir: temp_path.to_path_buf(),
634 ..Default::default()
635 };
636
637 let file_path = temp_path.join("test_file.txt");
639 let file_content = "Hello, world!";
640
641 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 let write_cmd = Command::write_file(file_path.clone(), file_content.to_string());
653
654 executor.exec_cmd(&write_cmd).await?;
656
657 assert!(file_path.exists());
659
660 let read_cmd = Command::read_file(file_path.clone());
662
663 let output = executor.exec_cmd(&read_cmd).await?.output;
665
666 assert_eq!(output, file_content);
668
669 Ok(())
670 }
671
672 #[tokio::test]
673 async fn test_local_executor_stream_files() -> anyhow::Result<()> {
674 let temp_dir = TempDir::new()?;
676 let temp_path = temp_dir.path();
677
678 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 let executor = LocalExecutor {
685 workdir: temp_path.to_path_buf(),
686 ..Default::default()
687 };
688
689 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 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 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 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 let fname = "workdir_check.txt";
729 let write_cmd = Command::write_file(fname, "test123");
730 executor.exec_cmd(&write_cmd).await?;
731
732 let expected_path = temp_path.join(fname);
734 assert!(expected_path.exists());
735 assert!(!Path::new(fname).exists());
736
737 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 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}