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 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 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 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 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 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 let mut all_groups: Vec<Vec<Guard>> = Vec::new();
337 if groups.is_empty() {
338 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 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 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 remainder = first;
376 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 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}