1use std::{
2 borrow::Cow,
3 path::{Path, PathBuf},
4 sync::Arc,
5 time::Duration,
6};
7
8use crate::{
9 command_output::{CommandOutput, CommandOutputChunk},
10 indexing::IndexingStream,
11};
12use anyhow::Result;
13use async_trait::async_trait;
14use dyn_clone::DynClone;
15use thiserror::Error;
16
17#[async_trait]
29pub trait ToolExecutor: Send + Sync + DynClone {
30 async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError>;
32
33 async fn exec_cmd_streaming(
35 &self,
36 cmd: &Command,
37 output: &mut CommandOutputSink<'_>,
38 ) -> Result<CommandOutput, CommandError> {
39 report_buffered_output(self.exec_cmd(cmd).await, output)
40 }
41
42 async fn stream_files(
44 &self,
45 path: &Path,
46 extensions: Option<Vec<String>>,
47 ) -> Result<IndexingStream<String>>;
48}
49
50dyn_clone::clone_trait_object!(ToolExecutor);
51
52pub type CommandOutputSink<'a> = dyn FnMut(&CommandOutputChunk) + Send + 'a;
56
57pub fn report_buffered_output(
65 result: Result<CommandOutput, CommandError>,
66 sink: &mut CommandOutputSink<'_>,
67) -> Result<CommandOutput, CommandError> {
68 if let Ok(output)
69 | Err(CommandError::NonZeroExit(output) | CommandError::TimedOut { output, .. }) = &result
70 {
71 output.chunks().iter().for_each(sink);
72 }
73 result
74}
75
76#[derive(Debug, Clone)]
81pub struct ScopedExecutor<E> {
82 executor: E,
83 scope: PathBuf,
84}
85
86impl<E> ScopedExecutor<E> {
87 pub fn new(executor: E, scope: impl Into<PathBuf>) -> Self {
89 Self {
90 executor,
91 scope: scope.into(),
92 }
93 }
94
95 fn apply_scope<'a>(&'a self, cmd: &'a Command) -> Cow<'a, Command> {
97 match cmd.current_dir_path() {
98 Some(path) if path.is_absolute() || self.scope.as_os_str().is_empty() => {
99 Cow::Borrowed(cmd)
100 }
101 Some(path) => {
102 let mut scoped = cmd.clone();
103 scoped.current_dir(self.scope.join(path));
104 Cow::Owned(scoped)
105 }
106 None if self.scope.as_os_str().is_empty() => Cow::Borrowed(cmd),
107 None => {
108 let mut scoped = cmd.clone();
109 scoped.current_dir(self.scope.clone());
110 Cow::Owned(scoped)
111 }
112 }
113 }
114
115 fn scoped_path<'a>(&'a self, path: &'a Path) -> Cow<'a, Path> {
117 if path.is_absolute() || self.scope.as_os_str().is_empty() {
118 Cow::Borrowed(path)
119 } else {
120 Cow::Owned(self.scope.join(path))
121 }
122 }
123
124 pub fn inner(&self) -> &E {
126 &self.executor
127 }
128
129 pub fn scope(&self) -> &Path {
131 &self.scope
132 }
133}
134
135#[async_trait]
136impl<E> ToolExecutor for ScopedExecutor<E>
137where
138 E: ToolExecutor + Send + Sync + Clone,
139{
140 async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
141 let scoped_cmd = self.apply_scope(cmd);
142 self.executor.exec_cmd(scoped_cmd.as_ref()).await
143 }
144
145 async fn exec_cmd_streaming(
146 &self,
147 cmd: &Command,
148 output: &mut CommandOutputSink<'_>,
149 ) -> Result<CommandOutput, CommandError> {
150 let scoped_cmd = self.apply_scope(cmd);
151 self.executor
152 .exec_cmd_streaming(scoped_cmd.as_ref(), output)
153 .await
154 }
155
156 async fn stream_files(
157 &self,
158 path: &Path,
159 extensions: Option<Vec<String>>,
160 ) -> Result<IndexingStream<String>> {
161 let scoped_path = self.scoped_path(path);
162 self.executor
163 .stream_files(scoped_path.as_ref(), extensions)
164 .await
165 }
166}
167
168pub trait ExecutorExt {
170 fn scoped(&self, path: impl Into<PathBuf>) -> ScopedExecutor<&Self>;
172
173 fn scoped_owned(self, path: impl Into<PathBuf>) -> ScopedExecutor<Self>
174 where
175 Self: Sized;
176}
177
178impl<T> ExecutorExt for T
179where
180 T: ToolExecutor,
181{
182 fn scoped(&self, path: impl Into<PathBuf>) -> ScopedExecutor<&Self> {
183 ScopedExecutor::new(self, path)
184 }
185
186 fn scoped_owned(self, path: impl Into<PathBuf>) -> ScopedExecutor<Self> {
187 ScopedExecutor::new(self, path)
188 }
189}
190
191#[async_trait]
192impl<T> ToolExecutor for &T
193where
194 T: ToolExecutor + ?Sized,
195{
196 async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
197 (**self).exec_cmd(cmd).await
198 }
199
200 async fn exec_cmd_streaming(
201 &self,
202 cmd: &Command,
203 output: &mut CommandOutputSink<'_>,
204 ) -> Result<CommandOutput, CommandError> {
205 (**self).exec_cmd_streaming(cmd, output).await
206 }
207
208 async fn stream_files(
209 &self,
210 path: &Path,
211 extensions: Option<Vec<String>>,
212 ) -> Result<IndexingStream<String>> {
213 (**self).stream_files(path, extensions).await
214 }
215}
216
217#[async_trait]
218impl ToolExecutor for Arc<dyn ToolExecutor> {
219 async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
220 self.as_ref().exec_cmd(cmd).await
221 }
222
223 async fn exec_cmd_streaming(
224 &self,
225 cmd: &Command,
226 output: &mut CommandOutputSink<'_>,
227 ) -> Result<CommandOutput, CommandError> {
228 self.as_ref().exec_cmd_streaming(cmd, output).await
229 }
230
231 async fn stream_files(
232 &self,
233 path: &Path,
234 extensions: Option<Vec<String>>,
235 ) -> Result<IndexingStream<String>> {
236 self.as_ref().stream_files(path, extensions).await
237 }
238}
239
240#[async_trait]
241impl ToolExecutor for Box<dyn ToolExecutor> {
242 async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> {
243 self.as_ref().exec_cmd(cmd).await
244 }
245
246 async fn exec_cmd_streaming(
247 &self,
248 cmd: &Command,
249 output: &mut CommandOutputSink<'_>,
250 ) -> Result<CommandOutput, CommandError> {
251 self.as_ref().exec_cmd_streaming(cmd, output).await
252 }
253
254 async fn stream_files(
255 &self,
256 path: &Path,
257 extensions: Option<Vec<String>>,
258 ) -> Result<IndexingStream<String>> {
259 self.as_ref().stream_files(path, extensions).await
260 }
261}
262
263#[derive(Debug, Error)]
264pub enum CommandError {
265 #[error("executor error: {0:#}")]
267 ExecutorError(#[from] anyhow::Error),
268
269 #[error("command timed out after {timeout:?}: {output}")]
271 TimedOut {
272 timeout: Duration,
273 output: CommandOutput,
274 },
275
276 #[error("command failed with NonZeroExit: {0}")]
278 NonZeroExit(CommandOutput),
279}
280
281impl From<std::io::Error> for CommandError {
282 fn from(err: std::io::Error) -> Self {
283 CommandError::NonZeroExit(err.to_string().into())
284 }
285}
286
287#[cfg(test)]
288mod command_error_tests {
289 use super::{CommandError, CommandOutput};
290
291 #[test]
292 fn renders_the_command_output() {
293 let output = CommandOutput::new("onetwo");
294
295 assert_eq!(
296 CommandError::NonZeroExit(output).to_string(),
297 "command failed with NonZeroExit: onetwo"
298 );
299 }
300}
301
302#[derive(Debug, Clone)]
313#[non_exhaustive]
314pub enum Command {
315 Shell {
316 command: String,
317 current_dir: Option<PathBuf>,
318 timeout: Option<Duration>,
319 },
320 ReadFile {
321 path: PathBuf,
322 current_dir: Option<PathBuf>,
323 timeout: Option<Duration>,
324 },
325 WriteFile {
326 path: PathBuf,
327 content: String,
328 current_dir: Option<PathBuf>,
329 timeout: Option<Duration>,
330 },
331}
332
333impl Command {
334 pub fn shell<S: Into<String>>(cmd: S) -> Self {
335 Command::Shell {
336 command: cmd.into(),
337 current_dir: None,
338 timeout: None,
339 }
340 }
341
342 pub fn read_file<P: Into<PathBuf>>(path: P) -> Self {
343 Command::ReadFile {
344 path: path.into(),
345 current_dir: None,
346 timeout: None,
347 }
348 }
349
350 pub fn write_file<P: Into<PathBuf>, S: Into<String>>(path: P, content: S) -> Self {
351 Command::WriteFile {
352 path: path.into(),
353 content: content.into(),
354 current_dir: None,
355 timeout: None,
356 }
357 }
358
359 #[must_use]
364 pub fn with_current_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
365 self.current_dir(path);
366 self
367 }
368
369 pub fn current_dir<P: Into<PathBuf>>(&mut self, path: P) -> &mut Self {
372 let dir = Some(path.into());
373 match self {
374 Command::Shell { current_dir, .. }
375 | Command::ReadFile { current_dir, .. }
376 | Command::WriteFile { current_dir, .. } => {
377 *current_dir = dir;
378 }
379 }
380 self
381 }
382
383 pub fn clear_current_dir(&mut self) -> &mut Self {
384 match self {
385 Command::Shell { current_dir, .. }
386 | Command::ReadFile { current_dir, .. }
387 | Command::WriteFile { current_dir, .. } => {
388 *current_dir = None;
389 }
390 }
391 self
392 }
393
394 pub fn current_dir_path(&self) -> Option<&Path> {
395 match self {
396 Command::Shell { current_dir, .. }
397 | Command::ReadFile { current_dir, .. }
398 | Command::WriteFile { current_dir, .. } => current_dir.as_deref(),
399 }
400 }
401
402 #[must_use]
404 pub fn with_timeout(mut self, timeout: Duration) -> Self {
405 self.timeout(timeout);
406 self
407 }
408
409 pub fn timeout(&mut self, timeout: Duration) -> &mut Self {
411 match self {
412 Command::Shell { timeout: slot, .. }
413 | Command::ReadFile { timeout: slot, .. }
414 | Command::WriteFile { timeout: slot, .. } => {
415 *slot = Some(timeout);
416 }
417 }
418 self
419 }
420
421 pub fn clear_timeout(&mut self) -> &mut Self {
423 match self {
424 Command::Shell { timeout, .. }
425 | Command::ReadFile { timeout, .. }
426 | Command::WriteFile { timeout, .. } => {
427 *timeout = None;
428 }
429 }
430 self
431 }
432
433 pub fn timeout_duration(&self) -> Option<&Duration> {
435 match self {
436 Command::Shell { timeout, .. }
437 | Command::ReadFile { timeout, .. }
438 | Command::WriteFile { timeout, .. } => timeout.as_ref(),
439 }
440 }
441}