1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::sync::Arc;
4
5pub use crate::commands::{AssertTarget, StepKind};
6use crate::constants::{
7 KEYWORD_ASYNC, KEYWORD_AWAIT, KEYWORD_BREAK, KEYWORD_CANCEL, KEYWORD_CONTINUE, KEYWORD_ELSE,
8 KEYWORD_EXPORT, KEYWORD_FOR, KEYWORD_FUNC, KEYWORD_IF, KEYWORD_IMPORT, KEYWORD_LET,
9 KEYWORD_REMOTE, KEYWORD_RETURN, KEYWORD_TYPE, KEYWORD_WHILE,
10};
11
12#[derive(Debug, Clone, Default)]
16pub struct ModuleFuncs {
17 pub functions: HashSet<String>,
19}
20
21#[derive(Debug, Clone, Default)]
26pub struct ModuleTable {
27 pub modules: HashMap<String, Option<ModuleFuncs>>,
28}
29
30impl ModuleTable {
31 pub fn reserved_base_names(&self) -> HashSet<String> {
35 let mut out = HashSet::new();
36 for funcs in self.modules.values().flatten() {
37 out.extend(funcs.functions.iter().cloned());
38 }
39 out
40 }
41}
42
43#[derive(Copy, Clone, Debug, Eq, PartialEq)]
51pub enum Command {
52 InheritEnv,
53 Workdir,
54 Workspace,
55 Env,
56 Echo,
57 Run,
58 Copy,
59 WithIo,
60 CopyGit,
61 HashSha256,
62 Symlink,
63 Mkdir,
64 Install,
65 Archive,
66 Unarchive,
67 Ls,
68 Cwd,
69 Read,
70 ReadLine,
71 Prompt,
72 Write,
73 Append,
74 Remove,
75 Expand,
76 AssertEq,
77 AssertContains,
78 Exit,
79 Async,
80 Timeout,
81 Sleep,
82 ListAppend,
83}
84
85pub const COMMANDS: &[Command] = &[
86 Command::InheritEnv,
87 Command::Workdir,
88 Command::Workspace,
89 Command::Env,
90 Command::Echo,
91 Command::Run,
92 Command::Copy,
93 Command::WithIo,
94 Command::CopyGit,
95 Command::HashSha256,
96 Command::Symlink,
97 Command::Mkdir,
98 Command::Install,
99 Command::Archive,
100 Command::Unarchive,
101 Command::Ls,
102 Command::Cwd,
103 Command::Read,
104 Command::ReadLine,
105 Command::Prompt,
106 Command::Write,
107 Command::Append,
108 Command::Remove,
109 Command::Expand,
110 Command::AssertEq,
111 Command::AssertContains,
112 Command::Exit,
113 Command::Timeout,
114 Command::Sleep,
115 Command::ListAppend,
116];
117
118pub const COMMAND_CATEGORIES: &[&str] =
123 &["INHERIT", "WITH", "COPY", "HASH", "READ", "ASSERT", "LIST"];
124
125impl Command {
126 pub const fn as_str(self) -> &'static str {
127 match self {
128 Command::InheritEnv => "INHERIT_ENV",
129 Command::Workdir => "WORKDIR",
130 Command::Workspace => "WORKSPACE",
131 Command::Env => "ENV",
132 Command::Echo => "ECHO",
133 Command::Run => "RUN",
134 Command::Copy => "COPY",
135 Command::WithIo => "WITH_IO",
136 Command::CopyGit => "COPY_GIT",
137 Command::HashSha256 => "HASH_SHA256",
138 Command::Symlink => "SYMLINK",
139 Command::Mkdir => "MKDIR",
140 Command::Install => "INSTALL",
141 Command::Archive => "ARCHIVE",
142 Command::Unarchive => "UNARCHIVE",
143 Command::Ls => "LS",
144 Command::Cwd => "CWD",
145 Command::Read => "READ",
146 Command::ReadLine => "READ_LINE",
147 Command::Prompt => "PROMPT",
148 Command::Write => "WRITE",
149 Command::Append => "APPEND",
150 Command::Remove => "REMOVE",
151 Command::Expand => "EXPAND",
152 Command::AssertEq => "ASSERT_EQ",
153 Command::AssertContains => "ASSERT_CONTAINS",
154 Command::Exit => "EXIT",
155 Command::Async => "ASYNC",
156 Command::Timeout => "TIMEOUT",
157 Command::Sleep => "SLEEP",
158 Command::ListAppend => "LIST_APPEND",
159 }
160 }
161
162 pub const fn syntax(self) -> &'static str {
163 match self {
164 Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
165 Command::Workdir => "WORKDIR <path>",
166 Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL|CACHE|SYSTEM [--local]",
167 Command::Env => "ENV KEY=value",
168 Command::Echo => "ECHO <message>",
169 Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
170 Command::Copy => "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
171 Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
172 Command::WithIo => "WITH_IO [bindings] [command | { block }]",
173 Command::HashSha256 => "HASH_SHA256 <path>",
174 Command::Symlink => {
175 "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>"
176 }
177 Command::Mkdir => "MKDIR <path>",
178 Command::Install => "INSTALL <source> <dest>",
179 Command::Archive => "ARCHIVE [--raw] <src> <dst>",
180 Command::Unarchive => "UNARCHIVE <src> <dst> [--max-bytes <bytes>]",
181 Command::Ls => "LS [<path>]",
182 Command::Cwd => "CWD",
183 Command::Read => "READ [<path>]",
184 Command::ReadLine => "READ_LINE $var",
185 Command::Prompt => "PROMPT $var <message> [<default>]",
186 Command::Write => "WRITE <path> [<contents>]",
187 Command::Append => "APPEND <path> [<contents>]",
188 Command::Remove => "REMOVE [--recursive] <path>",
189 Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
190 Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
191 Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
192 Command::Exit => "EXIT <code>",
193 Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
194 Command::Timeout => {
195 "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
196 }
197 Command::Sleep => "SLEEP <duration>",
198 Command::ListAppend => "LIST_APPEND $list <item>",
199 }
200 }
201
202 pub const fn expects_inner_command(self) -> bool {
203 matches!(self, Command::WithIo | Command::Async | Command::Timeout)
204 }
205
206 pub fn parse(s: &str) -> Option<Self> {
207 match s {
208 "INHERIT_ENV" => Some(Command::InheritEnv),
209 "WORKDIR" => Some(Command::Workdir),
210 "WORKSPACE" => Some(Command::Workspace),
211 "ENV" => Some(Command::Env),
212 "ECHO" => Some(Command::Echo),
213 "RUN" => Some(Command::Run),
214 "COPY" => Some(Command::Copy),
215 "WITH_IO" => Some(Command::WithIo),
216 "COPY_GIT" => Some(Command::CopyGit),
217 "HASH_SHA256" => Some(Command::HashSha256),
218 "SYMLINK" => Some(Command::Symlink),
219 "MKDIR" => Some(Command::Mkdir),
220 "INSTALL" => Some(Command::Install),
221 "ARCHIVE" => Some(Command::Archive),
222 "UNARCHIVE" => Some(Command::Unarchive),
223 "LS" => Some(Command::Ls),
224 "CWD" => Some(Command::Cwd),
225 "READ" => Some(Command::Read),
226 "READ_LINE" => Some(Command::ReadLine),
227 "PROMPT" => Some(Command::Prompt),
228 "WRITE" => Some(Command::Write),
229 "APPEND" => Some(Command::Append),
230 "REMOVE" => Some(Command::Remove),
231 "EXPAND" => Some(Command::Expand),
232 "ASSERT_EQ" => Some(Command::AssertEq),
233 "ASSERT_CONTAINS" => Some(Command::AssertContains),
234 "EXIT" => Some(Command::Exit),
235 "ASYNC" => Some(Command::Async),
236 "TIMEOUT" => Some(Command::Timeout),
237 "SLEEP" => Some(Command::Sleep),
238 "LIST_APPEND" => Some(Command::ListAppend),
239 _ => None,
240 }
241 }
242
243 pub(crate) fn is_statement_keyword(s: &str) -> bool {
248 Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
249 }
250}
251
252pub const STRUCTURAL_KEYWORDS: &[&str] = &[
258 KEYWORD_LET,
259 KEYWORD_FOR,
260 KEYWORD_IF,
261 KEYWORD_ELSE,
262 KEYWORD_ASYNC,
263 KEYWORD_AWAIT,
264 KEYWORD_CANCEL,
265 KEYWORD_FUNC,
266 KEYWORD_RETURN,
267 KEYWORD_WHILE,
268 KEYWORD_BREAK,
269 KEYWORD_CONTINUE,
270 KEYWORD_REMOTE,
271 KEYWORD_IMPORT,
272 KEYWORD_EXPORT,
273 KEYWORD_TYPE,
274];
275
276pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
281
282#[derive(Debug, Clone, Eq, PartialEq)]
295pub enum Guard {
296 Attr {
297 ns: Ns,
298 key: Option<String>,
299 val: Option<String>,
300 },
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
307pub enum Ns {
308 Os,
309 Arch,
310 Bool,
311 Env,
312}
313
314impl fmt::Display for Ns {
315 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316 match self {
317 Ns::Os => write!(f, "os"),
318 Ns::Arch => write!(f, "arch"),
319 Ns::Bool => write!(f, "bool"),
320 Ns::Env => write!(f, "env"),
321 }
322 }
323}
324
325pub const ARCH_VALUES: &[&str] = &[
329 "aarch64",
330 "arm",
331 "arm64ec",
332 "avr",
333 "bpf",
334 "csky",
335 "hexagon",
336 "loongarch32",
337 "loongarch64",
338 "mips",
339 "mips32r6",
340 "mips64",
341 "mips64r6",
342 "msp430",
343 "nvptx64",
344 "powerpc",
345 "powerpc64",
346 "riscv32",
347 "riscv64",
348 "s390x",
349 "sparc",
350 "sparc64",
351 "wasm32",
352 "wasm64",
353 "x86",
354 "x86_64",
355 "xtensa",
356];
357
358#[derive(Debug, Clone, Eq, PartialEq)]
359pub enum GuardExpr {
360 Predicate(Guard),
361 All(Vec<GuardExpr>),
362 Or(Vec<GuardExpr>),
363 Not(Box<GuardExpr>),
364}
365
366impl GuardExpr {
367 pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
368 let mut flat = Vec::new();
369 for expr in exprs {
370 match expr {
371 GuardExpr::All(children) => flat.extend(children),
372 other => flat.push(other),
373 }
374 }
375 match flat.len() {
376 0 => panic!("GuardExpr::all requires at least one expression"),
377 1 => flat.into_iter().next().unwrap(),
378 _ => GuardExpr::All(flat),
379 }
380 }
381
382 pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
383 let mut flat = Vec::new();
384 for expr in exprs {
385 match expr {
386 GuardExpr::Or(children) => flat.extend(children),
387 other => flat.push(other),
388 }
389 }
390 match flat.len() {
391 0 => panic!("GuardExpr::or requires at least one expression"),
392 1 => flat.into_iter().next().unwrap(),
393 _ => GuardExpr::Or(flat),
394 }
395 }
396
397 pub fn invert(expr: GuardExpr) -> GuardExpr {
398 match expr {
399 GuardExpr::Not(inner) => *inner,
400 other => GuardExpr::Not(Box::new(other)),
401 }
402 }
403}
404
405impl std::ops::Not for GuardExpr {
406 type Output = GuardExpr;
407
408 fn not(self) -> GuardExpr {
409 match self {
410 GuardExpr::Not(inner) => *inner,
411 other => GuardExpr::Not(Box::new(other)),
412 }
413 }
414}
415
416impl From<Guard> for GuardExpr {
417 fn from(guard: Guard) -> Self {
418 GuardExpr::Predicate(guard)
419 }
420}
421
422#[derive(Debug, Clone, PartialEq)]
424pub enum Arg {
425 String(String, bool),
429 Expr(Expr),
431 Parts(Vec<ArgPart>),
435}
436
437#[derive(Debug, Clone, PartialEq)]
439pub enum ArgPart {
440 Text(String, bool),
443 Expr(Expr),
445}
446
447impl Arg {
448 pub fn as_str(&self) -> &str {
449 match self {
450 Arg::String(s, _) => s,
451 Arg::Expr(_) | Arg::Parts(_) => "",
454 }
455 }
456
457 pub fn render(&self) -> String {
461 match self {
462 Arg::String(s, _) => s.clone(),
463 Arg::Expr(e) => e.to_string(),
464 Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
465 }
466 }
467
468 pub fn is_quoted(&self) -> bool {
469 matches!(self, Arg::String(_, true))
470 }
471}
472
473impl ArgPart {
474 pub fn render(&self) -> String {
475 match self {
476 ArgPart::Text(s, _) => s.clone(),
477 ArgPart::Expr(e) => e.to_string(),
478 }
479 }
480}
481
482impl From<String> for Arg {
483 fn from(s: String) -> Self {
484 Arg::String(s, false)
485 }
486}
487
488impl From<&str> for Arg {
489 fn from(s: &str) -> Self {
490 Arg::String(s.to_string(), false)
491 }
492}
493
494impl std::fmt::Display for Arg {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 match self {
497 Arg::String(s, _) => write!(f, "{}", s),
498 Arg::Expr(e) => write!(f, "{}", e),
499 Arg::Parts(parts) => {
500 for part in parts {
501 match part {
502 ArgPart::Text(s, _) => write!(f, "{}", s)?,
503 ArgPart::Expr(e) => write!(f, "{}", e)?,
504 }
505 }
506 Ok(())
507 }
508 }
509 }
510}
511
512impl AsRef<str> for Arg {
513 fn as_ref(&self) -> &str {
514 self.as_str()
515 }
516}
517
518impl PartialEq<str> for Arg {
519 fn eq(&self, other: &str) -> bool {
520 self.as_str() == other
521 }
522}
523
524impl PartialEq<&str> for Arg {
525 fn eq(&self, other: &&str) -> bool {
526 self.as_str() == *other
527 }
528}
529
530#[derive(Debug, Clone, Copy, Eq, PartialEq)]
531pub enum IoStream {
532 Stdin,
533 Stdout,
534 Stderr,
535}
536
537#[derive(Debug, Clone, Eq, PartialEq)]
538pub struct IoBinding {
539 pub stream: IoStream,
540 pub pipe: Option<PipeTarget>,
541}
542
543#[derive(Debug, Clone, Eq, PartialEq)]
546pub enum PipeTarget {
547 Var(String),
548}
549
550pub use crate::value::{
554 OxDockType, SemaphoreState, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy,
555 clone_shared, drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed,
556 fmt_inline, fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor,
557 unshare_boxed, unshare_inline, unshare_shared,
558};
559
560#[derive(Debug, Clone, Copy, Eq, PartialEq)]
561pub enum CompareOp {
562 Eq,
563 Ne,
564 Lt,
565 Le,
566 Gt,
567 Ge,
568}
569
570#[derive(Debug, Clone, Copy, Eq, PartialEq)]
571pub enum ArithOp {
572 Add,
573 Sub,
574 Mul,
575 Div,
576}
577
578#[derive(Debug, Clone, PartialEq)]
587pub enum MathOp {
588 PushConst(Value),
589 LoadVar(String),
590 LoadEnv(String),
591 LoadKeyPath { base: String, keys: Vec<String> },
592 Call { name: String, arity: usize },
593 Inspect(String),
594 Neg,
595 Add,
596 Sub,
597 Mul,
598 Div,
599 Lt,
600 Le,
601 Gt,
602 Ge,
603 Eq,
604 Ne,
605}
606
607#[derive(Debug, Clone, Copy, Eq, PartialEq)]
608pub enum LogicalOp {
609 And,
610 Or,
611}
612
613#[derive(Debug, Clone, PartialEq)]
614pub enum Expr {
615 Literal(Value),
616 Var(String),
617 Env(String),
620 KeyPath {
621 base: String,
622 keys: Vec<String>,
623 },
624 List(Vec<Expr>),
625 Map(Vec<(String, Expr)>),
626 Block(Vec<Step>),
631 Call {
632 name: String,
633 args: Vec<Expr>,
634 },
635 Inspect(String),
640 Compare {
641 op: CompareOp,
642 left: Box<Expr>,
643 right: Box<Expr>,
644 },
645 Arithmetic {
646 op: ArithOp,
647 left: Box<Expr>,
648 right: Box<Expr>,
649 },
650 CompiledMath(Vec<MathOp>),
653 FreshPipe,
659 UnsignedIntBoundary(u64),
663 Not(Box<Expr>),
664 Logical {
665 op: LogicalOp,
666 left: Box<Expr>,
667 right: Box<Expr>,
668 },
669}
670
671#[derive(Debug, Clone, PartialEq)]
672pub struct Step {
673 pub guard: Option<GuardExpr>,
674 pub kind: StepKind,
675 pub scope_enter: usize,
676 pub scope_exit: usize,
677}
678
679impl Expr {
680 pub fn walk(&self, f: &mut impl FnMut(&Expr)) {
687 f(self);
688 match self {
689 Expr::Literal(_)
690 | Expr::Var(_)
691 | Expr::Env(_)
692 | Expr::KeyPath { .. }
693 | Expr::Inspect(_)
694 | Expr::FreshPipe
695 | Expr::UnsignedIntBoundary(_) => {}
696 Expr::Call { args, .. } => {
697 for arg in args {
698 arg.walk(f);
699 }
700 }
701 Expr::List(items) => {
702 for item in items {
703 item.walk(f);
704 }
705 }
706 Expr::Map(entries) => {
707 for (_, value) in entries {
708 value.walk(f);
709 }
710 }
711 Expr::Block(steps) => {
712 for step in steps {
713 step.kind.walk_step_exprs(f);
714 }
715 }
716 Expr::Compare { left, right, .. }
717 | Expr::Arithmetic { left, right, .. }
718 | Expr::Logical { left, right, .. } => {
719 left.walk(f);
720 right.walk(f);
721 }
722 Expr::CompiledMath(_) => {}
723 Expr::Not(inner) => inner.walk(f),
724 }
725 }
726}
727
728#[derive(Debug, Clone, Eq, PartialEq)]
729pub enum WorkspaceTarget {
730 Snapshot,
731 Local,
732 Cache { local: bool },
733 System,
734}
735
736pub trait EnvLookup {
737 fn get_env(&self, key: &str) -> Option<&str>;
738}
739
740impl EnvLookup for HashMap<String, String> {
741 fn get_env(&self, key: &str) -> Option<&str> {
742 self.get(key).map(|s| s.as_str())
743 }
744}
745
746impl EnvLookup for Arc<HashMap<String, String>> {
747 fn get_env(&self, key: &str) -> Option<&str> {
748 (**self).get_env(key)
749 }
750}
751
752pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
753 let Guard::Attr { ns, key, val } = guard;
754 match ns {
755 Ns::Os => match val.as_deref() {
756 Some("macos") => platform_is_macos(),
757 Some("linux") => platform_is_linux(),
758 Some("windows") => platform_is_windows(),
759 _ => false,
760 },
761 Ns::Arch => match val.as_deref() {
762 Some(want) => ARCH_TARGET == want,
763 None => false,
764 },
765 Ns::Bool => val
766 .as_deref()
767 .map(|v| v.parse::<bool>().unwrap_or(false))
768 .unwrap_or(false),
769 Ns::Env => match key.as_deref() {
770 Some(var) => match val {
771 None => env.get_env(var).map(|v| !v.is_empty()).unwrap_or(false),
772 Some(want) => env
773 .get_env(var)
774 .map(|v| v == want.as_str())
775 .unwrap_or(false),
776 },
777 None => false,
779 },
780 }
781}
782
783#[allow(clippy::disallowed_macros)]
786fn platform_is_windows() -> bool {
787 cfg!(windows)
788}
789
790#[allow(clippy::disallowed_macros)]
792fn platform_is_macos() -> bool {
793 cfg!(target_os = "macos")
794}
795
796#[allow(clippy::disallowed_macros)]
798fn platform_is_linux() -> bool {
799 cfg!(target_os = "linux")
800}
801
802const ARCH_TARGET: &str = std::env::consts::ARCH;
805
806pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
807 match expr {
808 GuardExpr::Predicate(guard) => guard_allows(guard, env),
809 GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
810 GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
811 GuardExpr::Not(child) => !guard_expr_allows(child, env),
812 }
813}
814
815pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
816 match expr {
817 Some(e) => guard_expr_allows(e, env),
818 None => true,
819 }
820}
821
822impl Guard {
823 pub fn is_disjoint_from(&self, other: &Self) -> bool {
832 match (self, other) {
833 (
834 Guard::Attr {
835 ns: n1,
836 key: k1,
837 val: Some(v1),
838 },
839 Guard::Attr {
840 ns: n2,
841 key: k2,
842 val: Some(v2),
843 },
844 ) => n1 == n2 && k1 == k2 && v1 != v2,
845 _ => false,
846 }
847 }
848}
849
850impl GuardExpr {
851 pub fn is_disjoint_from(&self, other: &Self) -> bool {
857 match (self, other) {
858 (x, GuardExpr::Not(inner)) | (GuardExpr::Not(inner), x) => {
866 x == inner.as_ref() || unset_excludes(x, inner.as_ref())
867 }
868 (GuardExpr::Predicate(p1), GuardExpr::Predicate(p2)) => p1.is_disjoint_from(p2),
870 (GuardExpr::Or(arms), other) | (other, GuardExpr::Or(arms)) => {
872 arms.iter().all(|arm| arm.is_disjoint_from(other))
873 }
874 (GuardExpr::All(arms), other) | (other, GuardExpr::All(arms)) => {
876 arms.iter().any(|arm| arm.is_disjoint_from(other))
877 }
878 }
879 }
880}
881
882fn conjuncts(expr: &GuardExpr) -> Vec<&GuardExpr> {
886 match expr {
887 GuardExpr::All(items) => items.iter().collect(),
888 single => vec![single],
889 }
890}
891
892fn closed_domain_values(ns: Ns, key: Option<&str>) -> Option<&'static [&'static str]> {
899 match (ns, key) {
900 (Ns::Bool, None) => Some(&["true", "false"]),
901 (Ns::Os, None) => Some(&["macos", "linux", "windows"]),
902 (Ns::Arch, None) => Some(ARCH_VALUES),
903 _ => None,
904 }
905}
906
907#[derive(Clone)]
910enum DnfLit {
911 Pos(Guard),
912 Neg(Guard),
913}
914
915impl DnfLit {
916 fn as_expr(&self) -> GuardExpr {
917 match self {
918 DnfLit::Pos(guard) => GuardExpr::Predicate(guard.clone()),
919 DnfLit::Neg(guard) => GuardExpr::Not(Box::new(GuardExpr::Predicate(guard.clone()))),
920 }
921 }
922}
923
924const MAX_DNF_DISJUNCTS: usize = 64;
929
930fn dnf_push(expr: &GuardExpr, polarity: bool, out: &mut Vec<Vec<DnfLit>>) -> bool {
935 match (expr, polarity) {
936 (GuardExpr::Predicate(guard), true) => {
937 for disjunct in out.iter_mut() {
938 disjunct.push(DnfLit::Pos(guard.clone()));
939 }
940 true
941 }
942 (GuardExpr::Predicate(guard), false) => {
943 for disjunct in out.iter_mut() {
944 disjunct.push(DnfLit::Neg(guard.clone()));
945 }
946 true
947 }
948 (GuardExpr::All(children), true) | (GuardExpr::Or(children), false) => {
949 for child in children {
951 if !dnf_push(child, polarity, out) {
952 return false;
953 }
954 }
955 true
956 }
957 (GuardExpr::Or(children), true) | (GuardExpr::All(children), false) => {
958 let mut forked = Vec::new();
960 for disjunct in out.drain(..) {
961 for child in children {
962 let mut branch = vec![disjunct.clone()];
963 if !dnf_push(child, polarity, &mut branch) {
964 return false;
965 }
966 forked.extend(branch);
967 if forked.len() > MAX_DNF_DISJUNCTS {
968 return false;
969 }
970 }
971 }
972 *out = forked;
973 true
974 }
975 (GuardExpr::Not(inner), _) => dnf_push(inner, !polarity, out),
976 }
977}
978
979fn disjunct_satisfiable(lits: &[DnfLit]) -> bool {
983 for lit in lits {
987 match lit {
988 DnfLit::Pos(Guard::Attr {
989 ns: Ns::Bool, val, ..
990 }) if val.as_deref() != Some("true") => return false,
991 DnfLit::Neg(Guard::Attr {
992 ns: Ns::Bool,
993 key: None,
994 val: Some(value),
995 }) if value == "true" => return false,
996 _ => {}
997 }
998 }
999 for (index, first) in lits.iter().enumerate() {
1002 for second in &lits[index + 1..] {
1003 if first.as_expr().is_disjoint_from(&second.as_expr()) {
1004 return false;
1005 }
1006 }
1007 }
1008 for lit in lits {
1012 let (ns, key) = match lit {
1013 DnfLit::Neg(Guard::Attr {
1014 ns,
1015 key,
1016 val: Some(_),
1017 }) => (*ns, key.as_deref()),
1018 _ => continue,
1019 };
1020 let Some(domain) = closed_domain_values(ns, key) else {
1021 continue;
1022 };
1023 let exhausted = domain.iter().all(|value| {
1024 lits.iter().any(|other| match other {
1025 DnfLit::Neg(Guard::Attr {
1026 ns: other_ns,
1027 key: other_key,
1028 val: Some(other_val),
1029 }) => *other_ns == ns && other_key.as_deref() == key && other_val == value,
1030 _ => false,
1031 })
1032 });
1033 if exhausted {
1034 return false;
1035 }
1036 }
1037 true
1038}
1039
1040fn conjunction_dead(atoms: &[&GuardExpr]) -> bool {
1047 if atoms.is_empty() {
1048 return false;
1049 }
1050 let mut disjuncts = vec![Vec::new()];
1051 for atom in atoms {
1052 if !dnf_push(atom, true, &mut disjuncts) {
1053 return false;
1054 }
1055 }
1056 !disjuncts
1057 .iter()
1058 .any(|disjunct| disjunct_satisfiable(disjunct))
1059}
1060
1061pub fn guard_satisfiable(expr: &GuardExpr) -> bool {
1063 !conjunction_dead(&conjuncts(expr))
1064}
1065
1066pub fn guard_valid(expr: &GuardExpr) -> bool {
1069 match expr {
1070 GuardExpr::Predicate(Guard::Attr {
1071 ns: Ns::Bool,
1072 val: Some(value),
1073 ..
1074 }) => value.as_str() == "true",
1075 GuardExpr::All(items) => items.iter().all(guard_valid),
1076 GuardExpr::Not(inner) => !guard_satisfiable(inner),
1077 _ => false,
1078 }
1079}
1080
1081pub fn guards_overlap(a: Option<&GuardExpr>, b: Option<&GuardExpr>) -> bool {
1084 match (a, b) {
1085 (None, None) => true,
1086 (Some(x), None) | (None, Some(x)) => guard_satisfiable(x),
1087 (Some(x), Some(y)) => {
1088 let mut atoms = conjuncts(x);
1089 atoms.extend(conjuncts(y));
1090 !conjunction_dead(&atoms)
1091 }
1092 }
1093}
1094
1095fn unset_excludes(x: &GuardExpr, inner: &GuardExpr) -> bool {
1098 match (x, inner) {
1099 (
1100 GuardExpr::Predicate(Guard::Attr {
1101 ns: n2, key: k2, ..
1102 }),
1103 GuardExpr::Predicate(Guard::Attr {
1104 ns: n1,
1105 key: k1,
1106 val: None,
1107 }),
1108 ) => n1 == n2 && k1 == k2,
1109 _ => false,
1110 }
1111}
1112
1113impl fmt::Display for Guard {
1114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1120 match self {
1121 Guard::Attr {
1122 ns: Ns::Env,
1123 key: Some(var),
1124 val: None,
1125 } => write!(f, "env:{var}"),
1126 Guard::Attr {
1127 ns: Ns::Env,
1128 key: Some(var),
1129 val: Some(want),
1130 } => write!(f, "eq(env:{var}, {want})"),
1131 Guard::Attr {
1132 ns,
1133 key: _,
1134 val: Some(value),
1135 } => write!(f, "{ns}:{value}"),
1136 Guard::Attr { ns, key, val: None } => match key {
1137 Some(subject) => write!(f, "{ns}:{subject}"),
1138 None => write!(f, "{ns}"),
1139 },
1140 }
1141 }
1142}
1143
1144impl fmt::Display for WorkspaceTarget {
1145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1146 match self {
1147 WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
1148 WorkspaceTarget::Local => write!(f, "LOCAL"),
1149 WorkspaceTarget::Cache { local: false } => write!(f, "CACHE"),
1150 WorkspaceTarget::Cache { local: true } => write!(f, "CACHE --local"),
1151 WorkspaceTarget::System => write!(f, "SYSTEM"),
1152 }
1153 }
1154}
1155
1156impl fmt::Display for Expr {
1157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1158 match self {
1159 Expr::Literal(v) => write!(f, "{}", v),
1160 Expr::Var(name) => write!(f, "${}", name),
1161 Expr::Env(key) => write!(f, "env:{}", key),
1162 Expr::KeyPath { base, keys } => {
1163 write!(f, "${}", base)?;
1164 for key in keys {
1165 write!(f, ".{}", key)?;
1166 }
1167 Ok(())
1168 }
1169 Expr::Call { name, args } => {
1170 write!(f, "{}(", name)?;
1171 for (i, arg) in args.iter().enumerate() {
1172 if i > 0 {
1173 write!(f, ", ")?;
1174 }
1175 write!(f, "{}", arg)?;
1176 }
1177 write!(f, ")")
1178 }
1179 Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
1180 Expr::List(items) => {
1181 write!(f, "[")?;
1182 for (i, item) in items.iter().enumerate() {
1183 if i > 0 {
1184 write!(f, ", ")?;
1185 }
1186 write!(f, "{}", item)?;
1187 }
1188 write!(f, "]")
1189 }
1190 Expr::Map(entries) => {
1191 write!(f, "{{")?;
1192 for (i, (key, val)) in entries.iter().enumerate() {
1193 if i > 0 {
1194 write!(f, ", ")?;
1195 }
1196 write!(f, "\"{}\": {}", key, val)?;
1197 }
1198 write!(f, "}}")
1199 }
1200 Expr::Block(steps) => {
1201 write!(f, "{{ ")?;
1202 for (i, step) in steps.iter().enumerate() {
1203 if i > 0 {
1204 write!(f, "; ")?;
1205 }
1206 write!(f, "{}", step.kind)?;
1207 }
1208 write!(f, " }}")
1209 }
1210 Expr::Compare { op, left, right } => {
1211 write!(f, "{} {} {}", left, op, right)
1212 }
1213 Expr::Arithmetic { op, left, right } => {
1214 write!(f, "({} {} {})", left, op, right)
1215 }
1216 Expr::CompiledMath(ops) => {
1217 write!(f, "{}", format_compiled_math(ops))
1218 }
1219 Expr::FreshPipe => write!(f, "<fresh pipe>"),
1223 Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
1224 Expr::Not(inner) => {
1225 match inner.as_ref() {
1228 Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
1229 write!(f, "!({})", inner)
1230 }
1231 _ => write!(f, "!{}", inner),
1232 }
1233 }
1234 Expr::Logical { op, left, right } => {
1235 write!(f, "({} {} {})", left, op, right)
1236 }
1237 }
1238 }
1239}
1240
1241impl fmt::Display for CompareOp {
1242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1243 match self {
1244 CompareOp::Eq => write!(f, "=="),
1245 CompareOp::Ne => write!(f, "!="),
1246 CompareOp::Lt => write!(f, "<"),
1247 CompareOp::Le => write!(f, "<="),
1248 CompareOp::Gt => write!(f, ">"),
1249 CompareOp::Ge => write!(f, ">="),
1250 }
1251 }
1252}
1253
1254impl fmt::Display for ArithOp {
1255 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1256 match self {
1257 ArithOp::Add => write!(f, "+"),
1258 ArithOp::Sub => write!(f, "-"),
1259 ArithOp::Mul => write!(f, "*"),
1260 ArithOp::Div => write!(f, "/"),
1261 }
1262 }
1263}
1264
1265fn format_compiled_math(ops: &[MathOp]) -> String {
1270 let mut stack: Vec<String> = Vec::new();
1271 for op in ops {
1272 match op {
1273 MathOp::PushConst(v) => stack.push(format!("{}", v)),
1274 MathOp::LoadVar(name) => stack.push(format!("${}", name)),
1275 MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
1276 MathOp::LoadKeyPath { base, keys } => {
1277 let mut s = format!("${}", base);
1278 for key in keys {
1279 s.push('.');
1280 s.push_str(key);
1281 }
1282 stack.push(s);
1283 }
1284 MathOp::Call { name, arity } => {
1285 let mut args = Vec::new();
1286 for _ in 0..*arity {
1287 args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
1288 }
1289 args.reverse();
1290 stack.push(format!("{}({})", name, args.join(", ")));
1291 }
1292 MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
1293 MathOp::Neg => {
1294 let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1295 stack.push(format!("(-{})", inner));
1296 }
1297 MathOp::Add => push_bin(&mut stack, "+"),
1298 MathOp::Sub => push_bin(&mut stack, "-"),
1299 MathOp::Mul => push_bin(&mut stack, "*"),
1300 MathOp::Div => push_bin(&mut stack, "/"),
1301 MathOp::Lt => push_bin(&mut stack, "<"),
1302 MathOp::Le => push_bin(&mut stack, "<="),
1303 MathOp::Gt => push_bin(&mut stack, ">"),
1304 MathOp::Ge => push_bin(&mut stack, ">="),
1305 MathOp::Eq => push_bin(&mut stack, "=="),
1306 MathOp::Ne => push_bin(&mut stack, "!="),
1307 }
1308 }
1309 if stack.len() == 1 {
1310 let mut items = stack;
1311 items.pop().unwrap_or_else(|| "<empty>".to_string())
1312 } else {
1313 stack.join(" ")
1314 }
1315}
1316
1317fn push_bin(stack: &mut Vec<String>, op: &str) {
1318 let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1319 let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1320 stack.push(format!("({} {} {})", left, op, right));
1321}
1322
1323impl fmt::Display for LogicalOp {
1324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1325 match self {
1326 LogicalOp::And => write!(f, "&&"),
1327 LogicalOp::Or => write!(f, "||"),
1328 }
1329 }
1330}
1331
1332enum GuardDisplayContext {
1333 Root,
1334 InAnyArg,
1335 InNot,
1336 InAll,
1337}
1338
1339impl GuardExpr {
1340 fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
1341 match self {
1342 GuardExpr::Predicate(guard) => write!(f, "{}", guard),
1343 GuardExpr::All(children) => {
1344 let wrap = matches!(
1345 ctx,
1346 GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
1347 ) && children.len() > 1;
1348 if wrap {
1349 write!(f, "(")?;
1350 }
1351 for (i, child) in children.iter().enumerate() {
1352 if i > 0 {
1353 write!(f, ", ")?;
1354 }
1355 child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
1356 }
1357 if wrap {
1358 write!(f, ")")?;
1359 }
1360 Ok(())
1361 }
1362 GuardExpr::Or(children) => {
1363 write!(f, "any(")?;
1364 for (i, child) in children.iter().enumerate() {
1365 if i > 0 {
1366 write!(f, ", ")?;
1367 }
1368 child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
1369 }
1370 write!(f, ")")
1371 }
1372 GuardExpr::Not(child) => {
1373 write!(f, "not(")?;
1374 child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
1375 write!(f, ")")
1376 }
1377 }
1378 }
1379}
1380
1381impl fmt::Display for GuardExpr {
1382 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1383 self.fmt_with_ctx(f, GuardDisplayContext::Root)
1384 }
1385}
1386
1387impl fmt::Display for Step {
1388 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1389 if let Some(expr) = &self.guard {
1390 write!(f, "[{}] ", expr)?;
1391 }
1392 write!(f, "{}", self.kind)
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399 #[test]
1412 fn statement_keywords_match_pest_grammar() {
1413 use pest_meta::{ast::Expr, parser};
1414 use std::collections::{HashMap, HashSet};
1415
1416 let pest_src = include_str!("dsl.pest");
1417 let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
1418 .expect("dsl.pest must parse as a pest grammar");
1419 let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
1420 let by_name: HashMap<&str, &Expr> = rules
1421 .iter()
1422 .map(|rule| (rule.name.as_str(), &rule.expr))
1423 .collect();
1424
1425 let mut all_literals = HashSet::new();
1426 for rule in &rules {
1427 for node in rule.expr.iter_top_down() {
1428 match node {
1429 Expr::Str(literal) | Expr::Insens(literal) => {
1430 all_literals.insert(literal.clone());
1431 }
1432 _ => {}
1433 }
1434 }
1435 }
1436 for kw in STRUCTURAL_KEYWORDS {
1437 assert!(
1438 all_literals.contains(*kw),
1439 "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
1440 );
1441 }
1442
1443 let mut seen = HashSet::new();
1444 let mut stack = vec!["element".to_string(), "block_element".to_string()];
1445 let mut stmt_literals = HashSet::new();
1446 while let Some(name) = stack.pop() {
1447 if !seen.insert(name.clone()) {
1448 continue;
1449 }
1450 let Some(expr) = by_name.get(name.as_str()) else {
1451 continue;
1452 };
1453 for node in expr.iter_top_down() {
1454 match node {
1455 Expr::Str(literal) | Expr::Insens(literal) => {
1456 stmt_literals.insert(literal.clone());
1457 }
1458 Expr::Ident(dependency) => stack.push(dependency.clone()),
1459 _ => {}
1460 }
1461 }
1462 }
1463 assert!(
1464 seen.contains("element") && seen.contains("block_element"),
1465 "grammar must define element and block_element instruction rules"
1466 );
1467 for kw in stmt_literals {
1468 if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
1469 assert!(
1470 crate::Command::is_statement_keyword(&kw)
1471 || CLAUSE_KEYWORDS.contains(&kw.as_str()),
1472 "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
1473 );
1474 }
1475 }
1476 }
1477
1478 #[test]
1482 fn command_names_group_by_category() {
1483 for command in COMMANDS {
1484 let name = command.as_str();
1485 let Some((prefix, _)) = name.split_once('_') else {
1486 continue;
1487 };
1488 assert!(
1489 COMMAND_CATEGORIES.contains(&prefix),
1490 "command {name} introduces unregistered category {prefix}; add it to COMMAND_CATEGORIES"
1491 );
1492 }
1493 }
1494}
1495
1496#[cfg(test)]
1497mod guard_analysis_tests {
1498 use super::*;
1499
1500 fn attr(ns: Ns, key: Option<&str>, val: Option<&str>) -> GuardExpr {
1501 GuardExpr::Predicate(Guard::Attr {
1502 ns,
1503 key: key.map(str::to_string),
1504 val: val.map(str::to_string),
1505 })
1506 }
1507
1508 fn os(value: &str) -> GuardExpr {
1509 attr(Ns::Os, None, Some(value))
1510 }
1511
1512 fn arch(value: &str) -> GuardExpr {
1513 attr(Ns::Arch, None, Some(value))
1514 }
1515
1516 fn unix() -> GuardExpr {
1519 GuardExpr::Or(vec![os("macos"), os("linux")])
1520 }
1521
1522 fn windows() -> GuardExpr {
1523 os("windows")
1524 }
1525
1526 fn env_eq(var: &str, value: &str) -> GuardExpr {
1527 attr(Ns::Env, Some(var), Some(value))
1528 }
1529
1530 fn not(expr: GuardExpr) -> GuardExpr {
1531 GuardExpr::Not(Box::new(expr))
1532 }
1533
1534 #[test]
1538 fn partition_negations_are_jointly_dead() {
1539 let os_exhausted = GuardExpr::All(vec![not(os("macos")), not(os("linux")), not(windows())]);
1540 assert!(
1541 !guard_satisfiable(&os_exhausted),
1542 "no host is outside `macos`/`linux`/`windows`"
1543 );
1544 let os_partial = GuardExpr::All(vec![not(os("macos")), not(os("linux"))]);
1546 assert!(guard_satisfiable(&os_partial), "`windows` is still open");
1547 let bool_exhausted = GuardExpr::All(vec![
1549 not(attr(Ns::Bool, None, Some("true"))),
1550 not(attr(Ns::Bool, None, Some("false"))),
1551 ]);
1552 assert!(!guard_satisfiable(&bool_exhausted));
1553 let env_open = GuardExpr::All(vec![
1555 not(env_eq("MODE", "dev")),
1556 not(env_eq("MODE", "prod")),
1557 ]);
1558 assert!(guard_satisfiable(&env_open), "MODE may hold a third value");
1559 assert!(guard_satisfiable(¬(unix())));
1561 }
1562
1563 #[test]
1566 fn unix_alias_is_os_disjunction() {
1567 assert!(!guards_overlap(Some(&unix()), Some(&windows())));
1568 assert!(guards_overlap(Some(&unix()), Some(&os("macos"))));
1569 let covered = GuardExpr::All(vec![GuardExpr::Not(Box::new(unix())), not(windows())]);
1572 assert!(!guard_satisfiable(&covered));
1573 }
1574
1575 #[test]
1579 fn arch_guards_decide_by_value() {
1580 assert!(!guards_overlap(
1581 Some(&arch("x86_64")),
1582 Some(&arch("aarch64"))
1583 ));
1584 assert!(guards_overlap(Some(&arch("x86_64")), Some(&arch("x86_64"))));
1585 assert!(guards_overlap(Some(&arch("aarch64")), Some(&os("macos"))));
1586 let exhausted = GuardExpr::All(ARCH_VALUES.iter().map(|value| not(arch(value))).collect());
1587 assert!(!guard_satisfiable(&exhausted));
1588 let host = attr(Ns::Arch, None, Some(ARCH_TARGET));
1590 assert!(guard_expr_allows(&host, &HashMap::new()));
1591 }
1592
1593 #[test]
1597 fn platform_overlap_matrix_is_exhaustive() {
1598 let cases = [
1599 ("macos", "macos", true),
1600 ("macos", "linux", false),
1601 ("macos", "windows", false),
1602 ("linux", "linux", true),
1603 ("linux", "windows", false),
1604 ("windows", "windows", true),
1605 ];
1606 for (v1, v2, expected) in cases {
1607 let left = os(v1);
1608 let right = os(v2);
1609 assert_eq!(
1610 guards_overlap(Some(&left), Some(&right)),
1611 expected,
1612 "os:{v1} vs os:{v2}"
1613 );
1614 }
1615 }
1616
1617 #[test]
1620 fn disjoint_pairs_never_cofire_here() {
1621 let macos = os("macos");
1622 let holds = |expr: &GuardExpr| guard_expr_allows(expr, &HashMap::new());
1623 assert!(
1624 !(holds(&macos) && holds(&windows())),
1625 "os:macos and os:windows co-fired"
1626 );
1627 }
1628
1629 #[test]
1630 fn overlap_follows_namespace_semantics() {
1631 assert!(!guards_overlap(Some(&windows()), Some(&unix())));
1633 assert!(!guards_overlap(Some(&os("macos")), Some(&os("linux"))));
1634 assert!(guards_overlap(None, Some(&windows())));
1636 assert!(guards_overlap(None, None));
1637 let dead = GuardExpr::All(vec![os("macos"), windows()]);
1639 assert!(!guard_satisfiable(&dead));
1640 assert!(!guards_overlap(Some(&dead), Some(&os("macos"))));
1641 assert!(guard_valid(&GuardExpr::Predicate(Guard::Attr {
1642 ns: Ns::Bool,
1643 key: None,
1644 val: Some("true".to_string()),
1645 })));
1646 assert!(!guard_valid(&unix()));
1647 }
1648
1649 #[test]
1650 fn env_value_conflicts_decide_without_enumeration() {
1651 let fast = env_eq("MODE", "fast");
1652 let slow = env_eq("MODE", "slow");
1653 assert!(
1654 !guards_overlap(Some(&fast), Some(&slow)),
1655 "same key distinct values are disjoint"
1656 );
1657 let also_fast = env_eq("MODE", "fast");
1658 assert!(guards_overlap(Some(&fast), Some(&also_fast)));
1659 let bare = attr(Ns::Env, Some("MODE"), None);
1661 assert!(guards_overlap(Some(&bare), Some(&fast)));
1662 }
1663}
1664
1665#[cfg(test)]
1666mod guard_algebra_tests {
1667 use super::*;
1668
1669 fn os(value: &str) -> GuardExpr {
1670 GuardExpr::Predicate(Guard::Attr {
1671 ns: Ns::Os,
1672 key: None,
1673 val: Some(value.to_string()),
1674 })
1675 }
1676
1677 #[test]
1678 fn or_distributes_over_disjointness() {
1679 fn env_eq(var: &str, value: &str) -> GuardExpr {
1680 GuardExpr::Predicate(Guard::Attr {
1681 ns: Ns::Env,
1682 key: Some(var.to_string()),
1683 val: Some(value.to_string()),
1684 })
1685 }
1686 let mixed = GuardExpr::Or(vec![env_eq("MODE", "dev"), env_eq("MODE", "prod")]);
1689 assert!(!mixed.is_disjoint_from(&env_eq("MODE", "dev")));
1690 assert!(mixed.is_disjoint_from(&env_eq("MODE", "test")));
1692 let cross = GuardExpr::Or(vec![env_eq("MODE", "dev")]);
1694 let platform = os("windows");
1695 assert!(!cross.is_disjoint_from(&platform));
1696 }
1697
1698 #[test]
1699 fn all_distributes_over_disjointness() {
1700 let stacked = GuardExpr::All(vec![
1701 os("macos"),
1702 GuardExpr::Or(vec![os("macos"), os("linux")]),
1703 os("windows"),
1704 ]);
1705 assert!(stacked.is_disjoint_from(&os("macos")));
1706 let live = GuardExpr::All(vec![
1709 os("macos"),
1710 GuardExpr::Predicate(Guard::Attr {
1711 ns: Ns::Env,
1712 key: Some("MODE".to_string()),
1713 val: Some("dev".to_string()),
1714 }),
1715 ]);
1716 assert!(!live.is_disjoint_from(&os("macos")));
1717 }
1718
1719 #[test]
1720 fn negation_is_structural_not_syntactic() {
1721 let not_win = GuardExpr::Not(Box::new(os("windows")));
1722 assert!(not_win.is_disjoint_from(&os("windows")));
1723 assert!(os("windows").is_disjoint_from(¬_win));
1724 assert!(!not_win.is_disjoint_from(&os("macos")));
1725 }
1726}