1use 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 #[builder(default)]
27 pub(crate) env_clear: bool,
28 #[builder(default, setter(into))]
30 pub(crate) env_remove: Vec<String>,
31 #[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 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 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 return Err(anyhow::anyhow!("Failed to get outputs from command").into());
167 };
168
169 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 #[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 let temp_dir = TempDir::new()?;
251 let temp_path = temp_dir.path();
252
253 let executor = LocalExecutor {
255 workdir: temp_path.to_path_buf(),
256 ..Default::default()
257 };
258
259 let file_path = temp_path.join("test_file.txt");
261 let file_content = "Hello, world!";
262
263 let write_cmd =
265 Command::Shell(format!("echo '{}' > {}", file_content, file_path.display()));
266
267 executor.exec_cmd(&write_cmd).await?;
269
270 assert!(file_path.exists());
272
273 let read_cmd = Command::Shell(format!("cat {}", file_path.display()));
275
276 let output = executor.exec_cmd(&read_cmd).await?;
278
279 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 let temp_dir = TempDir::new()?;
289 let temp_path = temp_dir.path();
290
291 let executor = LocalExecutor {
293 workdir: temp_path.to_path_buf(),
294 ..Default::default()
295 };
296
297 let echo_cmd = Command::Shell("echo 'hello world'".to_string());
299
300 let output = executor.exec_cmd(&echo_cmd).await?;
302
303 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 let temp_dir = TempDir::new()?;
313 let temp_path = temp_dir.path();
314
315 let executor = LocalExecutor {
317 workdir: temp_path.to_path_buf(),
318 env_clear: true,
319 ..Default::default()
320 };
321
322 let echo_cmd = Command::Shell("printenv".to_string());
324
325 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
327
328 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 let temp_dir = TempDir::new()?;
339 let temp_path = temp_dir.path();
340
341 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 let echo_cmd = Command::Shell("printenv".to_string());
350
351 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
353
354 assert!(output.contains("TEST_ENV=HELLO"), "{output}");
357 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 let temp_dir = TempDir::new()?;
367 let temp_path = temp_dir.path();
368
369 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 let echo_cmd = Command::Shell("printenv".to_string());
378
379 let output = executor.exec_cmd(&echo_cmd).await?.to_string();
381
382 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 let temp_dir = TempDir::new()?;
393 let temp_path = temp_dir.path();
394
395 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 let output = executor
407 .exec_cmd(&Command::shell(script))
408 .await?
409 .to_string();
410
411 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 let temp_dir = TempDir::new()?;
422 let temp_path = temp_dir.path();
423
424 let executor = LocalExecutor {
426 workdir: temp_path.to_path_buf(),
427 ..Default::default()
428 };
429
430 let file_path = "test_file2.txt";
432 let file_content = indoc! {r#"
433 fn main() {
434 println!("Hello, world!");
435 }
436 "#};
437
438 let write_cmd = Command::Shell(format!("echo '{file_content}' > {file_path}"));
440
441 executor.exec_cmd(&write_cmd).await?;
443
444 let read_cmd = Command::Shell(format!("cat {file_path}"));
446
447 let output = executor.exec_cmd(&read_cmd).await?;
449
450 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 let temp_dir = TempDir::new()?;
460 let temp_path = temp_dir.path();
461
462 let executor = LocalExecutor {
464 workdir: temp_path.to_path_buf(),
465 ..Default::default()
466 };
467
468 let file_path = temp_path.join("test_file.txt");
470 let file_content = "Hello, world!";
471
472 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 let write_cmd = Command::WriteFile(file_path.clone(), file_content.to_string());
484
485 executor.exec_cmd(&write_cmd).await?;
487
488 assert!(file_path.exists());
490
491 let read_cmd = Command::ReadFile(file_path.clone());
493
494 let output = executor.exec_cmd(&read_cmd).await?.output;
496
497 assert_eq!(output, file_content);
499
500 Ok(())
501 }
502
503 #[tokio::test]
504 async fn test_local_executor_stream_files() -> anyhow::Result<()> {
505 let temp_dir = TempDir::new()?;
507 let temp_path = temp_dir.path();
508
509 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 let executor = LocalExecutor {
516 workdir: temp_path.to_path_buf(),
517 ..Default::default()
518 };
519
520 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 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}