oxdock_core/
ast.rs

1use anyhow::{Result, bail};
2use std::collections::HashMap;
3
4#[derive(Copy, Clone, Debug, Eq, PartialEq)]
5pub enum Command {
6    Workdir,
7    Workspace,
8    Env,
9    Echo,
10    Run,
11    RunBg,
12    Copy,
13    Capture,
14    CopyGit,
15    Symlink,
16    Mkdir,
17    Ls,
18    Cwd,
19    Cat,
20    Write,
21    Exit,
22}
23
24pub const COMMANDS: &[Command] = &[
25    Command::Workdir,
26    Command::Workspace,
27    Command::Env,
28    Command::Echo,
29    Command::Run,
30    Command::RunBg,
31    Command::Copy,
32    Command::Capture,
33    Command::CopyGit,
34    Command::Symlink,
35    Command::Mkdir,
36    Command::Ls,
37    Command::Cwd,
38    Command::Cat,
39    Command::Write,
40    Command::Exit,
41];
42
43fn platform_matches(target: PlatformGuard) -> bool {
44    #[allow(clippy::disallowed_macros)]
45    match target {
46        PlatformGuard::Unix => cfg!(unix),
47        PlatformGuard::Windows => cfg!(windows),
48        PlatformGuard::Macos => cfg!(target_os = "macos"),
49        PlatformGuard::Linux => cfg!(target_os = "linux"),
50    }
51}
52
53fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
54    match guard {
55        Guard::Platform { target, invert } => {
56            let res = platform_matches(*target);
57            if *invert { !res } else { res }
58        }
59        Guard::EnvExists { key, invert } => {
60            let res = script_envs
61                .get(key)
62                .cloned()
63                .or_else(|| std::env::var(key).ok())
64                .map(|v| !v.is_empty())
65                .unwrap_or(false);
66            if *invert { !res } else { res }
67        }
68        Guard::EnvEquals { key, value, invert } => {
69            let res = script_envs
70                .get(key)
71                .cloned()
72                .or_else(|| std::env::var(key).ok())
73                .map(|v| v == *value)
74                .unwrap_or(false);
75            if *invert { !res } else { res }
76        }
77    }
78}
79
80fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
81    group.iter().all(|g| guard_allows(g, script_envs))
82}
83
84pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
85    // No guards means allow.
86    if groups.is_empty() {
87        return true;
88    }
89    groups.iter().any(|g| guard_group_allows(g, script_envs))
90}
91
92fn parse_guard(raw: &str, line_no: usize) -> Result<Guard> {
93    let mut text = raw.trim();
94    let mut invert_prefix = false;
95    if let Some(rest) = text.strip_prefix('!') {
96        invert_prefix = true;
97        text = rest.trim();
98    }
99
100    if let Some(after) = text.strip_prefix("platform") {
101        let after = after.trim_start();
102        if let Some(rest) = after.strip_prefix(':').or_else(|| after.strip_prefix('=')) {
103            let tag = rest.trim().to_ascii_lowercase();
104            let target = match tag.as_str() {
105                "unix" => PlatformGuard::Unix,
106                "windows" => PlatformGuard::Windows,
107                "mac" | "macos" => PlatformGuard::Macos,
108                "linux" => PlatformGuard::Linux,
109                _ => bail!("line {}: unknown platform '{}'", line_no, rest),
110            };
111            return Ok(Guard::Platform {
112                target,
113                invert: invert_prefix,
114            });
115        }
116    }
117
118    if let Some(rest) = text.strip_prefix("env:") {
119        let rest = rest.trim();
120        if let Some(pos) = rest.find("!=") {
121            let key = rest[..pos].trim();
122            let value = rest[pos + 2..].trim();
123            if key.is_empty() || value.is_empty() {
124                bail!("line {}: guard env: requires key and value", line_no);
125            }
126            return Ok(Guard::EnvEquals {
127                key: key.to_string(),
128                value: value.to_string(),
129                invert: true,
130            });
131        }
132        if let Some(pos) = rest.find('=') {
133            let key = rest[..pos].trim();
134            let value = rest[pos + 1..].trim();
135            if key.is_empty() || value.is_empty() {
136                bail!("line {}: guard env: requires key and value", line_no);
137            }
138            return Ok(Guard::EnvEquals {
139                key: key.to_string(),
140                value: value.to_string(),
141                invert: invert_prefix,
142            });
143        }
144        if rest.is_empty() {
145            bail!("line {}: guard env: requires a variable name", line_no);
146        }
147        return Ok(Guard::EnvExists {
148            key: rest.to_string(),
149            invert: invert_prefix,
150        });
151    }
152
153    let tag = text.to_ascii_lowercase();
154    let target = match tag.as_str() {
155        "unix" => PlatformGuard::Unix,
156        "windows" => PlatformGuard::Windows,
157        "mac" | "macos" => PlatformGuard::Macos,
158        "linux" => PlatformGuard::Linux,
159        _ => bail!("line {}: unknown guard '{}'", line_no, raw),
160    };
161    Ok(Guard::Platform {
162        target,
163        invert: invert_prefix,
164    })
165}
166
167impl Command {
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Command::Workdir => "WORKDIR",
171            Command::Workspace => "WORKSPACE",
172            Command::Env => "ENV",
173            Command::Echo => "ECHO",
174            Command::Run => "RUN",
175            Command::RunBg => "RUN_BG",
176            Command::Copy => "COPY",
177            Command::Capture => "CAPTURE",
178            Command::CopyGit => "COPY_GIT",
179            Command::Symlink => "SYMLINK",
180            Command::Mkdir => "MKDIR",
181            Command::Ls => "LS",
182            Command::Cwd => "CWD",
183            Command::Cat => "CAT",
184            Command::Write => "WRITE",
185            Command::Exit => "EXIT",
186        }
187    }
188
189    pub fn parse(op: &str) -> Option<Self> {
190        COMMANDS.iter().copied().find(|c| c.as_str() == op)
191    }
192}
193
194#[derive(Copy, Clone, Debug, Eq, PartialEq)]
195pub enum PlatformGuard {
196    Unix,
197    Windows,
198    Macos,
199    Linux,
200}
201
202#[derive(Debug, Clone, Eq, PartialEq)]
203pub enum Guard {
204    Platform {
205        target: PlatformGuard,
206        invert: bool,
207    },
208    EnvExists {
209        key: String,
210        invert: bool,
211    },
212    EnvEquals {
213        key: String,
214        value: String,
215        invert: bool,
216    },
217}
218
219#[derive(Debug, Clone, Eq, PartialEq)]
220pub enum StepKind {
221    Workdir(String),
222    Workspace(WorkspaceTarget),
223    Env {
224        key: String,
225        value: String,
226    },
227    Run(String),
228    Echo(String),
229    RunBg(String),
230    Copy {
231        from: String,
232        to: String,
233    },
234    Symlink {
235        from: String,
236        to: String,
237    },
238    Mkdir(String),
239    Ls(Option<String>),
240    Cwd,
241    Cat(String),
242    Write {
243        path: String,
244        contents: String,
245    },
246    Capture {
247        path: String,
248        cmd: String,
249    },
250    CopyGit {
251        rev: String,
252        from: String,
253        to: String,
254    },
255    Exit(i32),
256}
257
258#[derive(Debug, Clone, Eq, PartialEq)]
259pub struct Step {
260    pub guards: Vec<Vec<Guard>>,
261    pub kind: StepKind,
262}
263
264#[derive(Debug, Clone, Eq, PartialEq)]
265pub enum WorkspaceTarget {
266    Snapshot,
267    Local,
268}
269
270pub fn parse_script(input: &str) -> Result<Vec<Step>> {
271    use std::collections::VecDeque;
272    let mut steps = Vec::new();
273    let mut pending_guards: Vec<Vec<Guard>> = Vec::new();
274
275    // Use a queue so we can push back semicolon tail segments to be parsed
276    // as separate commands while preserving their original logical order.
277    let mut queue: VecDeque<(usize, String)> = VecDeque::new();
278    for (i, raw) in input.lines().enumerate() {
279        let raw = raw.trim();
280        if raw.is_empty() || raw.starts_with('#') {
281            continue;
282        }
283        queue.push_back((i + 1, raw.to_string()));
284    }
285
286    while let Some((line_no, rawline)) = queue.pop_front() {
287        let line = rawline.as_str();
288
289        // If a line begins with a guard block, parse it. If the guard block
290        // is followed by a command on the same line, apply guards (plus any
291        // previously-pending guards) to that command. If the guard block is
292        // on its own line (no command after `]`), stash the guards to apply
293        // to the next non-empty command line.
294        let (groups, remainder_opt) = if let Some(rest) = line.strip_prefix('[') {
295            let end = rest
296                .find(']')
297                .ok_or_else(|| anyhow::anyhow!("line {}: guard must close with ]", line_no))?;
298            let guards_raw = &rest[..end];
299            let after = rest[end + 1..].trim();
300            // Parse alternatives separated by `|`, each alternative is a comma-separated AND group.
301            let mut parsed_groups: Vec<Vec<Guard>> = Vec::new();
302            for alt in guards_raw.split('|') {
303                let mut group: Vec<Guard> = Vec::new();
304                for g in alt.split(',') {
305                    let parsed = parse_guard(g, line_no)?;
306                    group.push(parsed);
307                }
308                parsed_groups.push(group);
309            }
310            if after.is_empty() {
311                // Guard-only line: combine with pending_guards by ANDing groups (cartesian product).
312                if pending_guards.is_empty() {
313                    pending_guards = parsed_groups;
314                } else {
315                    let mut new_pending: Vec<Vec<Guard>> = Vec::new();
316                    for p in pending_guards.iter() {
317                        for q in parsed_groups.iter() {
318                            let mut merged = p.clone();
319                            merged.extend(q.clone());
320                            new_pending.push(merged);
321                        }
322                    }
323                    pending_guards = new_pending;
324                }
325                continue;
326            }
327            (parsed_groups, Some(after.to_string()))
328        } else {
329            (Vec::new(), Some(line.to_string()))
330        };
331
332        // Combine any pending guard groups (from previous guard-only lines)
333        // with any groups parsed on this line. Combination is an AND between
334        // groups (cartesian product), producing OR-of-AND groups for the
335        // resulting step. If there are no inline groups, use pending groups.
336        let mut all_groups: Vec<Vec<Guard>> = Vec::new();
337        if groups.is_empty() {
338            // No inline groups: attach pending groups (if any) to this step.
339            all_groups = pending_guards.clone();
340        } else if pending_guards.is_empty() {
341            all_groups = groups.clone();
342        } else {
343            for p in pending_guards.iter() {
344                for q in groups.iter() {
345                    let mut merged = p.clone();
346                    merged.extend(q.clone());
347                    all_groups.push(merged);
348                }
349            }
350        }
351        pending_guards.clear();
352        let mut remainder = remainder_opt.unwrap();
353
354        // Parse op and rest from the current remainder.  Create owned
355        // strings to avoid borrowing `remainder` while we may need to
356        // reassign it below.
357        let mut parts = remainder.splitn(2, ' ');
358        let op_owned = parts.next().unwrap().trim().to_string();
359        let rest_owned = parts
360            .next()
361            .map(|s| s.trim().to_string())
362            .unwrap_or_default();
363        let cmd = Command::parse(op_owned.as_str()).ok_or_else(|| {
364            anyhow::anyhow!("line {}: unknown instruction '{}'", line_no, op_owned)
365        })?;
366
367        // If command is not RUN or RUN_BG and the `rest` contains a ';',
368        // split it so we only consume up to the first semicolon for this
369        // command. Push the tail back onto the queue to be parsed next.
370        if cmd != Command::Run && cmd != Command::RunBg {
371            if let Some(idx_sc) = rest_owned.find(';') {
372                let first = rest_owned[..idx_sc].trim().to_string();
373                let tail = rest_owned[idx_sc + 1..].trim();
374                // replace remainder with the first segment for current handling
375                remainder = first;
376                // push the tail as a new logical line onto the front of the queue
377                if !tail.is_empty() {
378                    queue.push_front((line_no, tail.to_string()));
379                }
380            } else {
381                remainder = rest_owned.to_string();
382            }
383        } else {
384            // RUN/RUN_BG keep the entire rest verbatim
385            remainder = rest_owned.to_string();
386        }
387
388        let kind = match cmd {
389            Command::Workdir => {
390                if remainder.is_empty() {
391                    bail!("line {}: WORKDIR requires a path", line_no);
392                }
393                StepKind::Workdir(remainder.to_string())
394            }
395            Command::Workspace => {
396                let target = match remainder.as_str() {
397                    "SNAPSHOT" | "snapshot" => WorkspaceTarget::Snapshot,
398                    "LOCAL" | "local" => WorkspaceTarget::Local,
399                    _ => bail!("line {}: WORKSPACE requires LOCAL or SNAPSHOT", line_no),
400                };
401                StepKind::Workspace(target)
402            }
403            Command::Env => {
404                let mut parts = remainder.splitn(2, '=');
405                let key = parts
406                    .next()
407                    .map(str::trim)
408                    .filter(|s| !s.is_empty())
409                    .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
410                let value = parts
411                    .next()
412                    .map(str::to_string)
413                    .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
414                StepKind::Env {
415                    key: key.to_string(),
416                    value,
417                }
418            }
419            Command::Echo => {
420                if remainder.is_empty() {
421                    bail!("line {}: ECHO requires a message", line_no);
422                }
423                StepKind::Echo(remainder.to_string())
424            }
425            Command::Run => {
426                if remainder.is_empty() {
427                    bail!("line {}: RUN requires a command", line_no);
428                }
429                StepKind::Run(remainder.to_string())
430            }
431            Command::RunBg => {
432                if remainder.is_empty() {
433                    bail!("line {}: RUN_BG requires a command", line_no);
434                }
435                StepKind::RunBg(remainder.to_string())
436            }
437            Command::Copy => {
438                let mut p = remainder.split_whitespace();
439                let from = p.next().ok_or_else(|| {
440                    anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
441                })?;
442                let to = p.next().ok_or_else(|| {
443                    anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
444                })?;
445                StepKind::Copy {
446                    from: from.to_string(),
447                    to: to.to_string(),
448                }
449            }
450            Command::Capture => {
451                let mut p = remainder.splitn(2, ' ');
452                let path = p
453                    .next()
454                    .map(str::trim)
455                    .filter(|s| !s.is_empty())
456                    .ok_or_else(|| {
457                        anyhow::anyhow!("line {}: CAPTURE requires <path> <command>", line_no)
458                    })?;
459                let cmd = p.next().map(str::to_string).ok_or_else(|| {
460                    anyhow::anyhow!("line {}: CAPTURE requires <path> <command>", line_no)
461                })?;
462                StepKind::Capture {
463                    path: path.to_string(),
464                    cmd,
465                }
466            }
467            Command::CopyGit => {
468                let mut p = remainder.split_whitespace();
469                let rev = p.next().ok_or_else(|| {
470                    anyhow::anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no)
471                })?;
472                let from = p.next().ok_or_else(|| {
473                    anyhow::anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no)
474                })?;
475                let to = p.next().ok_or_else(|| {
476                    anyhow::anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no)
477                })?;
478                StepKind::CopyGit {
479                    rev: rev.to_string(),
480                    from: from.to_string(),
481                    to: to.to_string(),
482                }
483            }
484            Command::Symlink => {
485                let mut p = remainder.split_whitespace();
486                let from = p.next().ok_or_else(|| {
487                    anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
488                })?;
489                let to = p.next().ok_or_else(|| {
490                    anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
491                })?;
492                StepKind::Symlink {
493                    from: from.to_string(),
494                    to: to.to_string(),
495                }
496            }
497            Command::Mkdir => {
498                if remainder.is_empty() {
499                    bail!("line {}: MKDIR requires a path", line_no);
500                }
501                StepKind::Mkdir(remainder.to_string())
502            }
503            Command::Ls => {
504                let path = remainder
505                    .split_whitespace()
506                    .next()
507                    .filter(|s| !s.is_empty())
508                    .map(|s| s.to_string());
509                StepKind::Ls(path)
510            }
511            Command::Cwd => StepKind::Cwd,
512            Command::Write => {
513                let mut p = remainder.splitn(2, ' ');
514                let path = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
515                    anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
516                })?;
517                let contents = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
518                    anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
519                })?;
520                StepKind::Write {
521                    path: path.to_string(),
522                    contents: contents.to_string(),
523                }
524            }
525            Command::Cat => {
526                let path = remainder
527                    .split_whitespace()
528                    .next()
529                    .filter(|s| !s.is_empty())
530                    .ok_or_else(|| anyhow::anyhow!("line {}: CAT requires <path>", line_no))?;
531                StepKind::Cat(path.to_string())
532            }
533            Command::Exit => {
534                if remainder.is_empty() {
535                    bail!("line {}: EXIT requires a code", line_no);
536                }
537                let code: i32 = remainder.parse().map_err(|_| {
538                    anyhow::anyhow!("line {}: EXIT code must be an integer", line_no)
539                })?;
540                StepKind::Exit(code)
541            }
542        };
543
544        steps.push(Step {
545            guards: all_groups,
546            kind,
547        });
548    }
549    Ok(steps)
550}
551
552#[cfg(test)]
553mod tests {
554    use super::{Guard, guard_allows, guards_allow_any, parse_script};
555    use std::collections::HashMap;
556
557    #[test]
558    fn commands_are_case_sensitive() {
559        for bad in ["run echo hi", "Run echo hi", "rUn echo hi", "write foo bar"] {
560            let err = parse_script(bad).expect_err("mixed/lowercase commands must fail");
561            assert!(
562                err.to_string().contains("unknown instruction"),
563                "unexpected error for '{bad}': {err}"
564            );
565        }
566    }
567
568    #[test]
569    fn env_equals_guard_respects_inversion() {
570        let mut envs = HashMap::new();
571        envs.insert("FOO".to_string(), "bar".to_string());
572        let guard = Guard::EnvEquals {
573            key: "FOO".into(),
574            value: "bar".into(),
575            invert: false,
576        };
577        assert!(guard_allows(&guard, &envs));
578
579        let inverted = Guard::EnvEquals {
580            key: "FOO".into(),
581            value: "bar".into(),
582            invert: true,
583        };
584        assert!(!guard_allows(&inverted, &envs));
585    }
586
587    #[test]
588    fn guards_allow_any_act_as_or_of_ands() {
589        let mut envs = HashMap::new();
590        envs.insert("MODE".to_string(), "beta".to_string());
591        let groups = vec![
592            vec![Guard::EnvEquals {
593                key: "MODE".into(),
594                value: "alpha".into(),
595                invert: false,
596            }],
597            vec![Guard::EnvEquals {
598                key: "MODE".into(),
599                value: "beta".into(),
600                invert: false,
601            }],
602        ];
603        assert!(guards_allow_any(&groups, &envs));
604    }
605
606    #[test]
607    fn guards_allow_any_falls_back_to_false_when_all_fail() {
608        let envs = HashMap::new();
609        let groups = vec![vec![Guard::EnvExists {
610            key: "MISSING".into(),
611            invert: false,
612        }]];
613        assert!(!guards_allow_any(&groups, &envs));
614    }
615}