slash-lib 0.1.0

Executor types and high-level API for the slash-command language
Documentation
use std::collections::HashMap;
use std::io::Write as _;

use slash_lang::parser::ast::{Command, Op, Program, Redirection};

// ============================================================================
// DOMAIN TYPES
// ============================================================================

/// Accumulated context passed through a chain of optional commands.
///
/// Each entry maps the command name to its string output (`None` if the command
/// produced no output). The context is serialized to JSON and passed as the
/// stdin input to the first non-optional command that terminates the chain.
pub struct Context {
    pub values: HashMap<String, Option<String>>,
}

impl Context {
    pub fn new() -> Self {
        Self {
            values: HashMap::new(),
        }
    }

    pub fn insert(&mut self, key: impl Into<String>, value: Option<String>) {
        self.values.insert(key.into(), value);
    }

    /// Serialize to a minimal JSON object. Values are string-escaped but this
    /// is not a full JSON encoder — keep values simple (no embedded quotes).
    pub fn to_json(&self) -> String {
        let pairs: Vec<String> = self
            .values
            .iter()
            .map(|(k, v)| match v {
                Some(s) => format!("\"{}\":\"{}\"", k, s.replace('"', "\\\"")),
                None => format!("\"{}\":null", k),
            })
            .collect();
        format!("{{{}}}", pairs.join(","))
    }
}

impl Default for Context {
    fn default() -> Self {
        Self::new()
    }
}

/// Value flowing through a pipe between commands within a pipeline.
pub enum PipeValue {
    /// Raw bytes from a non-optional command's stdout (or stdout+stderr for `|&`).
    Bytes(Vec<u8>),
    /// Accumulated context from a chain of optional commands, ready to be
    /// serialized to JSON and passed to the terminal non-optional command.
    Context(Context),
}

/// The output of running a single command via [`CommandRunner`].
pub struct CommandOutput {
    /// The command's stdout, if any.
    pub stdout: Option<Vec<u8>>,
    /// The command's stderr, if any. Included in the next command's input when
    /// the preceding pipe operator is `|&`.
    pub stderr: Option<Vec<u8>>,
    /// Whether the command succeeded. Controls `&&`/`||` branching.
    pub success: bool,
}

/// Errors that can occur during execution.
#[derive(Debug)]
pub enum ExecutionError {
    /// A [`CommandRunner`] returned an error for a specific command.
    Runner(String),
    /// Output redirection failed (e.g., could not open or write the file).
    Redirect(String),
}

// ============================================================================
// PORTS (TRAITS)
// ============================================================================

/// Port: dispatches a single slash command and returns its output.
///
/// Implement this trait to define what each command actually does. The
/// orchestration engine ([`Executor`]) handles `&&`/`||`/`|`/`|&` semantics,
/// optional-pipe context accumulation, and redirection; this trait handles
/// only individual command dispatch.
///
/// # Contract
///
/// - `cmd.name` is the normalized, lowercase command name.
/// - `input` is the pipe value arriving from the left, if any.
/// - Returning `Ok` with `success: false` is a *soft* failure — the executor
///   uses it for `&&`/`||` branching but does not propagate it as an error.
/// - Returning `Err` is a *hard* failure that aborts execution immediately.
pub trait CommandRunner {
    fn run(
        &self,
        cmd: &Command,
        input: Option<&PipeValue>,
    ) -> Result<CommandOutput, ExecutionError>;
}

/// Port: runs a complete parsed [`Program`] and returns the final output, if any.
pub trait Execute {
    fn execute(&self, program: &Program) -> Result<Option<PipeValue>, ExecutionError>;
}

// ============================================================================
// ORCHESTRATION ENGINE
// ============================================================================

/// Orchestration engine that walks a [`Program`] AST and applies shell-like
/// execution semantics.
///
/// Generic over [`CommandRunner`] so the actual command dispatch is pluggable.
/// The composition root wires in a concrete runner (e.g., a process spawner,
/// a hook dispatcher, or a test double).
///
/// # Semantics
///
/// - `&&` / `||` — connect pipelines; the gate is the previous pipeline's success.
/// - `|` — pipe stdout of one command into the next.
/// - `|&` — pipe stdout + stderr of one command into the next.
/// - `?` suffix — marks a command as optional; optional commands in sequence
///   accumulate their outputs into a [`Context`], which is serialized as JSON
///   and passed to the first non-optional command that follows.
/// - `>` / `>>` — redirect the final command's stdout to a file.
pub struct Executor<R: CommandRunner> {
    runner: R,
}

