slash-lib 0.1.0

Executor types and high-level API for the slash-command language
Documentation
use std::cell::RefCell;
use std::collections::HashMap;

use slash_lang::parser::ast::Command;
use slash_lang::parser::parse;
use slash_lib::executor::{
    CommandOutput, CommandRunner, Execute, ExecutionError, Executor, PipeValue,
};

// ============================================================================
// TEST DOUBLE
// ============================================================================

/// A `CommandRunner` that records every call and returns configurable outputs.
///
/// Per-command behavior is set via `outcomes`. Commands not listed default to
/// success with no output.
struct CapturingRunner {
    /// name → (stdout, success)
    outcomes: HashMap<&'static str, (Option<Vec<u8>>, bool)>,
    /// All (command_name, had_input) pairs in call order.
    calls: RefCell<Vec<(String, bool)>>,
}

impl CapturingRunner {
    fn new() -> Self {
        Self {
            outcomes: HashMap::new(),
            calls: RefCell::new(vec![]),
        }
    }

    fn with(mut self, name: &'static str, stdout: Option<&str>, success: bool) -> Self {
        self.outcomes
            .insert(name, (stdout.map(|s| s.as_bytes().to_vec()), success));
        self
    }

    fn calls(&self) -> Vec<(String, bool)> {
        self.calls.borrow().clone()
    }
}

impl CommandRunner for CapturingRunner {
    fn run(
        &self,
        cmd: &Command,
        input: Option<&PipeValue>,
    ) -> Result<CommandOutput, ExecutionError> {
        let had_input = input.is_some();
        self.calls.borrow_mut().push((cmd.name.clone(), had_input));

        let (stdout, success) = self
            .outcomes
            .get(cmd.name.as_str())
            .cloned()
            .unwrap_or((None, true));

        Ok(CommandOutput {
            stdout,
            stderr: None,
            success,
        })
    }
}

fn executor(runner: CapturingRunner) -> Executor<CapturingRunner> {
    Executor::new(runner)
}

// ============================================================================
// BASIC DISPATCH
// ============================================================================

#[test]
fn single_command_is_dispatched() {
    let runner = CapturingRunner::new().with("build", Some("ok"), true);
    let ex = executor(runner);
    let prog = parse("/build").unwrap();
    let result = ex.execute(&prog).unwrap();
    assert!(matches!(result, Some(PipeValue::Bytes(_))));
    assert_eq!(ex.into_runner().calls(), vec![("build".to_string(), false)]);
}

// ============================================================================
// && / || GATING
// ============================================================================

#[test]
fn and_skips_second_pipeline_on_failure() {
    let runner = CapturingRunner::new().with("lint", None, false);
    let ex = executor(runner);
    let prog = parse("/lint && /deploy").unwrap();
    ex.execute(&prog).unwrap();
    // /deploy must not be called
    let calls: Vec<_> = ex
        .into_runner()
        .calls()
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    assert_eq!(calls, vec!["lint"]);
}

#[test]
fn and_runs_second_pipeline_on_success() {
    let runner = CapturingRunner::new().with("lint", None, true);
    let ex = executor(runner);
    let prog = parse("/lint && /deploy").unwrap();
    ex.execute(&prog).unwrap();
    let calls: Vec<_> = ex
        .into_runner()
        .calls()
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    assert_eq!(calls, vec!["lint", "deploy"]);
}

#[test]
fn or_skips_second_pipeline_on_success() {
    let runner = CapturingRunner::new().with("primary", None, true);
    let ex = executor(runner);
    let prog = parse("/primary || /fallback").unwrap();
    ex.execute(&prog).unwrap();
    let calls: Vec<_> = ex
        .into_runner()
        .calls()
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    assert_eq!(calls, vec!["primary"]);
}

#[test]
fn or_runs_second_pipeline_on_failure() {
    let runner = CapturingRunner::new().with("primary", None, false);
    let ex = executor(runner);
    let prog = parse("/primary || /fallback").unwrap();
    ex.execute(&prog).unwrap();
    let calls: Vec<_> = ex
        .into_runner()
        .calls()
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    assert_eq!(calls, vec!["primary", "fallback"]);
}

// ============================================================================
// PIPE
// ============================================================================

#[test]
fn pipe_passes_stdout_to_next_command() {
    let runner = CapturingRunner::new().with("lint", Some("report"), true);
    let ex = executor(runner);
    let prog = parse("/lint | /format").unwrap();
    ex.execute(&prog).unwrap();
    let calls = ex.into_runner().calls();
    // /lint has no input; /format receives lint's stdout
    assert_eq!(
        calls,
        vec![("lint".to_string(), false), ("format".to_string(), true)]
    );
}

#[test]
fn pipe_chain_threads_input() {
    let runner =
        CapturingRunner::new()
            .with("a", Some("data"), true)
            .with("b", Some("transformed"), true);
    let ex = executor(runner);
    let prog = parse("/a | /b | /c").unwrap();
    ex.execute(&prog).unwrap();
    let calls = ex.into_runner().calls();
    assert_eq!(
        calls,
        vec![
            ("a".to_string(), false),
            ("b".to_string(), true),
            ("c".to_string(), true),
        ]
    );
}

// ============================================================================
// OPTIONAL PIPE CHAIN
// ============================================================================

#[test]
fn optional_pipe_chain_parses() {
    let prog = parse("/foo? | /bar? | /baz").unwrap();
    let pipeline = &prog.pipelines[0];
    assert_eq!(pipeline.commands.len(), 3);
    assert!(pipeline.commands[0].optional);
    assert!(pipeline.commands[1].optional);
    assert!(!pipeline.commands[2].optional);
}

#[test]
fn optional_commands_accumulate_and_terminal_receives_context() {
    let runner = CapturingRunner::new()
        .with("foo", Some("foo-out"), true)
        .with("bar", Some("bar-out"), true);
    let ex = executor(runner);
    let prog = parse("/foo? | /bar? | /baz").unwrap();
    ex.execute(&prog).unwrap();

    let calls = ex.into_runner().calls();
    assert_eq!(calls.len(), 3);

    // foo and bar are optional — they run with no piped input
    assert_eq!(calls[0], ("foo".to_string(), false));
    assert_eq!(calls[1], ("bar".to_string(), false));

    // baz is the terminal non-optional — it receives the JSON context
    assert_eq!(calls[2], ("baz".to_string(), true));
}

#[test]
fn optional_with_no_output_inserts_null_in_context() {
    // /foo? produces nothing (None stdout) → context entry is null
    // /baz receives the context JSON — we just check it was called with input
    let runner = CapturingRunner::new().with("foo", None, true);
    let ex = executor(runner);
    let prog = parse("/foo? | /baz").unwrap();
    ex.execute(&prog).unwrap();
    let calls = ex.into_runner().calls();
    assert_eq!(calls[1], ("baz".to_string(), true)); // received the context
}

// ============================================================================
// MULTI-PIPELINE COMBINATIONS
// ============================================================================

#[test]
fn three_pipeline_and_chain_stops_on_first_failure() {
    let runner = CapturingRunner::new()
        .with("a", None, true)
        .with("b", None, false);
    let ex = executor(runner);
    let prog = parse("/a && /b && /c").unwrap();
    ex.execute(&prog).unwrap();
    let calls: Vec<_> = ex
        .into_runner()
        .calls()
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    assert_eq!(calls, vec!["a", "b"]); // /c skipped
}