Skip to main content

slash_core/
plan.rs

1use serde::{Deserialize, Serialize};
2use slash_lang::parser::ast::{Op, Program};
3
4/// A step in an execution plan describing how a command should be executed.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(tag = "type")]
7pub enum ExecutionStep {
8    /// Execute a command sequentially (normal execution, or first in a chain).
9    Sequential { command: String },
10    /// Execute a command and on error, skip to the next ErrorRecovery marker.
11    ErrorRecovery { command: String },
12    /// Execute a command with piped input from the previous command.
13    Pipe { command: String },
14    /// Execute a command with piped input including stderr from the previous.
15    PipeErr { command: String },
16    /// Marker to accumulate optional command outputs into a context object.
17    ContextAccumulation { commands: Vec<String> },
18}
19
20/// An execution plan for a parsed [`Program`].
21///
22/// Converts a program's pipeline structure into a flat sequence of steps
23/// describing how commands should be orchestrated:
24/// - `&&` creates sequential steps with error gates
25/// - `||` signals error recovery paths
26/// - `|` and `|&` create pipe signals
27/// - Optional commands (`?` suffix) are grouped into context accumulation
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ExecutionPlan {
30    /// Sequence of execution steps.
31    pub steps: Vec<ExecutionStep>,
32    /// Metadata: whether any step involves optional commands.
33    pub has_optional: bool,
34    /// Metadata: whether any step involves error recovery.
35    pub has_error_recovery: bool,
36}
37
38impl ExecutionPlan {
39    /// Create an empty execution plan.
40    pub fn new() -> Self {
41        Self {
42            steps: Vec::new(),
43            has_optional: false,
44            has_error_recovery: false,
45        }
46    }
47
48    /// Convert to JSON representation (for serialization/debugging).
49    pub fn to_json(&self) -> Result<String, serde_json::Error> {
50        serde_json::to_string_pretty(self)
51    }
52
53    /// Parse from JSON representation.
54    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
55        serde_json::from_str(json)
56    }
57}
58
59impl Default for ExecutionPlan {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65/// Convert a parsed [`Program`] into an [`ExecutionPlan`].
66///
67/// Maps the program's AST structure to a flat sequence of execution steps:
68/// - Pipelines connected by `&&` or `||` operators become sequential steps with gates
69/// - Commands within a pipeline connected by `|` or `|&` become pipe steps
70/// - Optional commands (trailing `?`) are grouped into context accumulation blocks
71pub fn program_to_plan(program: &Program) -> ExecutionPlan {
72    let mut plan = ExecutionPlan::new();
73    let mut has_error_recovery = false;
74
75    for (idx, pipeline) in program.pipelines.iter().enumerate() {
76        // The `||` operator is stored on the *preceding* pipeline.
77        // Check the previous pipeline's operator to detect error recovery.
78        let prev_op = if idx > 0 {
79            program.pipelines[idx - 1].operator.as_ref()
80        } else {
81            None
82        };
83
84        if let Some(Op::Or) = prev_op {
85            has_error_recovery = true;
86        }
87
88        // Process commands within this pipeline.
89        let mut optional_commands = Vec::new();
90
91        for (cmd_idx, cmd) in pipeline.commands.iter().enumerate() {
92            if cmd.optional {
93                // Accumulate optional command names.
94                optional_commands.push(cmd.name.clone());
95                plan.has_optional = true;
96            } else {
97                // Flush any accumulated optional commands first.
98                if !optional_commands.is_empty() {
99                    plan.steps.push(ExecutionStep::ContextAccumulation {
100                        commands: optional_commands.clone(),
101                    });
102                    optional_commands.clear();
103                }
104
105                // Determine the step type based on piping and operators.
106                let step = if cmd_idx == 0 && idx == 0 {
107                    // First command in the program.
108                    ExecutionStep::Sequential {
109                        command: cmd.name.clone(),
110                    }
111                } else if let Some(Op::Or) = prev_op {
112                    // Part of an error recovery block (previous pipeline used `||`).
113                    ExecutionStep::ErrorRecovery {
114                        command: cmd.name.clone(),
115                    }
116                } else if cmd_idx > 0 {
117                    // Piped from a previous command in the same pipeline.
118                    match &pipeline.commands[cmd_idx - 1].pipe {
119                        Some(Op::PipeErr) => ExecutionStep::PipeErr {
120                            command: cmd.name.clone(),
121                        },
122                        Some(Op::Pipe) | Some(Op::And) | None => ExecutionStep::Pipe {
123                            command: cmd.name.clone(),
124                        },
125                        _ => ExecutionStep::Sequential {
126                            command: cmd.name.clone(),
127                        },
128                    }
129                } else {
130                    // Non-first command in pipeline without explicit pipe info.
131                    ExecutionStep::Sequential {
132                        command: cmd.name.clone(),
133                    }
134                };
135
136                plan.steps.push(step);
137            }
138        }
139
140        // Flush any remaining optional commands at the end of the pipeline.
141        if !optional_commands.is_empty() {
142            plan.steps.push(ExecutionStep::ContextAccumulation {
143                commands: optional_commands,
144            });
145        }
146    }
147
148    plan.has_error_recovery = has_error_recovery;
149    plan
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use slash_lang::parser::parse;
156
157    #[test]
158    fn plan_simple_sequential() {
159        let prog = parse("/echo(hello) && /echo(world)").unwrap();
160        let plan = program_to_plan(&prog);
161        assert_eq!(plan.steps.len(), 2);
162        assert!(!plan.has_optional);
163        assert!(!plan.has_error_recovery);
164    }
165
166    #[test]
167    fn plan_error_recovery() {
168        let prog = parse("/false || /echo(fallback)").unwrap();
169        let plan = program_to_plan(&prog);
170        assert!(plan.has_error_recovery);
171        // Should contain both commands.
172        assert!(plan
173            .steps
174            .iter()
175            .any(|s| matches!(s, ExecutionStep::ErrorRecovery { .. })));
176    }
177
178    #[test]
179    fn plan_optional_commands() {
180        let prog = parse("/cmd? && /echo(done)").unwrap();
181        let plan = program_to_plan(&prog);
182        assert!(plan.has_optional);
183        // Should have a ContextAccumulation step.
184        assert!(plan
185            .steps
186            .iter()
187            .any(|s| matches!(s, ExecutionStep::ContextAccumulation { .. })));
188    }
189
190    #[test]
191    fn plan_to_json_roundtrip() {
192        let prog = parse("/echo(test)").unwrap();
193        let plan = program_to_plan(&prog);
194        let json = plan.to_json().unwrap();
195        let plan2 = ExecutionPlan::from_json(&json).unwrap();
196        assert_eq!(plan.steps.len(), plan2.steps.len());
197        assert_eq!(plan.has_optional, plan2.has_optional);
198    }
199}