impl<R: CommandRunner> Executor<R> {
    pub fn new(runner: R) -> Self {
        Self { runner }
    }

    /// Consume the executor and return the underlying runner.
    ///
    /// Useful in tests to inspect what the runner recorded after execution.
    pub fn into_runner(self) -> R {
        self.runner
    }
}

impl<R: CommandRunner> Execute for Executor<R> {
    fn execute(&self, program: &Program) -> Result<Option<PipeValue>, ExecutionError> {
        let mut last_output: Option<PipeValue> = None;
        let mut last_success = true;
        let mut skip_reason: Option<&Op> = None;

        for pipeline in &program.pipelines {
            // Apply &&/|| gate from the previous pipeline's operator.
            if let Some(op) = skip_reason {
                let skip = match op {
                    Op::And => !last_success, // && skips on failure
                    Op::Or => last_success,   // || skips on success
                    _ => false,
                };
                if skip {
                    skip_reason = pipeline.operator.as_ref();
                    continue;
                }
            }

            let (output, success) =
                run_pipeline(&self.runner, &pipeline.commands, last_output.take())?;
            last_output = output;
            last_success = success;
            skip_reason = pipeline.operator.as_ref();
        }

        Ok(last_output)
    }
}

// ============================================================================
// PRIVATE ORCHESTRATION HELPERS
// ============================================================================

/// Execute one pipeline, returning its final output and success flag.
///
/// `initial_input` is passed to the first command (used when the caller wants
/// to seed the pipeline — currently always `None` in practice, since `&&`/`||`
/// do not pipe output between pipelines).
fn run_pipeline<R: CommandRunner>(
    runner: &R,
    commands: &[Command],
    initial_input: Option<PipeValue>,
) -> Result<(Option<PipeValue>, bool), ExecutionError> {
    let mut pipe_input: Option<PipeValue> = initial_input;
    let mut context = Context::new();
    let mut in_optional_chain = false;
    let mut last_success = true;

    for cmd in commands {
        if cmd.optional {
            // Optional commands run independently (no piped input) and
            // contribute their string output to the accumulating Context.
            in_optional_chain = true;
            let result = runner.run(cmd, None)?;
            last_success = result.success;
            let stdout_str = result.stdout.and_then(|b| String::from_utf8(b).ok());
            context.insert(&cmd.name, stdout_str);
        } else {
            // Non-optional command: determine what input it receives.
            let input = if in_optional_chain {
                // Close the optional chain and pass accumulated Context as JSON.
                in_optional_chain = false;
                let json = {
                    let mut ctx = Context::new();
                    std::mem::swap(&mut ctx, &mut context);
                    ctx.to_json()
                };
                Some(PipeValue::Bytes(json.into_bytes()))
            } else {
                pipe_input.take()
            };

            let result = runner.run(cmd, input.as_ref())?;
            last_success = result.success;

            // Handle output redirection (closes the pipeline for further `|`).
            if let Some(redirect) = &cmd.redirect {
                if let Some(stdout) = result.stdout {
                    write_redirect(redirect, &stdout)?;
                }
                pipe_input = None;
            } else {
                // Build the pipe value for the next command.
                // If this command is connected via |& the stderr is merged in.
                pipe_input = match &cmd.pipe {
                    Some(Op::PipeErr) => {
                        let mut combined = result.stdout.unwrap_or_default();
                        combined.extend(result.stderr.unwrap_or_default());
                        if combined.is_empty() {
                            None
                        } else {
                            Some(PipeValue::Bytes(combined))
                        }
                    }
                    _ => result.stdout.map(PipeValue::Bytes),
                };
            }
        }
    }

    Ok((pipe_input, last_success))
}

fn write_redirect(redirect: &Redirection, data: &[u8]) -> Result<(), ExecutionError> {
    match redirect {
        Redirection::Truncate(path) => {
            std::fs::write(path, data).map_err(|e| ExecutionError::Redirect(e.to_string()))
        }
        Redirection::Append(path) => {
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open(path)
                .map_err(|e| ExecutionError::Redirect(e.to_string()))?;
            file.write_all(data)
                .map_err(|e| ExecutionError::Redirect(e.to_string()))
        }
    }
}