slash-core 0.1.0

Orchestration layer for the slash-command language
Documentation
use serde::{Deserialize, Serialize};
use slash_lang::parser::ast::{Op, Program};

/// A step in an execution plan describing how a command should be executed.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ExecutionStep {
    /// Execute a command sequentially (normal execution, or first in a chain).
    Sequential { command: String },
    /// Execute a command and on error, skip to the next ErrorRecovery marker.
    ErrorRecovery { command: String },
    /// Execute a command with piped input from the previous command.
    Pipe { command: String },
    /// Execute a command with piped input including stderr from the previous.
    PipeErr { command: String },
    /// Marker to accumulate optional command outputs into a context object.
    ContextAccumulation { commands: Vec<String> },
}

/// An execution plan for a parsed [`Program`].
///
/// Converts a program's pipeline structure into a flat sequence of steps
/// describing how commands should be orchestrated:
/// - `&&` creates sequential steps with error gates
/// - `||` signals error recovery paths
/// - `|` and `|&` create pipe signals
/// - Optional commands (`?` suffix) are grouped into context accumulation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionPlan {
    /// Sequence of execution steps.
    pub steps: Vec<ExecutionStep>,
    /// Metadata: whether any step involves optional commands.
    pub has_optional: bool,
    /// Metadata: whether any step involves error recovery.
    pub has_error_recovery: bool,
}

impl ExecutionPlan {
    /// Create an empty execution plan.
    pub fn new() -> Self {
        Self {
            steps: Vec::new(),
            has_optional: false,
            has_error_recovery: false,
        }
    }

    /// Convert to JSON representation (for serialization/debugging).
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string_pretty(self)
    }

    /// Parse from JSON representation.
    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
        serde_json::from_str(json)
    }
}

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

/// Convert a parsed [`Program`] into an [`ExecutionPlan`].
///
/// Maps the program's AST structure to a flat sequence of execution steps:
/// - Pipelines connected by `&&` or `||` operators become sequential steps with gates
/// - Commands within a pipeline connected by `|` or `|&` become pipe steps
/// - Optional commands (trailing `?`) are grouped into context accumulation blocks
pub fn program_to_plan(program: &Program) -> ExecutionPlan {
    let mut plan = ExecutionPlan::new();
    let mut has_error_recovery = false;

    for (idx, pipeline) in program.pipelines.iter().enumerate() {
        // The `||` operator is stored on the *preceding* pipeline.
        // Check the previous pipeline's operator to detect error recovery.
        let prev_op = if idx > 0 {
            program.pipelines[idx - 1].operator.as_ref()
        } else {
            None
        };

        if let Some(Op::Or) = prev_op {
            has_error_recovery = true;
        }

        // Process commands within this pipeline.
        let mut optional_commands = Vec::new();

        for (cmd_idx, cmd) in pipeline.commands.iter().enumerate() {
            if cmd.optional {
                // Accumulate optional command names.
                optional_commands.push(cmd.name.clone());
                plan.has_optional = true;
            } else {
                // Flush any accumulated optional commands first.
                if !optional_commands.is_empty() {
                    plan.steps.push(ExecutionStep::ContextAccumulation {
                        commands: optional_commands.clone(),
                    });
                    optional_commands.clear();
                }

                // Determine the step type based on piping and operators.
                let step = if cmd_idx == 0 && idx == 0 {
                    // First command in the program.
                    ExecutionStep::Sequential {
                        command: cmd.name.clone(),
                    }
                } else if let Some(Op::Or) = prev_op {
                    // Part of an error recovery block (previous pipeline used `||`).
                    ExecutionStep::ErrorRecovery {
                        command: cmd.name.clone(),
                    }
                } else if cmd_idx > 0 {
                    // Piped from a previous command in the same pipeline.
                    match &pipeline.commands[cmd_idx - 1].pipe {
                        Some(Op::PipeErr) => ExecutionStep::PipeErr {
                            command: cmd.name.clone(),
                        },
                        Some(Op::Pipe) | Some(Op::And) | None => ExecutionStep::Pipe {
                            command: cmd.name.clone(),
                        },
                        _ => ExecutionStep::Sequential {
                            command: cmd.name.clone(),
                        },
                    }
                } else {
                    // Non-first command in pipeline without explicit pipe info.
                    ExecutionStep::Sequential {
                        command: cmd.name.clone(),
                    }
                };

                plan.steps.push(step);
            }
        }

        // Flush any remaining optional commands at the end of the pipeline.
        if !optional_commands.is_empty() {
            plan.steps.push(ExecutionStep::ContextAccumulation {
                commands: optional_commands,
            });
        }
    }

    plan.has_error_recovery = has_error_recovery;
    plan
}

#[cfg(test)]
mod tests {
    use super::*;
    use slash_lang::parser::parse;

    #[test]
    fn plan_simple_sequential() {
        let prog = parse("/echo(hello) && /echo(world)").unwrap();
        let plan = program_to_plan(&prog);
        assert_eq!(plan.steps.len(), 2);
        assert!(!plan.has_optional);
        assert!(!plan.has_error_recovery);
    }

    #[test]
    fn plan_error_recovery() {
        let prog = parse("/false || /echo(fallback)").unwrap();
        let plan = program_to_plan(&prog);
        assert!(plan.has_error_recovery);
        // Should contain both commands.
        assert!(plan
            .steps
            .iter()
            .any(|s| matches!(s, ExecutionStep::ErrorRecovery { .. })));
    }

    #[test]
    fn plan_optional_commands() {
        let prog = parse("/cmd? && /echo(done)").unwrap();
        let plan = program_to_plan(&prog);
        assert!(plan.has_optional);
        // Should have a ContextAccumulation step.
        assert!(plan
            .steps
            .iter()
            .any(|s| matches!(s, ExecutionStep::ContextAccumulation { .. })));
    }

    #[test]
    fn plan_to_json_roundtrip() {
        let prog = parse("/echo(test)").unwrap();
        let plan = program_to_plan(&prog);
        let json = plan.to_json().unwrap();
        let plan2 = ExecutionPlan::from_json(&json).unwrap();
        assert_eq!(plan.steps.len(), plan2.steps.len());
        assert_eq!(plan.has_optional, plan2.has_optional);
    }
}