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}