Skip to main content

slash_lib/
executor.rs

1use std::collections::HashMap;
2use std::io::Write as _;
3
4use slash_lang::parser::ast::{Command, Op, Program, Redirection};
5
6// ============================================================================
7// DOMAIN TYPES
8// ============================================================================
9
10/// Accumulated context passed through a chain of optional commands.
11///
12/// Each entry maps the command name to its string output (`None` if the command
13/// produced no output). The context is serialized to JSON and passed as the
14/// stdin input to the first non-optional command that terminates the chain.
15pub struct Context {
16    pub values: HashMap<String, Option<String>>,
17}
18
19impl Context {
20    pub fn new() -> Self {
21        Self {
22            values: HashMap::new(),
23        }
24    }
25
26    pub fn insert(&mut self, key: impl Into<String>, value: Option<String>) {
27        self.values.insert(key.into(), value);
28    }
29
30    /// Serialize to a minimal JSON object. Values are string-escaped but this
31    /// is not a full JSON encoder — keep values simple (no embedded quotes).
32    pub fn to_json(&self) -> String {
33        let pairs: Vec<String> = self
34            .values
35            .iter()
36            .map(|(k, v)| match v {
37                Some(s) => format!("\"{}\":\"{}\"", k, s.replace('"', "\\\"")),
38                None => format!("\"{}\":null", k),
39            })
40            .collect();
41        format!("{{{}}}", pairs.join(","))
42    }
43}
44
45impl Default for Context {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// Value flowing through a pipe between commands within a pipeline.
52pub enum PipeValue {
53    /// Raw bytes from a non-optional command's stdout (or stdout+stderr for `|&`).
54    Bytes(Vec<u8>),
55    /// Accumulated context from a chain of optional commands, ready to be
56    /// serialized to JSON and passed to the terminal non-optional command.
57    Context(Context),
58}
59
60/// The output of running a single command via [`CommandRunner`].
61pub struct CommandOutput {
62    /// The command's stdout, if any.
63    pub stdout: Option<Vec<u8>>,
64    /// The command's stderr, if any. Included in the next command's input when
65    /// the preceding pipe operator is `|&`.
66    pub stderr: Option<Vec<u8>>,
67    /// Whether the command succeeded. Controls `&&`/`||` branching.
68    pub success: bool,
69}
70
71/// Errors that can occur during execution.
72#[derive(Debug)]
73pub enum ExecutionError {
74    /// A [`CommandRunner`] returned an error for a specific command.
75    Runner(String),
76    /// Output redirection failed (e.g., could not open or write the file).
77    Redirect(String),
78}
79
80// ============================================================================
81// PORTS (TRAITS)
82// ============================================================================
83
84/// Port: dispatches a single slash command and returns its output.
85///
86/// Implement this trait to define what each command actually does. The
87/// orchestration engine ([`Executor`]) handles `&&`/`||`/`|`/`|&` semantics,
88/// optional-pipe context accumulation, and redirection; this trait handles
89/// only individual command dispatch.
90///
91/// # Contract
92///
93/// - `cmd.name` is the normalized, lowercase command name.
94/// - `input` is the pipe value arriving from the left, if any.
95/// - Returning `Ok` with `success: false` is a *soft* failure — the executor
96///   uses it for `&&`/`||` branching but does not propagate it as an error.
97/// - Returning `Err` is a *hard* failure that aborts execution immediately.
98pub trait CommandRunner {
99    fn run(
100        &self,
101        cmd: &Command,
102        input: Option<&PipeValue>,
103    ) -> Result<CommandOutput, ExecutionError>;
104}
105
106/// Port: runs a complete parsed [`Program`] and returns the final output, if any.
107pub trait Execute {
108    fn execute(&self, program: &Program) -> Result<Option<PipeValue>, ExecutionError>;
109}
110
111// ============================================================================
112// ORCHESTRATION ENGINE
113// ============================================================================
114
115/// Orchestration engine that walks a [`Program`] AST and applies shell-like
116/// execution semantics.
117///
118/// Generic over [`CommandRunner`] so the actual command dispatch is pluggable.
119/// The composition root wires in a concrete runner (e.g., a process spawner,
120/// a hook dispatcher, or a test double).
121///
122/// # Semantics
123///
124/// - `&&` / `||` — connect pipelines; the gate is the previous pipeline's success.
125/// - `|` — pipe stdout of one command into the next.
126/// - `|&` — pipe stdout + stderr of one command into the next.
127/// - `?` suffix — marks a command as optional; optional commands in sequence
128///   accumulate their outputs into a [`Context`], which is serialized as JSON
129///   and passed to the first non-optional command that follows.
130/// - `>` / `>>` — redirect the final command's stdout to a file.
131pub struct Executor<R: CommandRunner> {
132    runner: R,
133}
134
135impl<R: CommandRunner> Executor<R> {
136    pub fn new(runner: R) -> Self {
137        Self { runner }
138    }
139
140    /// Consume the executor and return the underlying runner.
141    ///
142    /// Useful in tests to inspect what the runner recorded after execution.
143    pub fn into_runner(self) -> R {
144        self.runner
145    }
146}
147
148impl<R: CommandRunner> Execute for Executor<R> {
149    fn execute(&self, program: &Program) -> Result<Option<PipeValue>, ExecutionError> {
150        let mut last_output: Option<PipeValue> = None;
151        let mut last_success = true;
152        let mut skip_reason: Option<&Op> = None;
153
154        for pipeline in &program.pipelines {
155            // Apply &&/|| gate from the previous pipeline's operator.
156            if let Some(op) = skip_reason {
157                let skip = match op {
158                    Op::And => !last_success, // && skips on failure
159                    Op::Or => last_success,   // || skips on success
160                    _ => false,
161                };
162                if skip {
163                    skip_reason = pipeline.operator.as_ref();
164                    continue;
165                }
166            }
167
168            let (output, success) =
169                run_pipeline(&self.runner, &pipeline.commands, last_output.take())?;
170            last_output = output;
171            last_success = success;
172            skip_reason = pipeline.operator.as_ref();
173        }
174
175        Ok(last_output)
176    }
177}
178
179// ============================================================================
180// PRIVATE ORCHESTRATION HELPERS
181// ============================================================================
182
183/// Execute one pipeline, returning its final output and success flag.
184///
185/// `initial_input` is passed to the first command (used when the caller wants
186/// to seed the pipeline — currently always `None` in practice, since `&&`/`||`
187/// do not pipe output between pipelines).
188fn run_pipeline<R: CommandRunner>(
189    runner: &R,
190    commands: &[Command],
191    initial_input: Option<PipeValue>,
192) -> Result<(Option<PipeValue>, bool), ExecutionError> {
193    let mut pipe_input: Option<PipeValue> = initial_input;
194    let mut context = Context::new();
195    let mut in_optional_chain = false;
196    let mut last_success = true;
197
198    for cmd in commands {
199        if cmd.optional {
200            // Optional commands run independently (no piped input) and
201            // contribute their string output to the accumulating Context.
202            in_optional_chain = true;
203            let result = runner.run(cmd, None)?;
204            last_success = result.success;
205            let stdout_str = result.stdout.and_then(|b| String::from_utf8(b).ok());
206            context.insert(&cmd.name, stdout_str);
207        } else {
208            // Non-optional command: determine what input it receives.
209            let input = if in_optional_chain {
210                // Close the optional chain and pass accumulated Context as JSON.
211                in_optional_chain = false;
212                let json = {
213                    let mut ctx = Context::new();
214                    std::mem::swap(&mut ctx, &mut context);
215                    ctx.to_json()
216                };
217                Some(PipeValue::Bytes(json.into_bytes()))
218            } else {
219                pipe_input.take()
220            };
221
222            let result = runner.run(cmd, input.as_ref())?;
223            last_success = result.success;
224
225            // Handle output redirection (closes the pipeline for further `|`).
226            if let Some(redirect) = &cmd.redirect {
227                if let Some(stdout) = result.stdout {
228                    write_redirect(redirect, &stdout)?;
229                }
230                pipe_input = None;
231            } else {
232                // Build the pipe value for the next command.
233                // If this command is connected via |& the stderr is merged in.
234                pipe_input = match &cmd.pipe {
235                    Some(Op::PipeErr) => {
236                        let mut combined = result.stdout.unwrap_or_default();
237                        combined.extend(result.stderr.unwrap_or_default());
238                        if combined.is_empty() {
239                            None
240                        } else {
241                            Some(PipeValue::Bytes(combined))
242                        }
243                    }
244                    _ => result.stdout.map(PipeValue::Bytes),
245                };
246            }
247        }
248    }
249
250    Ok((pipe_input, last_success))
251}
252
253fn write_redirect(redirect: &Redirection, data: &[u8]) -> Result<(), ExecutionError> {
254    match redirect {
255        Redirection::Truncate(path) => {
256            std::fs::write(path, data).map_err(|e| ExecutionError::Redirect(e.to_string()))
257        }
258        Redirection::Append(path) => {
259            let mut file = std::fs::OpenOptions::new()
260                .create(true)
261                .append(true)
262                .open(path)
263                .map_err(|e| ExecutionError::Redirect(e.to_string()))?;
264            file.write_all(data)
265                .map_err(|e| ExecutionError::Redirect(e.to_string()))
266        }
267    }
268}