oxdock_parser/
lib.rs

1use anyhow::{Result, anyhow, bail};
2use std::collections::{HashMap, VecDeque};
3
4#[cfg(feature = "token-input")]
5mod macro_input;
6#[cfg(feature = "token-input")]
7pub use macro_input::parse_braced_tokens;
8#[cfg(feature = "token-input")]
9pub use macro_input::{DslMacroInput, ScriptSource, script_from_braced_tokens};
10
11#[derive(Copy, Clone, Debug, Eq, PartialEq)]
12pub enum Command {
13    Workdir,
14    Workspace,
15    Env,
16    Echo,
17    Run,
18    RunBg,
19    Copy,
20    Capture,
21    CopyGit,
22    Symlink,
23    Mkdir,
24    Ls,
25    Cwd,
26    Cat,
27    Write,
28    Exit,
29}
30
31pub const COMMANDS: &[Command] = &[
32    Command::Workdir,
33    Command::Workspace,
34    Command::Env,
35    Command::Echo,
36    Command::Run,
37    Command::RunBg,
38    Command::Copy,
39    Command::Capture,
40    Command::CopyGit,
41    Command::Symlink,
42    Command::Mkdir,
43    Command::Ls,
44    Command::Cwd,
45    Command::Cat,
46    Command::Write,
47    Command::Exit,
48];
49
50fn platform_matches(target: PlatformGuard) -> bool {
51    #[allow(clippy::disallowed_macros)]
52    match target {
53        PlatformGuard::Unix => cfg!(unix),
54        PlatformGuard::Windows => cfg!(windows),
55        PlatformGuard::Macos => cfg!(target_os = "macos"),
56        PlatformGuard::Linux => cfg!(target_os = "linux"),
57    }
58}
59
60fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
61    match guard {
62        Guard::Platform { target, invert } => {
63            let res = platform_matches(*target);
64            if *invert { !res } else { res }
65        }
66        Guard::EnvExists { key, invert } => {
67            let res = script_envs
68                .get(key)
69                .cloned()
70                .or_else(|| std::env::var(key).ok())
71                .map(|v| !v.is_empty())
72                .unwrap_or(false);
73            if *invert { !res } else { res }
74        }
75        Guard::EnvEquals { key, value, invert } => {
76            let res = script_envs
77                .get(key)
78                .cloned()
79                .or_else(|| std::env::var(key).ok())
80                .map(|v| v == *value)
81                .unwrap_or(false);
82            if *invert { !res } else { res }
83        }
84    }
85}
86
87fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
88    group.iter().all(|g| guard_allows(g, script_envs))
89}
90
91pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
92    if groups.is_empty() {
93        return true;
94    }
95    groups.iter().any(|g| guard_group_allows(g, script_envs))
96}
97
98#[derive(Copy, Clone, Debug, Eq, PartialEq)]
99pub enum PlatformGuard {
100    Unix,
101    Windows,
102    Macos,
103    Linux,
104}
105
106#[derive(Debug, Clone, Eq, PartialEq)]
107pub enum Guard {
108    Platform {
109        target: PlatformGuard,
110        invert: bool,
111    },
112    EnvExists {
113        key: String,
114        invert: bool,
115    },
116    EnvEquals {
117        key: String,
118        value: String,
119        invert: bool,
120    },
121}
122
123#[derive(Debug, Clone, Eq, PartialEq)]
124pub enum StepKind {
125    Workdir(String),
126    Workspace(WorkspaceTarget),
127    Env {
128        key: String,
129        value: String,
130    },
131    Run(String),
132    Echo(String),
133    RunBg(String),
134    Copy {
135        from: String,
136        to: String,
137    },
138    Symlink {
139        from: String,
140        to: String,
141    },
142    Mkdir(String),
143    Ls(Option<String>),
144    Cwd,
145    Cat(String),
146    Write {
147        path: String,
148        contents: String,
149    },
150    Capture {
151        path: String,
152        cmd: String,
153    },
154    CopyGit {
155        rev: String,
156        from: String,
157        to: String,
158    },
159    Exit(i32),
160}
161
162#[derive(Debug, Clone, Eq, PartialEq)]
163pub struct Step {
164    pub guards: Vec<Vec<Guard>>,
165    pub kind: StepKind,
166    pub scope_enter: usize,
167    pub scope_exit: usize,
168}
169
170#[derive(Debug, Clone, Eq, PartialEq)]
171pub enum WorkspaceTarget {
172    Snapshot,
173    Local,
174}
175
176#[derive(Clone)]
177struct ScriptLine {
178    line_no: usize,
179    text: String,
180}
181
182fn strip_comments(input: &str) -> Result<String> {
183    let mut output = String::with_capacity(input.len());
184    let mut chars = input.chars().peekable();
185    let mut in_line_comment = false;
186    let mut block_depth = 0usize;
187    let mut in_double_quote = false;
188    let mut in_single_quote = false;
189    let mut double_escape = false;
190    let mut single_escape = false;
191
192    while let Some(ch) = chars.next() {
193        if in_line_comment {
194            if ch == '\n' {
195                in_line_comment = false;
196                output.push(ch);
197            }
198            continue;
199        }
200
201        if block_depth > 0 {
202            if ch == '/' && matches!(chars.peek(), Some('*')) {
203                chars.next();
204                block_depth += 1;
205                continue;
206            }
207            if ch == '*' && matches!(chars.peek(), Some('/')) {
208                chars.next();
209                block_depth -= 1;
210                continue;
211            }
212            if ch == '\n' {
213                output.push('\n');
214            }
215            continue;
216        }
217
218        if !in_double_quote && !in_single_quote && ch == '/' {
219            match chars.peek() {
220                Some('/') => {
221                    chars.next();
222                    in_line_comment = true;
223                    continue;
224                }
225                Some('*') => {
226                    chars.next();
227                    block_depth = 1;
228                    continue;
229                }
230                _ => {}
231            }
232        }
233
234        output.push(ch);
235
236        if in_double_quote {
237            if double_escape {
238                double_escape = false;
239            } else if ch == '\\' {
240                double_escape = true;
241            } else if ch == '"' {
242                in_double_quote = false;
243            }
244            continue;
245        }
246
247        if in_single_quote {
248            if single_escape {
249                single_escape = false;
250            } else if ch == '\\' {
251                single_escape = true;
252            } else if ch == '\'' {
253                in_single_quote = false;
254            }
255            continue;
256        }
257
258        if ch == '"' {
259            in_double_quote = true;
260            double_escape = false;
261        } else if ch == '\'' {
262            in_single_quote = true;
263            single_escape = false;
264        }
265    }
266
267    if block_depth > 0 {
268        bail!("unclosed block comment in DSL script");
269    }
270
271    Ok(output)
272}
273
274fn parse_guard(raw: &str, line_no: usize) -> Result<Guard> {
275    let mut text = raw.trim();
276    let mut invert_prefix = false;
277    if let Some(rest) = text.strip_prefix('!') {
278        invert_prefix = true;
279        text = rest.trim();
280    }
281
282    if let Some(after) = text.strip_prefix("platform") {
283        let after = after.trim_start();
284        if let Some(rest) = after.strip_prefix(':').or_else(|| after.strip_prefix('=')) {
285            let tag = rest.trim().to_ascii_lowercase();
286            let target = match tag.as_str() {
287                "unix" => PlatformGuard::Unix,
288                "windows" => PlatformGuard::Windows,
289                "mac" | "macos" => PlatformGuard::Macos,
290                "linux" => PlatformGuard::Linux,
291                _ => bail!("line {}: unknown platform '{}'", line_no, rest.trim()),
292            };
293            return Ok(Guard::Platform {
294                target,
295                invert: invert_prefix,
296            });
297        }
298    }
299
300    if let Some(rest) = text.strip_prefix("env:") {
301        let rest = rest.trim();
302        if let Some(pos) = rest.find("!=") {
303            let key = rest[..pos].trim();
304            let value = rest[pos + 2..].trim();
305            if key.is_empty() || value.is_empty() {
306                bail!("line {}: guard env: requires key and value", line_no);
307            }
308            return Ok(Guard::EnvEquals {
309                key: key.to_string(),
310                value: value.to_string(),
311                invert: true,
312            });
313        }
314        if let Some(pos) = rest.find('=') {
315            let key = rest[..pos].trim();
316            let value = rest[pos + 1..].trim();
317            if key.is_empty() || value.is_empty() {
318                bail!("line {}: guard env: requires key and value", line_no);
319            }
320            return Ok(Guard::EnvEquals {
321                key: key.to_string(),
322                value: value.to_string(),
323                invert: invert_prefix,
324            });
325        }
326        if rest.is_empty() {
327            bail!("line {}: guard env: requires a variable name", line_no);
328        }
329        return Ok(Guard::EnvExists {
330            key: rest.to_string(),
331            invert: invert_prefix,
332        });
333    }
334
335    let tag = text.to_ascii_lowercase();
336    let target = match tag.as_str() {
337        "unix" => PlatformGuard::Unix,
338        "windows" => PlatformGuard::Windows,
339        "mac" | "macos" => PlatformGuard::Macos,
340        "linux" => PlatformGuard::Linux,
341        _ => bail!("line {}: unknown guard '{}'", line_no, raw),
342    };
343    Ok(Guard::Platform {
344        target,
345        invert: invert_prefix,
346    })
347}
348
349fn parse_guard_groups(block: &str, line_no: usize) -> Result<Vec<Vec<Guard>>> {
350    let mut groups: Vec<Vec<Guard>> = Vec::new();
351    for alt in block.split('|') {
352        let mut group: Vec<Guard> = Vec::new();
353        for entry in alt.split(',') {
354            let trimmed = entry.trim();
355            if trimmed.is_empty() {
356                continue;
357            }
358            group.push(parse_guard(trimmed, line_no)?);
359        }
360        if !group.is_empty() {
361            groups.push(group);
362        }
363    }
364    if groups.is_empty() {
365        bail!(
366            "line {}: guard block must contain at least one guard",
367            line_no
368        );
369    }
370    Ok(groups)
371}
372
373fn combine_guard_groups(a: &[Vec<Guard>], b: &[Vec<Guard>]) -> Vec<Vec<Guard>> {
374    if a.is_empty() {
375        return b.to_vec();
376    }
377    if b.is_empty() {
378        return a.to_vec();
379    }
380    let mut combined = Vec::new();
381    for left in a {
382        for right in b {
383            let mut merged = left.clone();
384            merged.extend(right.clone());
385            combined.push(merged);
386        }
387    }
388    combined
389}
390
391#[derive(Clone)]
392struct ScopeFrame {
393    line_no: usize,
394    had_command: bool,
395}
396
397struct ScriptParser {
398    lines: VecDeque<ScriptLine>,
399    steps: Vec<Step>,
400    guard_stack: Vec<Vec<Vec<Guard>>>,
401    pending_guards: Option<Vec<Vec<Guard>>>,
402    pending_can_open_block: bool,
403    pending_scope_enters: usize,
404    scope_stack: Vec<ScopeFrame>,
405}
406
407impl ScriptParser {
408    fn new(input: &str) -> Result<Self> {
409        let stripped = strip_comments(input)?;
410        let lines = stripped
411            .lines()
412            .enumerate()
413            .map(|(idx, raw)| ScriptLine {
414                line_no: idx + 1,
415                text: raw.to_string(),
416            })
417            .collect::<VecDeque<_>>();
418        Ok(Self {
419            lines,
420            steps: Vec::new(),
421            guard_stack: vec![Vec::new()],
422            pending_guards: None,
423            pending_can_open_block: false,
424            pending_scope_enters: 0,
425            scope_stack: Vec::new(),
426        })
427    }
428
429    fn parse(mut self) -> Result<Vec<Step>> {
430        while let Some(line) = self.next_line() {
431            let trimmed = line.text.trim();
432            if trimmed.is_empty() || trimmed.starts_with('#') {
433                continue;
434            }
435            if trimmed == "{" {
436                self.start_block_from_pending(line.line_no)?;
437                continue;
438            }
439            if trimmed == "}" {
440                self.end_block(line.line_no)?;
441                continue;
442            }
443            if trimmed.starts_with('[') {
444                self.handle_guard_line(line)?;
445                continue;
446            }
447            self.handle_command(line.line_no, line.text, None)?;
448        }
449
450        if self.guard_stack.len() != 1 {
451            bail!("unclosed guard block at end of script");
452        }
453        if let Some(pending) = &self.pending_guards
454            && !pending.is_empty()
455        {
456            bail!("guard declared on final lines without a following command");
457        }
458
459        Ok(self.steps)
460    }
461
462    fn next_line(&mut self) -> Option<ScriptLine> {
463        while let Some(line) = self.lines.pop_front() {
464            if line.text.trim().is_empty() {
465                continue;
466            }
467            if line.text.trim_start().starts_with('#') {
468                continue;
469            }
470            return Some(line);
471        }
472        None
473    }
474
475    fn push_front(&mut self, line_no: usize, text: String) {
476        self.lines.push_front(ScriptLine { line_no, text });
477    }
478
479    fn handle_guard_line(&mut self, first_line: ScriptLine) -> Result<()> {
480        let mut buf = String::new();
481        let remainder: String;
482        let mut current = first_line.text.trim_start().to_string();
483        let mut closing_line = first_line.line_no;
484        if !current.starts_with('[') {
485            bail!("line {}: guard must start with '['", first_line.line_no);
486        }
487        current.remove(0);
488
489        loop {
490            if let Some(idx) = current.find(']') {
491                buf.push_str(&current[..idx]);
492                remainder = current[idx + 1..].to_string();
493                break;
494            } else {
495                buf.push_str(&current);
496                buf.push('\n');
497                let next = self
498                    .lines
499                    .pop_front()
500                    .ok_or_else(|| anyhow!("line {}: guard must close with ']'", closing_line))?;
501                closing_line = next.line_no;
502                current = next.text.trim().to_string();
503            }
504        }
505
506        let groups = parse_guard_groups(&buf, first_line.line_no)?;
507        let remainder_trimmed = {
508            let trimmed = remainder.trim();
509            if trimmed.starts_with('#') {
510                ""
511            } else {
512                trimmed
513            }
514        };
515
516        if let Some(after_brace) = remainder_trimmed.strip_prefix('{') {
517            let after = after_brace.trim_start();
518            if !after.is_empty() {
519                self.push_front(closing_line, after.to_string());
520            }
521            self.start_block(groups, first_line.line_no)?;
522            return Ok(());
523        }
524
525        if remainder_trimmed.is_empty() {
526            self.stash_pending_guard(groups);
527            self.pending_can_open_block = true;
528            return Ok(());
529        }
530
531        self.pending_can_open_block = false;
532        self.handle_command(closing_line, remainder_trimmed.to_string(), Some(groups))
533    }
534
535    fn stash_pending_guard(&mut self, groups: Vec<Vec<Guard>>) {
536        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
537            combine_guard_groups(&existing, &groups)
538        } else {
539            groups
540        });
541    }
542
543    fn start_block_from_pending(&mut self, line_no: usize) -> Result<()> {
544        let guards = self
545            .pending_guards
546            .take()
547            .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
548        if !self.pending_can_open_block {
549            bail!("line {}: '{{' must directly follow a guard", line_no);
550        }
551        self.pending_can_open_block = false;
552        self.start_block(guards, line_no)
553    }
554
555    fn start_block(&mut self, guards: Vec<Vec<Guard>>, line_no: usize) -> Result<()> {
556        let with_pending = if let Some(pending) = self.pending_guards.take() {
557            combine_guard_groups(&pending, &guards)
558        } else {
559            guards
560        };
561        let parent = self.guard_stack.last().cloned().unwrap_or_default();
562        let next = if parent.is_empty() {
563            with_pending
564        } else if with_pending.is_empty() {
565            parent
566        } else {
567            combine_guard_groups(&parent, &with_pending)
568        };
569        self.guard_stack.push(next);
570        self.scope_stack.push(ScopeFrame {
571            line_no,
572            had_command: false,
573        });
574        self.pending_scope_enters += 1;
575        Ok(())
576    }
577
578    fn end_block(&mut self, line_no: usize) -> Result<()> {
579        if self.guard_stack.len() == 1 {
580            bail!("line {}: unexpected '}}'", line_no);
581        }
582        if self.pending_guards.is_some() {
583            bail!(
584                "line {}: guard declared immediately before '}}' without a command",
585                line_no
586            );
587        }
588        let frame = self
589            .scope_stack
590            .last()
591            .cloned()
592            .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
593        if !frame.had_command {
594            bail!(
595                "line {}: guard block starting on line {} must contain at least one command",
596                line_no,
597                frame.line_no
598            );
599        }
600        let step = self
601            .steps
602            .last_mut()
603            .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
604        step.scope_exit += 1;
605        self.scope_stack.pop();
606        self.guard_stack.pop();
607        Ok(())
608    }
609
610    fn guard_context(&mut self, inline: Option<Vec<Vec<Guard>>>) -> Vec<Vec<Guard>> {
611        let mut context = if let Some(top) = self.guard_stack.last() {
612            top.clone()
613        } else {
614            Vec::new()
615        };
616        if let Some(pending) = self.pending_guards.take() {
617            context = if context.is_empty() {
618                pending
619            } else {
620                combine_guard_groups(&context, &pending)
621            };
622            self.pending_can_open_block = false;
623        }
624        if let Some(inline_groups) = inline {
625            context = if context.is_empty() {
626                inline_groups
627            } else {
628                combine_guard_groups(&context, &inline_groups)
629            };
630        }
631        context
632    }
633
634    fn handle_command(
635        &mut self,
636        line_no: usize,
637        text: String,
638        inline_guards: Option<Vec<Vec<Guard>>>,
639    ) -> Result<()> {
640        let trimmed = text.trim();
641        if trimmed.is_empty() {
642            return Ok(());
643        }
644        if let Some(idx) = trimmed.find(';') {
645            let command_token = trimmed[..idx].trim();
646            if !command_token.is_empty() && !command_token.chars().any(|c| c.is_whitespace()) {
647                let after = trimmed[idx + 1..].trim();
648                if !after.is_empty() {
649                    self.push_front(line_no, after.to_string());
650                }
651                return self.handle_command(line_no, command_token.to_string(), inline_guards);
652            }
653        }
654        let (op_str, rest_str) = split_op_and_rest(trimmed);
655        let cmd = Command::parse(op_str)
656            .ok_or_else(|| anyhow!("line {}: unknown instruction '{}'", line_no, op_str))?;
657        let mut remainder = rest_str.to_string();
658        if cmd != Command::Run
659            && cmd != Command::RunBg
660            && let Some(idx) = remainder.find(';')
661        {
662            let first = remainder[..idx].trim().to_string();
663            let tail = remainder[idx + 1..].trim();
664            if !tail.is_empty() {
665                self.push_front(line_no, tail.to_string());
666            }
667            remainder = first;
668        }
669
670        let guards = self.guard_context(inline_guards);
671        let kind = build_step_kind(cmd, &remainder, line_no)?;
672        let scope_enter = self.pending_scope_enters;
673        self.pending_scope_enters = 0;
674        for frame in self.scope_stack.iter_mut() {
675            frame.had_command = true;
676        }
677        self.steps.push(Step {
678            guards,
679            kind,
680            scope_enter,
681            scope_exit: 0,
682        });
683        Ok(())
684    }
685}
686
687fn split_op_and_rest(input: &str) -> (&str, &str) {
688    if let Some((idx, _)) = input.char_indices().find(|(_, ch)| ch.is_whitespace()) {
689        let op = &input[..idx];
690        let rest = input[idx..].trim();
691        (op, rest)
692    } else {
693        (input, "")
694    }
695}
696
697fn build_step_kind(cmd: Command, remainder: &str, line_no: usize) -> Result<StepKind> {
698    let kind = match cmd {
699        Command::Workdir => {
700            if remainder.is_empty() {
701                bail!("line {}: WORKDIR requires a path", line_no);
702            }
703            StepKind::Workdir(remainder.to_string())
704        }
705        Command::Workspace => {
706            let target = match remainder {
707                "SNAPSHOT" | "snapshot" => WorkspaceTarget::Snapshot,
708                "LOCAL" | "local" => WorkspaceTarget::Local,
709                _ => bail!("line {}: WORKSPACE requires LOCAL or SNAPSHOT", line_no),
710            };
711            StepKind::Workspace(target)
712        }
713        Command::Env => {
714            let mut parts = remainder.splitn(2, '=');
715            let key = parts
716                .next()
717                .map(str::trim)
718                .filter(|s| !s.is_empty())
719                .ok_or_else(|| anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
720            let value = parts
721                .next()
722                .map(str::to_string)
723                .ok_or_else(|| anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
724            StepKind::Env {
725                key: key.to_string(),
726                value,
727            }
728        }
729        Command::Echo => {
730            if remainder.is_empty() {
731                bail!("line {}: ECHO requires a message", line_no);
732            }
733            StepKind::Echo(remainder.to_string())
734        }
735        Command::Run => {
736            if remainder.is_empty() {
737                bail!("line {}: RUN requires a command", line_no);
738            }
739            StepKind::Run(remainder.to_string())
740        }
741        Command::RunBg => {
742            if remainder.is_empty() {
743                bail!("line {}: RUN_BG requires a command", line_no);
744            }
745            StepKind::RunBg(remainder.to_string())
746        }
747        Command::Copy => {
748            let mut p = remainder.split_whitespace();
749            let from = p
750                .next()
751                .ok_or_else(|| anyhow!("line {}: COPY requires <from> <to>", line_no))?;
752            let to = p
753                .next()
754                .ok_or_else(|| anyhow!("line {}: COPY requires <from> <to>", line_no))?;
755            StepKind::Copy {
756                from: from.to_string(),
757                to: to.to_string(),
758            }
759        }
760        Command::Capture => {
761            let mut p = remainder.splitn(2, ' ');
762            let path = p
763                .next()
764                .map(str::trim)
765                .filter(|s| !s.is_empty())
766                .ok_or_else(|| anyhow!("line {}: CAPTURE requires <path> <command>", line_no))?;
767            let cmd = p
768                .next()
769                .map(str::to_string)
770                .ok_or_else(|| anyhow!("line {}: CAPTURE requires <path> <command>", line_no))?;
771            StepKind::Capture {
772                path: path.to_string(),
773                cmd,
774            }
775        }
776        Command::CopyGit => {
777            let mut p = remainder.split_whitespace();
778            let rev = p
779                .next()
780                .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
781            let from = p
782                .next()
783                .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
784            let to = p
785                .next()
786                .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
787            StepKind::CopyGit {
788                rev: rev.to_string(),
789                from: from.to_string(),
790                to: to.to_string(),
791            }
792        }
793        Command::Symlink => {
794            let mut p = remainder.split_whitespace();
795            let from = p
796                .next()
797                .ok_or_else(|| anyhow!("line {}: SYMLINK requires <link> <target>", line_no))?;
798            let to = p
799                .next()
800                .ok_or_else(|| anyhow!("line {}: SYMLINK requires <link> <target>", line_no))?;
801            StepKind::Symlink {
802                from: from.to_string(),
803                to: to.to_string(),
804            }
805        }
806        Command::Mkdir => {
807            if remainder.is_empty() {
808                bail!("line {}: MKDIR requires a path", line_no);
809            }
810            StepKind::Mkdir(remainder.to_string())
811        }
812        Command::Ls => {
813            let path = remainder
814                .split_whitespace()
815                .next()
816                .filter(|s| !s.is_empty())
817                .map(|s| s.to_string());
818            StepKind::Ls(path)
819        }
820        Command::Cwd => StepKind::Cwd,
821        Command::Write => {
822            let mut p = remainder.splitn(2, ' ');
823            let path = p
824                .next()
825                .filter(|s| !s.is_empty())
826                .ok_or_else(|| anyhow!("line {}: WRITE requires <path> <contents>", line_no))?;
827            let contents = p
828                .next()
829                .filter(|s| !s.is_empty())
830                .ok_or_else(|| anyhow!("line {}: WRITE requires <path> <contents>", line_no))?;
831            StepKind::Write {
832                path: path.to_string(),
833                contents: contents.to_string(),
834            }
835        }
836        Command::Cat => {
837            let path = remainder
838                .split_whitespace()
839                .next()
840                .filter(|s| !s.is_empty())
841                .ok_or_else(|| anyhow!("line {}: CAT requires <path>", line_no))?;
842            StepKind::Cat(path.to_string())
843        }
844        Command::Exit => {
845            if remainder.is_empty() {
846                bail!("line {}: EXIT requires a code", line_no);
847            }
848            let code: i32 = remainder
849                .parse()
850                .map_err(|_| anyhow!("line {}: EXIT code must be an integer", line_no))?;
851            StepKind::Exit(code)
852        }
853    };
854    Ok(kind)
855}
856
857pub fn parse_script(input: &str) -> Result<Vec<Step>> {
858    ScriptParser::new(input)?.parse()
859}
860
861impl Command {
862    pub const fn as_str(self) -> &'static str {
863        match self {
864            Command::Workdir => "WORKDIR",
865            Command::Workspace => "WORKSPACE",
866            Command::Env => "ENV",
867            Command::Echo => "ECHO",
868            Command::Run => "RUN",
869            Command::RunBg => "RUN_BG",
870            Command::Copy => "COPY",
871            Command::Capture => "CAPTURE",
872            Command::CopyGit => "COPY_GIT",
873            Command::Symlink => "SYMLINK",
874            Command::Mkdir => "MKDIR",
875            Command::Ls => "LS",
876            Command::Cwd => "CWD",
877            Command::Cat => "CAT",
878            Command::Write => "WRITE",
879            Command::Exit => "EXIT",
880        }
881    }
882
883    pub fn parse(op: &str) -> Option<Self> {
884        COMMANDS.iter().copied().find(|c| c.as_str() == op)
885    }
886}
887
888#[cfg(test)]
889mod tests {
890    use super::*;
891    use indoc::indoc;
892
893    #[test]
894    fn commands_are_case_sensitive() {
895        for bad in ["run echo hi", "Run echo hi", "rUn echo hi", "write foo bar"] {
896            let err = parse_script(bad).expect_err("mixed/lowercase commands must fail");
897            assert!(
898                err.to_string().contains("unknown instruction"),
899                "unexpected error for '{bad}': {err}"
900            );
901        }
902    }
903
904    #[test]
905    fn string_dsl_supports_rust_style_comments() {
906        let script = indoc! {r#"
907            // leading comment line
908            WORKDIR /tmp // inline comment
909            RUN echo "keep // literal"
910            /* block comment
911               WORKDIR ignored
912               /* nested inner */
913               RUN ignored as well
914            */
915            RUN echo final
916            RUN echo 'literal /* stay */ value'
917        "#};
918        let steps = parse_script(script).expect("parse ok");
919        assert_eq!(steps.len(), 4, "expected 4 executable steps");
920        match &steps[0].kind {
921            StepKind::Workdir(path) => assert_eq!(path, "/tmp"),
922            other => panic!("expected WORKDIR, saw {:?}", other),
923        }
924        match &steps[1].kind {
925            StepKind::Run(cmd) => assert_eq!(cmd, "echo \"keep // literal\""),
926            other => panic!("expected RUN, saw {:?}", other),
927        }
928        match &steps[2].kind {
929            StepKind::Run(cmd) => assert_eq!(cmd, "echo final"),
930            other => panic!("expected RUN, saw {:?}", other),
931        }
932        match &steps[3].kind {
933            StepKind::Run(cmd) => assert_eq!(cmd, "echo 'literal /* stay */ value'"),
934            other => panic!("expected RUN, saw {:?}", other),
935        }
936    }
937
938    #[test]
939    fn semicolon_attached_to_command_splits_instructions() {
940        let script = "LS;LS;LS";
941        let steps = parse_script(script).expect("parse ok");
942        assert_eq!(steps.len(), 3);
943        assert!(
944            steps
945                .iter()
946                .all(|step| matches!(step.kind, StepKind::Ls(_)))
947        );
948    }
949
950    #[test]
951    fn string_dsl_errors_on_unclosed_block_comment() {
952        let err = parse_script("RUN echo hi /*").expect_err("unclosed block comment should error");
953        assert!(
954            err.to_string().contains("unclosed block comment"),
955            "unexpected error message: {err}"
956        );
957    }
958
959    #[cfg(feature = "token-input")]
960    #[test]
961    fn string_and_braced_scripts_produce_identical_ast() {
962        use quote::quote;
963
964        let atomic_instructions: Vec<(&str, proc_macro2::TokenStream)> = vec![
965            ("WORKDIR /tmp", quote! { WORKDIR /tmp }),
966            ("ENV FOO=bar", quote! { ENV FOO=bar }),
967            ("RUN echo hi && ls", quote! { RUN echo hi && ls }),
968            ("WRITE dist/out.txt hi", quote! { WRITE dist/out.txt "hi" }),
969            (
970                "COPY src/file dist/file",
971                quote! { COPY src/file dist/file },
972            ),
973        ];
974
975        let mut cases: Vec<(String, proc_macro2::TokenStream)> = Vec::new();
976
977        for mask in 1..(1 << atomic_instructions.len()) {
978            let mut literal_parts = Vec::new();
979            let mut token_parts = Vec::new();
980            for (idx, (lit, tokens)) in atomic_instructions.iter().enumerate() {
981                if (mask & (1 << idx)) != 0 {
982                    literal_parts.push(*lit);
983                    token_parts.push(tokens.clone());
984                }
985            }
986            let literal = literal_parts.join("\n");
987            let tokens = quote! { #(#token_parts)* };
988            cases.push((literal, tokens));
989        }
990
991        cases.push((
992            indoc! {r#"
993                [env:PROFILE=release]
994                RUN echo release
995                RUN echo done
996            "#}
997            .trim()
998            .to_string(),
999            quote! {
1000                [env:PROFILE=release] RUN echo release
1001                RUN echo done
1002            },
1003        ));
1004
1005        cases.push((
1006            indoc! {r#"
1007                [platform:linux] {
1008                    WORKDIR /client
1009                    RUN echo linux
1010                }
1011                [env:FOO=bar] {
1012                    WRITE scoped.txt hit
1013                }
1014                RUN echo finished
1015            "#}
1016            .trim()
1017            .to_string(),
1018            quote! {
1019                [platform:linux] {
1020                    WORKDIR /client
1021                    RUN echo linux
1022                }
1023                [env:FOO=bar] {
1024                    WRITE scoped.txt hit
1025                }
1026                RUN echo finished
1027            },
1028        ));
1029
1030        cases.push((
1031            indoc! {r#"
1032                [!env:SKIP]
1033                [platform:windows] RUN echo win
1034                [ env:MODE=beta,
1035                  linux
1036                ] RUN echo combo
1037            "#}
1038            .trim()
1039            .to_string(),
1040            quote! {
1041                [!env:SKIP]
1042                [platform:windows] RUN echo win
1043                [env:MODE=beta, linux] RUN echo combo
1044            },
1045        ));
1046
1047        cases.push((
1048            indoc! {r#"
1049                [env:OUTER] {
1050                    WORKDIR /tmp
1051                    [env:INNER] {
1052                        RUN echo inner; echo still
1053                    }
1054                    WRITE after.txt ok
1055                }
1056                RUN echo done
1057            "#}
1058            .trim()
1059            .to_string(),
1060            quote! {
1061                [env:OUTER] {
1062                    WORKDIR /tmp
1063                    [env:INNER] {
1064                        RUN echo inner; echo still
1065                    }
1066                    WRITE after.txt ok
1067                }
1068                RUN echo done
1069            },
1070        ));
1071
1072        cases.push((
1073            indoc! {r#"
1074                [env:TEST=1] CAPTURE out.txt RUN echo hi
1075                [env:FOO] WRITE foo.txt bar
1076                SYMLINK link target
1077            "#}
1078            .trim()
1079            .to_string(),
1080            quote! {
1081                [env:TEST=1] CAPTURE out.txt RUN echo hi
1082                [env:FOO] WRITE foo.txt "bar"
1083                SYMLINK link target
1084            },
1085        ));
1086
1087        for (idx, (literal, tokens)) in cases.iter().enumerate() {
1088            let text = literal.trim();
1089            let string_steps = parse_script(text)
1090                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
1091            let braced_steps = parse_braced_tokens(tokens)
1092                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
1093            assert_eq!(
1094                string_steps, braced_steps,
1095                "AST mismatch for case {idx} literal:\n{text}"
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn env_equals_guard_respects_inversion() {
1102        let mut envs = HashMap::new();
1103        envs.insert("FOO".to_string(), "bar".to_string());
1104        let guard = Guard::EnvEquals {
1105            key: "FOO".into(),
1106            value: "bar".into(),
1107            invert: false,
1108        };
1109        assert!(guard_allows(&guard, &envs));
1110
1111        let inverted = Guard::EnvEquals {
1112            key: "FOO".into(),
1113            value: "bar".into(),
1114            invert: true,
1115        };
1116        assert!(!guard_allows(&inverted, &envs));
1117    }
1118
1119    #[test]
1120    fn guards_allow_any_act_as_or_of_ands() {
1121        let mut envs = HashMap::new();
1122        envs.insert("MODE".to_string(), "beta".to_string());
1123        let groups = vec![
1124            vec![Guard::EnvEquals {
1125                key: "MODE".into(),
1126                value: "alpha".into(),
1127                invert: false,
1128            }],
1129            vec![Guard::EnvEquals {
1130                key: "MODE".into(),
1131                value: "beta".into(),
1132                invert: false,
1133            }],
1134        ];
1135        assert!(guards_allow_any(&groups, &envs));
1136    }
1137
1138    #[test]
1139    fn guards_allow_any_falls_back_to_false_when_all_fail() {
1140        let envs = HashMap::new();
1141        let groups = vec![vec![Guard::EnvExists {
1142            key: "MISSING".into(),
1143            invert: false,
1144        }]];
1145        assert!(!guards_allow_any(&groups, &envs));
1146    }
1147
1148    #[test]
1149    fn multi_line_guard_blocks_apply_to_next_command() {
1150        let script = indoc! {r#"
1151            [ env:FOO=bar,
1152              linux
1153            ]
1154            RUN echo guarded
1155        "#};
1156        let steps = parse_script(script).expect("parse ok");
1157        assert_eq!(steps.len(), 1);
1158        assert_eq!(steps[0].guards.len(), 1);
1159        assert!(matches!(steps[0].kind, StepKind::Run(ref cmd) if cmd == "echo guarded"));
1160    }
1161
1162    #[test]
1163    fn guarded_brace_blocks_apply_to_all_inner_steps() {
1164        let script = indoc! {r#"
1165            [env:APP=demo] {
1166                WRITE one.txt 1
1167                WRITE two.txt 2
1168            }
1169            WRITE three.txt 3
1170        "#};
1171        let steps = parse_script(script).expect("parse ok");
1172        assert_eq!(steps.len(), 3);
1173        assert_eq!(steps[0].guards.len(), 1);
1174        assert_eq!(steps[1].guards.len(), 1);
1175        assert!(steps[2].guards.is_empty());
1176    }
1177
1178    #[test]
1179    fn nested_guard_blocks_stack() {
1180        let script = indoc! {r#"
1181            [env:OUTER] {
1182                [env:INNER] {
1183                    WRITE nested.txt yes
1184                }
1185            }
1186        "#};
1187        let steps = parse_script(script).expect("parse ok");
1188        assert_eq!(steps.len(), 1);
1189        assert_eq!(steps[0].guards.len(), 1);
1190        assert_eq!(steps[0].guards[0].len(), 2);
1191    }
1192
1193    #[test]
1194    fn brace_blocks_require_guard() {
1195        let script = indoc! {r#"
1196            {
1197                WRITE nope.txt hi
1198            }
1199        "#};
1200        let err = parse_script(script).expect_err("block without guard must fail");
1201        assert!(err.to_string().contains("'{'"), "unexpected error: {err}");
1202    }
1203
1204    #[test]
1205    fn guard_lines_chain_before_block() {
1206        let script = indoc! {r#"
1207            [env:FOO]
1208            [linux]
1209            {
1210                WRITE ok.txt hi
1211            }
1212        "#};
1213        let steps = parse_script(script).expect("parse ok");
1214        assert_eq!(steps.len(), 1);
1215        assert_eq!(steps[0].guards[0].len(), 2);
1216    }
1217
1218    #[test]
1219    fn guard_block_emits_scope_markers() {
1220        let script = indoc! {r#"
1221            ENV RUN=1
1222            [env:RUN] {
1223                WRITE one.txt 1
1224                WRITE two.txt 2
1225            }
1226            WRITE three.txt 3
1227        "#};
1228        let steps = parse_script(script).expect("parse ok");
1229        assert_eq!(steps.len(), 4);
1230        assert_eq!(steps[1].scope_enter, 1);
1231        assert_eq!(steps[1].scope_exit, 0);
1232        assert_eq!(steps[2].scope_enter, 0);
1233        assert_eq!(steps[2].scope_exit, 1);
1234        assert_eq!(steps[3].scope_enter, 0);
1235        assert_eq!(steps[3].scope_exit, 0);
1236    }
1237
1238    #[test]
1239    fn nested_guard_block_scopes_stack_counts() {
1240        let script = indoc! {r#"
1241            [env:OUTER] {
1242                [env:INNER] {
1243                    WRITE deep.txt ok
1244                }
1245            }
1246        "#};
1247        let steps = parse_script(script).expect("parse ok");
1248        assert_eq!(steps.len(), 1);
1249        assert_eq!(steps[0].scope_enter, 2);
1250        assert_eq!(steps[0].scope_exit, 2);
1251    }
1252
1253    #[test]
1254    fn guard_block_must_contain_command() {
1255        let script = indoc! {r#"
1256            [env:FOO]
1257            {
1258            }
1259        "#};
1260        let err = parse_script(script).expect_err("empty block must fail");
1261        assert!(
1262            err.to_string()
1263                .contains("must contain at least one command"),
1264            "unexpected error: {err}"
1265        );
1266    }
1267}