Skip to main content

swiftide_core/
tool_executor.rs

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/// A `ToolExecutor` provides an interface for agents to interact with a system
18/// in an isolated context.
19///
20/// When starting up an agent, it's context expects an executor. For example,
21/// you might want your coding agent to work with a fresh, isolated set of files,
22/// separated from the rest of the system.
23///
24/// See `swiftide-docker-executor` for an executor that uses Docker. By default
25/// the executor is a local executor.
26///
27/// Additionally, the executor can be used stream files files for indexing.
28#[async_trait]
29pub trait ToolExecutor: Send + Sync + DynClone {
30    /// Execute a command in the executor
31    async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError>;
32
33    /// Execute a command and report output as it becomes available.
34    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    /// Stream files from the executor
43    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
52/// Receives command output in the order the executor observed it.
53///
54/// Called from the executor's read loop, so it must not block.
55pub type CommandOutputSink<'a> = dyn FnMut(&CommandOutputChunk) + Send + 'a;
56
57/// Reports the output of an already finished command to `sink` and returns the result unchanged.
58///
59/// Executors that buffer output use this to satisfy the streaming contract after the fact.
60///
61/// # Errors
62///
63/// Returns the error from `result` after reporting any output it carries.
64pub 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/// Lightweight executor wrapper that applies a default working directory to forwarded commands.
77///
78/// Most callers should construct this via [`ExecutorExt::scoped`], which borrows the underlying
79/// executor and only clones commands/paths when the scope actually changes their resolution.
80#[derive(Debug, Clone)]
81pub struct ScopedExecutor<E> {
82    executor: E,
83    scope: PathBuf,
84}
85
86impl<E> ScopedExecutor<E> {
87    /// Build a new wrapper around `executor` that prefixes relative paths with `scope`.
88    pub fn new(executor: E, scope: impl Into<PathBuf>) -> Self {
89        Self {
90            executor,
91            scope: scope.into(),
92        }
93    }
94
95    /// Returns either the original command or a scoped clone depending on the current directory.
96    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    /// Returns a path adjusted for the scope when the provided path is relative.
116    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    /// Access the inner executor.
125    pub fn inner(&self) -> &E {
126        &self.executor
127    }
128
129    /// Expose the scope that will be applied to relative paths.
130    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
168/// Convenience methods for scoping executors without cloning them.
169pub trait ExecutorExt {
170    /// Borrow `self` and return a wrapper that resolves relative operations inside `path`.
171    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    /// The executor itself failed
266    #[error("executor error: {0:#}")]
267    ExecutorError(#[from] anyhow::Error),
268
269    /// The command exceeded its allotted time budget
270    #[error("command timed out after {timeout:?}: {output}")]
271    TimedOut {
272        timeout: Duration,
273        output: CommandOutput,
274    },
275
276    /// The command failed, i.e. failing tests with stderr. This error might be handled
277    #[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/// Commands that can be executed by the executor
303/// Conceptually, `Shell` allows any kind of input, and other commands enable more optimized
304/// implementations.
305///
306/// There is an ongoing consideration to make this an associated type on the executor
307///
308/// TODO: Should be able to borrow everything?
309///
310/// Use the constructor helpers (e.g. [`Command::shell`]) and then chain configuration methods
311/// such as [`Command::with_current_dir`] or [`Command::current_dir`] for builder-style ergonomics.
312#[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    /// Override the working directory used when executing this command.
360    ///
361    /// Executors may interpret relative paths in the context of their own
362    /// working directory.
363    #[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    /// Override the working directory using the `std::process::Command`
370    /// builder-lite style API.
371    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    /// Override the timeout used when executing this command.
403    #[must_use]
404    pub fn with_timeout(mut self, timeout: Duration) -> Self {
405        self.timeout(timeout);
406        self
407    }
408
409    /// Override the timeout using the builder-style API.
410    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    /// Remove any timeout previously configured on this command.
422    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    /// Returns the timeout associated with this command, if any.
434    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}