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 Symlink,
14 Mkdir,
15 Ls,
16 Cat,
17 Write,
18 Exit,
19}
20
21pub const COMMANDS: &[Command] = &[
22 Command::Workdir,
23 Command::Workspace,
24 Command::Env,
25 Command::Echo,
26 Command::Run,
27 Command::RunBg,
28 Command::Copy,
29 Command::Symlink,
30 Command::Mkdir,
31 Command::Ls,
32 Command::Cat,
33 Command::Write,
34 Command::Exit,
35];
36
37fn platform_matches(target: PlatformGuard) -> bool {
38 match target {
39 PlatformGuard::Unix => cfg!(unix),
40 PlatformGuard::Windows => cfg!(windows),
41 PlatformGuard::Macos => cfg!(target_os = "macos"),
42 PlatformGuard::Linux => cfg!(target_os = "linux"),
43 }
44}
45
46fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
47 match guard {
48 Guard::Platform { target, invert } => {
49 let res = platform_matches(*target);
50 if *invert { !res } else { res }
51 }
52 Guard::EnvExists { key, invert } => {
53 let res = script_envs
54 .get(key)
55 .cloned()
56 .or_else(|| std::env::var(key).ok())
57 .map(|v| !v.is_empty())
58 .unwrap_or(false);
59 if *invert { !res } else { res }
60 }
61 Guard::EnvEquals { key, value, invert } => {
62 let res = script_envs
63 .get(key)
64 .cloned()
65 .or_else(|| std::env::var(key).ok())
66 .map(|v| v == *value)
67 .unwrap_or(false);
68 if *invert { !res } else { res }
69 }
70 }
71}
72
73fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
74 group.iter().all(|g| guard_allows(g, script_envs))
75}
76
77pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
78 if groups.is_empty() {
80 return true;
81 }
82 groups.iter().any(|g| guard_group_allows(g, script_envs))
83}
84
85fn parse_guard(raw: &str, line_no: usize) -> Result<Guard> {
86 let mut text = raw.trim();
87 let mut invert_prefix = false;
88 if let Some(rest) = text.strip_prefix('!') {
89 invert_prefix = true;
90 text = rest.trim();
91 }
92
93 if let Some(after) = text.strip_prefix("platform") {
94 let after = after.trim_start();
95 if let Some(rest) = after.strip_prefix(':').or_else(|| after.strip_prefix('=')) {
96 let tag = rest.trim().to_ascii_lowercase();
97 let target = match tag.as_str() {
98 "unix" => PlatformGuard::Unix,
99 "windows" => PlatformGuard::Windows,
100 "mac" | "macos" => PlatformGuard::Macos,
101 "linux" => PlatformGuard::Linux,
102 _ => bail!("line {}: unknown platform '{}'", line_no, rest),
103 };
104 return Ok(Guard::Platform {
105 target,
106 invert: invert_prefix,
107 });
108 }
109 }
110
111 if let Some(rest) = text.strip_prefix("env:") {
112 let rest = rest.trim();
113 if let Some(pos) = rest.find("!=") {
114 let key = rest[..pos].trim();
115 let value = rest[pos + 2..].trim();
116 if key.is_empty() || value.is_empty() {
117 bail!("line {}: guard env: requires key and value", line_no);
118 }
119 return Ok(Guard::EnvEquals {
120 key: key.to_string(),
121 value: value.to_string(),
122 invert: true,
123 });
124 }
125 if let Some(pos) = rest.find('=') {
126 let key = rest[..pos].trim();
127 let value = rest[pos + 1..].trim();
128 if key.is_empty() || value.is_empty() {
129 bail!("line {}: guard env: requires key and value", line_no);
130 }
131 return Ok(Guard::EnvEquals {
132 key: key.to_string(),
133 value: value.to_string(),
134 invert: invert_prefix,
135 });
136 }
137 if rest.is_empty() {
138 bail!("line {}: guard env: requires a variable name", line_no);
139 }
140 return Ok(Guard::EnvExists {
141 key: rest.to_string(),
142 invert: invert_prefix,
143 });
144 }
145
146 let tag = text.to_ascii_lowercase();
147 let target = match tag.as_str() {
148 "unix" => PlatformGuard::Unix,
149 "windows" => PlatformGuard::Windows,
150 "mac" | "macos" => PlatformGuard::Macos,
151 "linux" => PlatformGuard::Linux,
152 _ => bail!("line {}: unknown guard '{}'", line_no, raw),
153 };
154 Ok(Guard::Platform {
155 target,
156 invert: invert_prefix,
157 })
158}
159
160impl Command {
161 pub const fn as_str(self) -> &'static str {
162 match self {
163 Command::Workdir => "WORKDIR",
164 Command::Workspace => "WORKSPACE",
165 Command::Env => "ENV",
166 Command::Echo => "ECHO",
167 Command::Run => "RUN",
168 Command::RunBg => "RUN_BG",
169 Command::Copy => "COPY",
170 Command::Symlink => "SYMLINK",
171 Command::Mkdir => "MKDIR",
172 Command::Ls => "LS",
173 Command::Cat => "CAT",
174 Command::Write => "WRITE",
175 Command::Exit => "EXIT",
176 }
177 }
178
179 pub fn parse(op: &str) -> Option<Self> {
180 COMMANDS.iter().copied().find(|c| c.as_str() == op)
181 }
182}
183
184#[derive(Copy, Clone, Debug, Eq, PartialEq)]
185pub enum PlatformGuard {
186 Unix,
187 Windows,
188 Macos,
189 Linux,
190}
191
192#[derive(Debug, Clone, Eq, PartialEq)]
193pub enum Guard {
194 Platform {
195 target: PlatformGuard,
196 invert: bool,
197 },
198 EnvExists {
199 key: String,
200 invert: bool,
201 },
202 EnvEquals {
203 key: String,
204 value: String,
205 invert: bool,
206 },
207}
208
209#[derive(Debug, Clone, Eq, PartialEq)]
210pub enum StepKind {
211 Workdir(String),
212 Workspace(WorkspaceTarget),
213 Env { key: String, value: String },
214 Run(String),
215 Echo(String),
216 RunBg(String),
217 Copy { from: String, to: String },
218 Symlink { from: String, to: String },
219 Mkdir(String),
220 Ls(Option<String>),
221 Cat(String),
222 Write { path: String, contents: String },
223 Exit(i32),
224}
225
226#[derive(Debug, Clone, Eq, PartialEq)]
227pub struct Step {
228 pub guards: Vec<Vec<Guard>>,
229 pub kind: StepKind,
230}
231
232#[derive(Debug, Clone, Eq, PartialEq)]
233pub enum WorkspaceTarget {
234 Snapshot,
235 Local,
236}
237
238pub fn parse_script(input: &str) -> Result<Vec<Step>> {
239 use std::collections::VecDeque;
240 let mut steps = Vec::new();
241 let mut pending_guards: Vec<Vec<Guard>> = Vec::new();
242
243 let mut queue: VecDeque<(usize, String)> = VecDeque::new();
246 for (i, raw) in input.lines().enumerate() {
247 let raw = raw.trim();
248 if raw.is_empty() || raw.starts_with('#') {
249 continue;
250 }
251 queue.push_back((i + 1, raw.to_string()));
252 }
253
254 while let Some((line_no, rawline)) = queue.pop_front() {
255 let line = rawline.as_str();
256
257 let (groups, remainder_opt) = if let Some(rest) = line.strip_prefix('[') {
263 let end = rest
264 .find(']')
265 .ok_or_else(|| anyhow::anyhow!("line {}: guard must close with ]", line_no))?;
266 let guards_raw = &rest[..end];
267 let after = rest[end + 1..].trim();
268 let mut parsed_groups: Vec<Vec<Guard>> = Vec::new();
270 for alt in guards_raw.split('|') {
271 let mut group: Vec<Guard> = Vec::new();
272 for g in alt.split(',') {
273 let parsed = parse_guard(g, line_no)?;
274 group.push(parsed);
275 }
276 parsed_groups.push(group);
277 }
278 if after.is_empty() {
279 if pending_guards.is_empty() {
281 pending_guards = parsed_groups;
282 } else {
283 let mut new_pending: Vec<Vec<Guard>> = Vec::new();
284 for p in pending_guards.iter() {
285 for q in parsed_groups.iter() {
286 let mut merged = p.clone();
287 merged.extend(q.clone());
288 new_pending.push(merged);
289 }
290 }
291 pending_guards = new_pending;
292 }
293 continue;
294 }
295 (parsed_groups, Some(after.to_string()))
296 } else {
297 (Vec::new(), Some(line.to_string()))
298 };
299
300 let mut all_groups: Vec<Vec<Guard>> = Vec::new();
305 if groups.is_empty() {
306 all_groups = pending_guards.clone();
308 } else if pending_guards.is_empty() {
309 all_groups = groups.clone();
310 } else {
311 for p in pending_guards.iter() {
312 for q in groups.iter() {
313 let mut merged = p.clone();
314 merged.extend(q.clone());
315 all_groups.push(merged);
316 }
317 }
318 }
319 pending_guards.clear();
320 let mut remainder = remainder_opt.unwrap();
321
322 let mut parts = remainder.splitn(2, ' ');
326 let op_owned = parts.next().unwrap().trim().to_string();
327 let rest_owned = parts
328 .next()
329 .map(|s| s.trim().to_string())
330 .unwrap_or_default();
331 let cmd = Command::parse(op_owned.as_str()).ok_or_else(|| {
332 anyhow::anyhow!("line {}: unknown instruction '{}'", line_no, op_owned)
333 })?;
334
335 if cmd != Command::Run && cmd != Command::RunBg {
339 if let Some(idx_sc) = rest_owned.find(';') {
340 let first = rest_owned[..idx_sc].trim().to_string();
341 let tail = rest_owned[idx_sc + 1..].trim();
342 remainder = first;
344 if !tail.is_empty() {
346 queue.push_front((line_no, tail.to_string()));
347 }
348 } else {
349 remainder = rest_owned.to_string();
350 }
351 } else {
352 remainder = rest_owned.to_string();
354 }
355
356 let kind = match cmd {
357 Command::Workdir => {
358 if remainder.is_empty() {
359 bail!("line {}: WORKDIR requires a path", line_no);
360 }
361 StepKind::Workdir(remainder.to_string())
362 }
363 Command::Workspace => {
364 let target = match remainder.as_str() {
365 "SNAPSHOT" | "snapshot" => WorkspaceTarget::Snapshot,
366 "LOCAL" | "local" => WorkspaceTarget::Local,
367 _ => bail!("line {}: WORKSPACE requires LOCAL or SNAPSHOT", line_no),
368 };
369 StepKind::Workspace(target)
370 }
371 Command::Env => {
372 let mut parts = remainder.splitn(2, '=');
373 let key = parts
374 .next()
375 .map(str::trim)
376 .filter(|s| !s.is_empty())
377 .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
378 let value = parts
379 .next()
380 .map(str::to_string)
381 .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
382 StepKind::Env {
383 key: key.to_string(),
384 value,
385 }
386 }
387 Command::Echo => {
388 if remainder.is_empty() {
389 bail!("line {}: ECHO requires a message", line_no);
390 }
391 StepKind::Echo(remainder.to_string())
392 }
393 Command::Run => {
394 if remainder.is_empty() {
395 bail!("line {}: RUN requires a command", line_no);
396 }
397 StepKind::Run(remainder.to_string())
398 }
399 Command::RunBg => {
400 if remainder.is_empty() {
401 bail!("line {}: RUN_BG requires a command", line_no);
402 }
403 StepKind::RunBg(remainder.to_string())
404 }
405 Command::Copy => {
406 let mut p = remainder.split_whitespace();
407 let from = p.next().ok_or_else(|| {
408 anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
409 })?;
410 let to = p.next().ok_or_else(|| {
411 anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
412 })?;
413 StepKind::Copy {
414 from: from.to_string(),
415 to: to.to_string(),
416 }
417 }
418 Command::Symlink => {
419 let mut p = remainder.split_whitespace();
420 let from = p.next().ok_or_else(|| {
421 anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
422 })?;
423 let to = p.next().ok_or_else(|| {
424 anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
425 })?;
426 StepKind::Symlink {
427 from: from.to_string(),
428 to: to.to_string(),
429 }
430 }
431 Command::Mkdir => {
432 if remainder.is_empty() {
433 bail!("line {}: MKDIR requires a path", line_no);
434 }
435 StepKind::Mkdir(remainder.to_string())
436 }
437 Command::Ls => {
438 let path = remainder
439 .split_whitespace()
440 .next()
441 .filter(|s| !s.is_empty())
442 .map(|s| s.to_string());
443 StepKind::Ls(path)
444 }
445 Command::Write => {
446 let mut p = remainder.splitn(2, ' ');
447 let path = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
448 anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
449 })?;
450 let contents = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
451 anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
452 })?;
453 StepKind::Write {
454 path: path.to_string(),
455 contents: contents.to_string(),
456 }
457 }
458 Command::Cat => {
459 let path = remainder
460 .split_whitespace()
461 .next()
462 .filter(|s| !s.is_empty())
463 .ok_or_else(|| anyhow::anyhow!("line {}: CAT requires <path>", line_no))?;
464 StepKind::Cat(path.to_string())
465 }
466 Command::Exit => {
467 if remainder.is_empty() {
468 bail!("line {}: EXIT requires a code", line_no);
469 }
470 let code: i32 = remainder.parse().map_err(|_| {
471 anyhow::anyhow!("line {}: EXIT code must be an integer", line_no)
472 })?;
473 StepKind::Exit(code)
474 }
475 };
476
477 steps.push(Step {
478 guards: all_groups,
479 kind,
480 });
481 }
482 Ok(steps)
483}