1use std::collections::{BTreeMap, HashMap};
17
18use bynk_syntax::ast::{Case, EnvClause, ExprKind, SuiteDecl};
19use bynk_syntax::error::CompileError;
20use bynk_syntax::span::Span;
21
22pub fn check_env_clauses(decl: &SuiteDecl, errors: &mut Vec<CompileError>) {
26 check_scope(&decl.envs, "suite", errors);
27 for case in &decl.cases {
28 check_scope(&case.envs, "case", errors);
29 }
30}
31
32fn check_scope(envs: &[EnvClause], scope: &str, errors: &mut Vec<CompileError>) {
33 let mut seen: HashMap<&str, Span> = HashMap::new();
34 for entry in envs.iter().flat_map(|e| e.entries.iter()) {
35 let name = entry.name.name.as_str();
36 if let Some(prev) = seen.get(name) {
37 errors.push(
38 CompileError::new(
39 "bynk.suite.duplicate_env",
40 entry.name.span,
41 format!("`{name}` is given more than once in this {scope}'s `env`"),
42 )
43 .with_label(*prev, "first given here")
44 .with_note(
45 "each name has one value per scope; a case's `env` may override a name its suite gives",
46 ),
47 );
48 } else {
49 seen.insert(name, entry.name.span);
50 }
51 if !matches!(entry.value.kind, ExprKind::StrLit(_)) {
52 errors.push(
53 CompileError::new(
54 "bynk.suite.env_not_literal",
55 entry.value.span,
56 format!("the value of `{name}` in an `env` clause is not a `String` literal"),
57 )
58 .with_note(
59 "a Worker's variables and secrets are strings, so an `env` entry's value is written as one: `NAME = \"value\"`",
60 ),
61 );
62 }
63 }
64}
65
66pub fn case_env(decl: &SuiteDecl, case: &Case) -> BTreeMap<String, String> {
70 let mut env = BTreeMap::new();
71 for envs in [&decl.envs, &case.envs] {
72 for entry in envs.iter().flat_map(|e| e.entries.iter()) {
73 if let ExprKind::StrLit(v) = &entry.value.kind {
74 env.insert(entry.name.name.clone(), v.clone());
75 }
76 }
77 }
78 env
79}
80
81pub fn has_env(decl: &SuiteDecl) -> bool {
83 !decl.envs.is_empty() || decl.cases.iter().any(|c| !c.envs.is_empty())
84}
85
86pub fn check_env_binding_clash(
93 decl: &SuiteDecl,
94 bindings: &BTreeMap<String, String>,
95 errors: &mut Vec<CompileError>,
96) {
97 let entries = decl
98 .envs
99 .iter()
100 .chain(decl.cases.iter().flat_map(|c| c.envs.iter()))
101 .flat_map(|e| e.entries.iter());
102 for entry in entries {
103 let name = &entry.name.name;
104 let Some(owner) = bindings.get(name) else {
105 continue;
106 };
107 errors.push(
108 CompileError::new(
109 "bynk.suite.env_binding_clash",
110 entry.name.span,
111 format!("`{name}` is {owner}, so it cannot be given in `env`"),
112 )
113 .with_note(
114 "at `system`, an `env` entry is put on every participant's Worker `env`, where it would clash with what is already there",
115 )
116 .with_note("rename the variable"),
117 );
118 }
119}
120
121pub fn remote_env_disagreement(
132 case_name: &str,
133 span: Span,
134 env: &BTreeMap<String, String>,
135 vars: &BTreeMap<String, String>,
136 secrets: &[String],
137) -> Option<CompileError> {
138 let refusal = |what: String, note: &str| {
139 Some(
140 CompileError::new(
141 "bynk.test.remote_unsupported_env",
142 span,
143 format!(
144 "case `\"{case_name}\"` {what}, so it can't run against this target unchanged"
145 ),
146 )
147 .with_note(note.to_string()),
148 )
149 };
150 for (name, value) in env {
151 if secrets.contains(name) {
152 return refusal(
153 format!("sets the secret `{name}` in its `env`"),
154 "against a named target a secret comes from the environment, never from a suite; drop it from the case's `env` for this run",
155 );
156 }
157 match vars.get(name) {
158 None => {
159 return refusal(
160 format!("sets `{name}` in its `env`, which the target doesn't declare"),
161 "declare the value the target was deployed with under its `vars`, or run the case in process",
162 );
163 }
164 Some(deployed) if deployed != value => {
165 return refusal(
166 format!(
167 "sets `{name} = \"{value}\"`, but the target was deployed with `\"{deployed}\"`"
168 ),
169 "a suite written for one configuration must not run against another; run it in process, or against a target deployed as it expects",
170 );
171 }
172 Some(_) => {}
173 }
174 }
175 if let Some(name) = vars.keys().find(|n| !env.contains_key(*n)) {
176 return refusal(
177 format!("doesn't set `{name}`, which the target declares"),
178 "in process an unset name reads as `None`, so the target is a different environment; pin the value in the case's `env`",
179 );
180 }
181 None
182}
183
184#[cfg(test)]
185mod remote_env_tests {
186 use super::*;
187
188 fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
189 pairs
190 .iter()
191 .map(|(k, v)| (k.to_string(), v.to_string()))
192 .collect()
193 }
194
195 fn check(env: &[(&str, &str)], vars: &[(&str, &str)], secrets: &[&str]) -> Option<String> {
196 let secrets: Vec<String> = secrets.iter().map(|s| s.to_string()).collect();
197 remote_env_disagreement("c", Span::default(), &map(env), &map(vars), &secrets)
198 .map(|e| e.message)
199 }
200
201 #[test]
202 fn agreeing_env_and_vars_run() {
203 assert_eq!(check(&[], &[], &[]), None);
204 assert_eq!(check(&[("P", "fake")], &[("P", "fake")], &["S"]), None);
205 }
206
207 #[test]
208 fn every_disagreement_is_refused() {
209 let listed = check(&[("S", "x")], &[], &["S"]).expect("a listed name is refused");
210 assert!(listed.contains("sets the secret `S`"), "{listed}");
211 let undeclared = check(&[("P", "fake")], &[], &[]).expect("undeclared is refused");
212 assert!(undeclared.contains("doesn't declare"), "{undeclared}");
213 let differs = check(&[("P", "fake")], &[("P", "real")], &[]).expect("differs");
214 assert!(differs.contains("deployed with `\"real\"`"), "{differs}");
215 let unset = check(&[], &[("P", "real")], &[]).expect("unset is refused");
216 assert!(unset.contains("doesn't set `P`"), "{unset}");
217 }
218}