Skip to main content

mur_common/
pipeline.rs

1//! Pipeline expression AST and parser.
2//!
3//! Supports three composition operators with precedence (high → low):
4//! - `|`  Pipe — pass output of left as input to right
5//! - `&&` Sequential — run right only if left succeeds (exit code 0)
6//! - `,`  Parallel — run all branches concurrently
7
8use serde::{Deserialize, Serialize};
9
10/// Pipeline expression AST.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub enum PipelineExpr {
13    /// A single workflow reference (by name or ID).
14    Single(String),
15    /// `w1 | w2` — pipe output of left into right.
16    Pipe(Box<PipelineExpr>, Box<PipelineExpr>),
17    /// `w1 && w2` — run right only if left succeeds.
18    Sequential(Box<PipelineExpr>, Box<PipelineExpr>),
19    /// `w1, w2, w3` — run all concurrently.
20    Parallel(Vec<PipelineExpr>),
21}
22
23/// Status of a pipeline stage execution.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum PipelineStatus {
27    Success,
28    Failed,
29    Skipped,
30}
31
32/// Output produced by a single pipeline stage.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PipelineOutput {
35    pub workflow_id: String,
36    pub status: PipelineStatus,
37    pub output_text: Option<String>,
38    pub output_data: Option<serde_json::Value>,
39    pub exit_code: i32,
40    pub duration_ms: u64,
41}
42
43/// Replace `{{input}}` placeholders in a template string with the given value.
44/// If `input` is `None`, replaces with empty string.
45///
46/// The replacement is shell-escaped to prevent injection when the result
47/// is passed to `sh -c`.
48pub fn inject_input(template: &str, input: Option<&str>) -> String {
49    let replacement = input.unwrap_or("");
50    let escaped = shell_escape::escape(replacement.into());
51    template.replace("{{input}}", &escaped)
52}
53
54/// Returns `true` if the input string contains pipeline operators.
55pub fn has_pipeline_syntax(input: &str) -> bool {
56    // Check for `|` (but not inside workflow names), `&&`, or `,`
57    input.contains(" | ") || input.contains(" && ") || input.contains(", ") || input.contains(',')
58}
59
60/// Parse a pipeline expression string into an AST.
61///
62/// Operator precedence (high → low): `|` > `&&` > `,`
63///
64/// Parsing strategy: split by lowest-precedence operator first (`,`),
65/// then by `&&`, then by `|`.
66pub fn parse_pipeline_expr(input: &str) -> Result<PipelineExpr, PipelineParseError> {
67    let input = input.trim();
68    if input.is_empty() {
69        return Err(PipelineParseError::EmptyInput);
70    }
71    parse_parallel(input)
72}
73
74/// Errors from pipeline expression parsing.
75#[derive(Debug, Clone, PartialEq, thiserror::Error)]
76pub enum PipelineParseError {
77    #[error("empty pipeline expression")]
78    EmptyInput,
79    #[error("empty segment in pipeline expression")]
80    EmptySegment,
81}
82
83/// Parse comma-separated parallel branches (lowest precedence).
84fn parse_parallel(input: &str) -> Result<PipelineExpr, PipelineParseError> {
85    let parts: Vec<&str> = input.split(',').collect();
86    if parts.len() == 1 {
87        return parse_sequential(input);
88    }
89
90    let mut exprs = Vec::with_capacity(parts.len());
91    for part in parts {
92        let part = part.trim();
93        if part.is_empty() {
94            return Err(PipelineParseError::EmptySegment);
95        }
96        exprs.push(parse_sequential(part)?);
97    }
98
99    if exprs.len() == 1 {
100        Ok(exprs.into_iter().next().unwrap())
101    } else {
102        Ok(PipelineExpr::Parallel(exprs))
103    }
104}
105
106/// Parse `&&`-separated sequential chains (middle precedence).
107fn parse_sequential(input: &str) -> Result<PipelineExpr, PipelineParseError> {
108    let parts: Vec<&str> = input.split("&&").collect();
109    if parts.len() == 1 {
110        return parse_pipe(input);
111    }
112
113    let mut iter = parts.into_iter();
114    let first = iter.next().unwrap().trim();
115    if first.is_empty() {
116        return Err(PipelineParseError::EmptySegment);
117    }
118    let mut expr = parse_pipe(first)?;
119
120    for part in iter {
121        let part = part.trim();
122        if part.is_empty() {
123            return Err(PipelineParseError::EmptySegment);
124        }
125        let right = parse_pipe(part)?;
126        expr = PipelineExpr::Sequential(Box::new(expr), Box::new(right));
127    }
128
129    Ok(expr)
130}
131
132/// Parse `|`-separated pipe chains (highest precedence).
133fn parse_pipe(input: &str) -> Result<PipelineExpr, PipelineParseError> {
134    let parts: Vec<&str> = input.split('|').collect();
135    if parts.len() == 1 {
136        return parse_single(input);
137    }
138
139    let mut iter = parts.into_iter();
140    let first = iter.next().unwrap().trim();
141    if first.is_empty() {
142        return Err(PipelineParseError::EmptySegment);
143    }
144    let mut expr = parse_single(first)?;
145
146    for part in iter {
147        let part = part.trim();
148        if part.is_empty() {
149            return Err(PipelineParseError::EmptySegment);
150        }
151        let right = parse_single(part)?;
152        expr = PipelineExpr::Pipe(Box::new(expr), Box::new(right));
153    }
154
155    Ok(expr)
156}
157
158/// Parse a single workflow identifier.
159fn parse_single(input: &str) -> Result<PipelineExpr, PipelineParseError> {
160    let input = input.trim();
161    if input.is_empty() {
162        return Err(PipelineParseError::EmptySegment);
163    }
164    Ok(PipelineExpr::Single(input.to_string()))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_single() {
173        assert_eq!(
174            parse_pipeline_expr("w1").unwrap(),
175            PipelineExpr::Single("w1".into())
176        );
177    }
178
179    #[test]
180    fn test_pipe() {
181        assert_eq!(
182            parse_pipeline_expr("w1 | w2").unwrap(),
183            PipelineExpr::Pipe(
184                Box::new(PipelineExpr::Single("w1".into())),
185                Box::new(PipelineExpr::Single("w2".into())),
186            )
187        );
188    }
189
190    #[test]
191    fn test_sequential() {
192        assert_eq!(
193            parse_pipeline_expr("w1 && w2").unwrap(),
194            PipelineExpr::Sequential(
195                Box::new(PipelineExpr::Single("w1".into())),
196                Box::new(PipelineExpr::Single("w2".into())),
197            )
198        );
199    }
200
201    #[test]
202    fn test_parallel() {
203        assert_eq!(
204            parse_pipeline_expr("w1, w2").unwrap(),
205            PipelineExpr::Parallel(vec![
206                PipelineExpr::Single("w1".into()),
207                PipelineExpr::Single("w2".into()),
208            ])
209        );
210    }
211
212    #[test]
213    fn test_pipe_then_sequential() {
214        // "w1 | w2 && w3" → Sequential(Pipe(w1, w2), w3)
215        assert_eq!(
216            parse_pipeline_expr("w1 | w2 && w3").unwrap(),
217            PipelineExpr::Sequential(
218                Box::new(PipelineExpr::Pipe(
219                    Box::new(PipelineExpr::Single("w1".into())),
220                    Box::new(PipelineExpr::Single("w2".into())),
221                )),
222                Box::new(PipelineExpr::Single("w3".into())),
223            )
224        );
225    }
226
227    #[test]
228    fn test_full_combo() {
229        // "w1 | w2 && w3, w4" →
230        // Parallel([
231        //   Sequential(Pipe(w1, w2), w3),
232        //   w4,
233        // ])
234        assert_eq!(
235            parse_pipeline_expr("w1 | w2 && w3, w4").unwrap(),
236            PipelineExpr::Parallel(vec![
237                PipelineExpr::Sequential(
238                    Box::new(PipelineExpr::Pipe(
239                        Box::new(PipelineExpr::Single("w1".into())),
240                        Box::new(PipelineExpr::Single("w2".into())),
241                    )),
242                    Box::new(PipelineExpr::Single("w3".into())),
243                ),
244                PipelineExpr::Single("w4".into()),
245            ])
246        );
247    }
248
249    #[test]
250    fn test_empty_input() {
251        assert_eq!(parse_pipeline_expr(""), Err(PipelineParseError::EmptyInput));
252    }
253
254    #[test]
255    fn test_triple_pipe() {
256        // "w1 | w2 | w3" → Pipe(Pipe(w1, w2), w3)
257        assert_eq!(
258            parse_pipeline_expr("w1 | w2 | w3").unwrap(),
259            PipelineExpr::Pipe(
260                Box::new(PipelineExpr::Pipe(
261                    Box::new(PipelineExpr::Single("w1".into())),
262                    Box::new(PipelineExpr::Single("w2".into())),
263                )),
264                Box::new(PipelineExpr::Single("w3".into())),
265            )
266        );
267    }
268
269    #[test]
270    fn test_has_pipeline_syntax() {
271        assert!(!has_pipeline_syntax("simple-workflow"));
272        assert!(has_pipeline_syntax("w1 | w2"));
273        assert!(has_pipeline_syntax("w1 && w2"));
274        assert!(has_pipeline_syntax("w1, w2"));
275    }
276
277    #[test]
278    fn test_inject_input_with_value() {
279        // "hello world" contains a space, so shell-escape wraps in quotes
280        let result = inject_input("Analyze this: {{input}}\nDone.", Some("hello world"));
281        // Unix uses single quotes, Windows uses double quotes
282        assert!(
283            result == "Analyze this: 'hello world'\nDone."
284                || result == "Analyze this: \"hello world\"\nDone."
285        );
286    }
287
288    #[test]
289    fn test_inject_input_none() {
290        let result = inject_input("Prefix {{input}} suffix", None);
291        assert!(result == "Prefix '' suffix" || result == "Prefix \"\" suffix");
292    }
293
294    #[test]
295    fn test_inject_input_no_placeholder() {
296        let result = inject_input("no placeholder here", Some("data"));
297        assert_eq!(result, "no placeholder here");
298    }
299
300    #[test]
301    fn test_inject_input_multiple() {
302        let result = inject_input("{{input}} and {{input}}", Some("x"));
303        assert_eq!(result, "x and x");
304    }
305
306    #[test]
307    fn test_inject_input_shell_injection_prevented() {
308        let result = inject_input("echo {{input}}", Some("hello; rm -rf /"));
309        // The malicious input should be escaped, not executed literally
310        // Unix: 'hello; rm -rf /', Windows: "hello; rm -rf /"
311        assert!(result.contains("'hello; rm -rf /'") || result.contains("\"hello; rm -rf /\""));
312    }
313}