sendra_core/environment/mod.rs
1//! Environments: named files of variables, and the substitution pass that puts
2//! them into a request.
3//!
4//! An environment is one flat YAML file of name-to-value pairs, living at
5//! `.sendra/environments/<name>.yaml` inside a project:
6//!
7//! ```text
8//! base_url: https://staging.api.example.com
9//! api_key: ${API_KEY}
10//! ```
11//!
12//! Request and collection files reference those values with `{{name}}` inside
13//! `url`, `headers` (names and values), `body`, and the values of an
14//! `assertions` block. A value written as `${VAR}` is read from the OS
15//! environment when it is used, so a file that names a secret can still be
16//! committed — the secret itself never is.
17//!
18//! One top-level key is reserved rather than a variable: `auth`. An
19//! environment may carry a default [`Auth`](crate::Auth) block — exactly the
20//! same shape [`Request::auth`](crate::Request::auth) is — applied to every
21//! request run against it that sets no `auth:` of its own:
22//!
23//! ```text
24//! base_url: https://staging.api.example.com
25//! auth:
26//! bearer: ${API_TOKEN}
27//! ```
28//!
29//! See [`Environment::auth`] for the full precedence rule (a request's own
30//! `auth:` fully replaces the environment's, never merges with it) and
31//! [`Environment::apply`] for where it is filled in.
32//!
33//! Two references, two syntaxes, on purpose. `{{name}}` only ever means "a
34//! variable from the environment file" and is only looked for in request files;
35//! `${VAR}` only ever means "a variable from the OS environment" and is only
36//! looked for in environment-file values. Neither can appear where the other is
37//! resolved, so there is never a question of which of the two a given
38//! placeholder is, or of what order the two run in.
39//!
40//! # Why substitution is a pass over the parsed request
41//!
42//! Substitution happens **after** the YAML is parsed, walking the string fields
43//! of a [`Request`](crate::Request), rather than as a find-and-replace over the
44//! raw file text before parsing. Text-level substitution is easier to write and
45//! wrong in ways that only show up on someone else's machine:
46//!
47//! - A value can change the shape of the document. A token containing `:` or
48//! `#`, a multi-line PEM key, a body starting with `-` — each of those turns
49//! a valid file into a different (or invalid) one once pasted in as raw text.
50//! Post-parse, a value is a string that was already a string, and nothing it
51//! contains can add a key, end a block or start a comment.
52//! - It would make `deny_unknown_fields` and the collection rules run against
53//! text the author never wrote, so a parse error could point at a line that
54//! exists in no file, with a column that means nothing.
55//! - It would let `{{var}}` appear anywhere at all — in `method`, in half of a
56//! key name — which is a far larger contract than substitution is meant to
57//! make, and not one that could be walked back later.
58//!
59//! The cost is that only the fields listed above are templated. `method` is a
60//! closed enum with no useful placeholder, and `name` is deliberately excluded
61//! because it is the selector `sendra run <file> <name>` matches on: a label
62//! that changed with the environment could not be typed on the command line.
63//! Inside `assertions`, values are templated but the keys that select part of
64//! the response — header names, JSON paths — are not, for a related reason:
65//! see [`Environment::apply_assertions`].
66//!
67//! # `EnvironmentFile` vs. `Environment`
68//!
69//! [`Config`](crate::Config) splits into a `ConfigFile` (every field optional,
70//! because that optionality is the merge information) and a resolved `Config`
71//! because several config *sources* — project file, global file, CLI flags —
72//! merge into one. [`EnvironmentFile`] splits from [`Environment`] for a much
73//! narrower reason: it is purely the on-disk shape `serde` deserializes,
74//! while `Environment` additionally carries `source`, the per-run `captured`
75//! store, and (in tests) a stand-in OS environment — none of which come from
76//! the file itself. There is still no merging or layering here: one
77//! environment file, read once, is the whole story. `EnvironmentFile` exists
78//! for `schemars` to derive a real schema
79//! from (see `xtask`), not because a second environment file could combine
80//! with a first.
81
82mod substitute;
83
84use std::collections::BTreeMap;
85use std::path::{Path, PathBuf};
86
87use serde::{Deserialize, Serialize};
88
89use crate::collection::unique_temp_path;
90use crate::config::PROJECT_DIR_NAME;
91use crate::{Auth, SendraError};
92
93/// Directory holding environment files, under a project's `.sendra/`.
94const ENVIRONMENTS_DIR_NAME: &str = "environments";
95
96/// The environment name `sendra run` falls back to when `--env` is omitted.
97///
98/// Nothing in this module treats it as special: it resolves like any other
99/// name, and a project with no `default.yaml` gets the empty environment the
100/// same way `staging` with no `staging.yaml` would. The front-end is what
101/// decides an *explicitly named* environment with no file is an error while an
102/// absent default is not — see `environment_for` in `sendra-cli`.
103pub const DEFAULT_ENVIRONMENT_NAME: &str = "default";
104
105/// Delimiters for a `${VAR}` reference in an environment-file value.
106const OS_VAR_OPEN: &str = "${";
107const OS_VAR_CLOSE: &str = "}";
108
109/// A set of variables a request can be sent against.
110///
111/// [`Environment::default`] is the empty environment: no variables, and no file
112/// behind it. That is the state of a project with no `.sendra/environments/` at
113/// all, and it is not an error — a request with no `{{...}}` in it is untouched
114/// by substitution, so Sendra behaves exactly as it did before this existed.
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub struct Environment {
117 /// The file's contents, verbatim. Values still hold their `${VAR}`
118 /// references: those are resolved when a variable is used, not when the
119 /// file is read. See [`Environment::lookup`].
120 pub variables: BTreeMap<String, String>,
121
122 /// A default `auth:` block, reusing [`Request::auth`](crate::Request::auth)'s
123 /// exact shape, applied by [`Environment::apply`] to every request run
124 /// against this environment that sets no `auth:` of its own.
125 ///
126 /// **Fully replaced, never merged**, by a request's own `auth:` — the
127 /// same "two things claiming ownership of one setting" stance
128 /// `Request::auth` already takes against an explicit `Authorization`
129 /// header, applied one layer up: a request that wants different
130 /// credentials than its environment's default writes its own `auth:`
131 /// block, in full, rather than overriding one field of this one.
132 ///
133 /// `bearer`/`basic`/`api_key` still hold unsubstituted `{{var}}`/`${VAR}`
134 /// text here, exactly like [`variables`](Self::variables) — resolved,
135 /// against this same environment, only when [`apply`](Self::apply)
136 /// actually uses it.
137 pub auth: Option<Auth>,
138
139 /// The file this came from, or `None` for an environment that was not read
140 /// from disk (the empty default, or one built in a test). Carried so a
141 /// missing-variable error can name the file to go and fix.
142 pub source: Option<PathBuf>,
143
144 /// Variables captured by requests earlier in the same run, looked up
145 /// alongside [`variables`](Self::variables) — see
146 /// [`with_captured`](Self::with_captured), which is the only way to set it.
147 ///
148 /// Private, and set by rebuilding rather than by mutation, because the
149 /// growth of this map is the *whole* of how ordering works: the store is a
150 /// fact about a point in a run, and an `Environment` that could be mutated
151 /// in place would let a value reach a request that ran before it was
152 /// captured. Rebuilding per request makes each substitution see exactly the
153 /// captures that existed when it started, which is what "file order is real
154 /// order" has to mean.
155 ///
156 /// Disjoint from `variables` by construction: a capture whose name the file
157 /// already defines is refused where it happens, as
158 /// [`CaptureFailure::Shadowed`](crate::CaptureFailure::Shadowed), so it
159 /// never reaches this map.
160 captured: BTreeMap<String, String>,
161
162 /// Stands in for the OS environment when set.
163 ///
164 /// Tests need to know what `${VAR}` resolves to, and the alternative is
165 /// `std::env::set_var`, which is process-global: one test setting a
166 /// variable is visible to every other test running beside it. The config
167 /// module dodged the same trap by taking paths as arguments instead of
168 /// reading the working directory; this is that idea for the environment.
169 /// `None` — the only value production code ever builds — means the real OS
170 /// environment.
171 os_env_override: Option<BTreeMap<String, String>>,
172}
173
174impl Environment {
175 /// Parse an environment from a YAML string.
176 pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
177 let file = parse(yaml, SendraError::ParseStr)?;
178 Self::from_file(file, None)
179 }
180
181 /// Read and parse an environment file from disk.
182 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
183 let path = path.as_ref();
184 let raw = std::fs::read_to_string(path).map_err(|source| SendraError::EnvIo {
185 path: path.to_path_buf(),
186 source,
187 })?;
188 let file = parse(&raw, |source| SendraError::EnvParse {
189 path: path.to_path_buf(),
190 source,
191 })?;
192 Self::from_file(file, Some(path.to_path_buf()))
193 }
194
195 /// Common tail of [`from_yaml_str`](Self::from_yaml_str) and
196 /// [`from_path`](Self::from_path): validate the parsed `auth:` block, if
197 /// any, then assemble the runtime [`Environment`] around it.
198 ///
199 /// `auth`'s own mutual-exclusivity rule (`Auth::validate_exclusivity`) is
200 /// checked here, once, at parse time — the same point
201 /// [`Request::validate`](crate::Request::validate) checks it for a
202 /// request's own `auth:` block, since this is the exact same rule on the
203 /// exact same type. What it cannot check yet is a collision with a
204 /// request's headers/query, since there is no request in scope until
205 /// [`apply`](Self::apply) runs — see there.
206 fn from_file(file: EnvironmentFile, source: Option<PathBuf>) -> Result<Self, SendraError> {
207 if let Some(auth) = &file.auth {
208 if let Err(reason) = auth.validate_exclusivity() {
209 return Err(SendraError::InvalidEnvironment {
210 path: source,
211 reason,
212 });
213 }
214 if let Some(oauth) = &auth.oauth {
215 if let Err(reason) = oauth.validate_grant_fields() {
216 return Err(SendraError::InvalidEnvironment {
217 path: source,
218 reason,
219 });
220 }
221 }
222 }
223 Ok(Self {
224 variables: file.variables,
225 auth: file.auth,
226 source,
227 captured: BTreeMap::new(),
228 os_env_override: None,
229 })
230 }
231
232 /// Find and load the environment called `name`, starting from the current
233 /// directory.
234 ///
235 /// A missing environment file is not an error, it is the empty
236 /// environment — the same call the config module makes for a missing config
237 /// file. What *is* an error is a request asking for a variable the
238 /// environment does not have, empty or not; that surfaces in
239 /// [`Environment::apply`], where the message can name the variable.
240 pub fn resolve(name: &str) -> Result<Self, SendraError> {
241 let cwd = std::env::current_dir().map_err(SendraError::CurrentDir)?;
242 Self::resolve_from(&cwd, name)
243 }
244
245 /// [`Environment::resolve`] with the starting directory passed in, so the
246 /// search is testable against a temporary tree without changing the
247 /// process's working directory.
248 pub fn resolve_from(start_dir: &Path, name: &str) -> Result<Self, SendraError> {
249 match find_environment(start_dir, name) {
250 Some(path) => Self::from_path(path),
251 None => Ok(Self::default()),
252 }
253 }
254
255 /// This environment as it stands at one point in a run: the file's own
256 /// variables, plus everything captured by the requests that have already
257 /// finished.
258 ///
259 /// **This is the whole of the accumulating store.** A run holds one growing
260 /// map and calls this once per request, so the environment a request is
261 /// substituted against is a *view* built from the captures that existed
262 /// when that request was reached — request 3 sees what 1 and 2 captured,
263 /// request 1 sees nothing, and no request can see forwards. Threading the
264 /// growth through a rebuilt value rather than through a mutable
265 /// `Environment` is what makes that structural instead of a rule the loop
266 /// has to remember: there is no `&mut Environment` anywhere for a later
267 /// capture to reach an earlier request through.
268 ///
269 /// The copy is a `BTreeMap` clone per request, which is nothing at the
270 /// sizes a hand-written collection reaches, and it buys the property that
271 /// the value handed to [`apply`](Self::apply) cannot change underneath it.
272 ///
273 /// Nothing else changes: `source`, `auth`, and the OS-environment
274 /// override tests use, are carried through untouched.
275 pub fn with_captured(&self, captured: &BTreeMap<String, String>) -> Self {
276 Self {
277 variables: self.variables.clone(),
278 auth: self.auth.clone(),
279 source: self.source.clone(),
280 captured: captured.clone(),
281 os_env_override: self.os_env_override.clone(),
282 }
283 }
284
285 /// The variable names this environment's **file** defines, sorted — the
286 /// list a "no variable named X" error offers, the way
287 /// [`RequestNotFound`](SendraError::RequestNotFound) offers request names.
288 ///
289 /// Captured names are deliberately not in here: this list is offered under
290 /// the name of the file it came from, and a capture did not come from that
291 /// file. They are reported beside it — see
292 /// [`captured_names`](Self::captured_names).
293 pub fn names(&self) -> Vec<String> {
294 self.variables.keys().cloned().collect()
295 }
296
297 /// The names captured by earlier requests in this run, sorted. Empty
298 /// unless [`with_captured`](Self::with_captured) put something there.
299 pub fn captured_names(&self) -> Vec<String> {
300 self.captured.keys().cloned().collect()
301 }
302
303 /// Whether this environment defines no variables at all — captures
304 /// included, since a `{{name}}` can resolve against either.
305 pub fn is_empty(&self) -> bool {
306 self.variables.is_empty() && self.captured.is_empty()
307 }
308
309 /// The value of one variable, with any `${VAR}` in it resolved.
310 ///
311 /// Resolution is lazy — on use, not when the file is read — so an
312 /// environment listing five secrets does not demand all five from the OS
313 /// just to send the one request that needs one of them.
314 ///
315 /// The result is *not* re-scanned for `{{...}}`. Substitution is a single
316 /// pass by design: recursion would let one environment variable reference
317 /// another (a layering deliberately left out), and would let a value
318 /// fetched from the OS environment be read as a template rather than as
319 /// data.
320 fn lookup(&self, name: &str) -> Result<String, SendraError> {
321 // Captured first, and it costs nothing to be exact about why: the two
322 // maps are disjoint by construction, since a capture whose name the
323 // file already defines is refused at capture time rather than allowed
324 // to shadow it. So this order is a statement of that invariant, not a
325 // precedence rule — if it ever mattered, something upstream is broken.
326 if let Some(value) = self.captured.get(name) {
327 // **Not scanned for `${VAR}`.** A captured value is text that came
328 // back from a server, not a line someone wrote in an environment
329 // file, and a token that happens to contain `${` is data. This is
330 // the same single-pass rule the doc comment above states for
331 // `{{...}}`, applied to the other syntax.
332 return Ok(value.clone());
333 }
334
335 let value = self
336 .variables
337 .get(name)
338 .ok_or_else(|| SendraError::VariableNotFound {
339 name: name.to_string(),
340 available: self.names(),
341 environment: self.source.clone(),
342 captured: self.captured_names(),
343 })?;
344
345 expand(value, OS_VAR_OPEN, OS_VAR_CLOSE, |os_var| {
346 self.os_var(os_var, name)
347 })
348 }
349
350 /// Read `os_var` from the OS environment. `referenced_by` is the
351 /// environment-file variable whose value asked for it, so the error can say
352 /// where to look rather than only which variable is missing.
353 fn os_var(&self, os_var: &str, referenced_by: &str) -> Result<String, SendraError> {
354 let found = match &self.os_env_override {
355 Some(fixed) => fixed.get(os_var).cloned(),
356 None => std::env::var(os_var).ok(),
357 };
358
359 found.ok_or_else(|| SendraError::EnvVarNotSet {
360 name: os_var.to_string(),
361 variable: referenced_by.to_string(),
362 environment: self.source.clone(),
363 })
364 }
365
366 /// Every rule `Deserialize` cannot express, checked directly rather than
367 /// only ever at parse time: `auth`'s own mutual-exclusivity and (for
368 /// `oauth`) grant-field rules — the exact same checks
369 /// [`from_file`](Self::from_file) already runs when reading a file from
370 /// disk, exposed here so a caller building or mutating an `Environment`
371 /// in memory (a front-end applying an edit, say) can ask the question
372 /// before [`save_to_path`](Self::save_to_path) ever writes it, the same
373 /// "check before writing rather than only discover it broken on the next
374 /// load" reasoning [`Document::validate`](crate::Document::validate)
375 /// documents for its own callers. `variables` has no rule of its own to
376 /// check: every `BTreeMap<String, String>`, empty included, is already a
377 /// valid environment — see this module's own doc comment on why an empty
378 /// file is not an error.
379 pub fn validate(&self) -> Result<(), SendraError> {
380 let Some(auth) = &self.auth else {
381 return Ok(());
382 };
383 let invalid = |reason: String| {
384 Err(SendraError::InvalidEnvironment {
385 path: self.source.clone(),
386 reason,
387 })
388 };
389 if let Err(reason) = auth.validate_exclusivity() {
390 return invalid(reason);
391 }
392 if let Some(oauth) = &auth.oauth {
393 if let Err(reason) = oauth.validate_grant_fields() {
394 return invalid(reason);
395 }
396 }
397 Ok(())
398 }
399
400 /// Serializes this environment back to YAML, exactly the flat shape
401 /// [`from_yaml_str`](Self::from_yaml_str)/[`from_path`](Self::from_path)
402 /// parse: every variable as a top-level key, plus `auth:` when set — see
403 /// [`EnvironmentFile`]'s own `Serialize` impl for why this goes through
404 /// that type's hand-written map serialization rather than a derived one
405 /// on `Environment` itself. `captured`/`os_env_override` are runtime-only
406 /// (never part of a file — see their own doc comments) and so play no
407 /// part here; only `variables` and `auth` round-trip.
408 pub fn to_yaml_string(&self) -> Result<String, SendraError> {
409 let file = EnvironmentFile {
410 auth: self.auth.clone(),
411 variables: self.variables.clone(),
412 };
413 serde_yaml::to_string(&file).map_err(SendraError::Serialize)
414 }
415
416 /// Writes this environment back to `path`, atomically — the exact same
417 /// write-to-a-sibling-temp-file-then-rename guarantee
418 /// [`Document::save_to_path`](crate::Document::save_to_path) documents
419 /// for a collection file, reusing its own [`unique_temp_path`] helper
420 /// rather than a second implementation of the same atomicity argument.
421 /// Refuses to write an invalid environment (see
422 /// [`validate`](Self::validate)) before the temp file is even created,
423 /// for the same reason `Document::save_to_path` checks first: an
424 /// in-memory edit that left `auth` invalid would otherwise still produce
425 /// a file that parses back as YAML but fails validation the next time
426 /// anything loads it.
427 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SendraError> {
428 self.validate()?;
429
430 let path = path.as_ref();
431 let yaml = self.to_yaml_string()?;
432 let temp_path = unique_temp_path(path);
433
434 std::fs::write(&temp_path, yaml.as_bytes()).map_err(|source| SendraError::EnvSaveIo {
435 path: path.to_path_buf(),
436 source,
437 })?;
438
439 std::fs::rename(&temp_path, path).map_err(|source| {
440 let _ = std::fs::remove_file(&temp_path);
441 SendraError::EnvSaveIo {
442 path: path.to_path_buf(),
443 source,
444 }
445 })
446 }
447}
448
449/// The on-disk shape of one environment file: every top-level key is a
450/// variable, **except `auth`**, which is reserved for an optional default
451/// [`Auth`] block — see the module docs and [`Environment::auth`].
452///
453/// `auth` is the only top-level key this type gives a name to — every other
454/// key becomes a variable, the same way it always has — so a file with no
455/// `auth:` key behaves exactly like the flat `BTreeMap<String, String>` this
456/// used to deserialize straight into. The one behavior change this trades
457/// for that continuity: a project that happened to have a variable literally
458/// named `auth` now needs a different name, or its own `auth:` block
459/// instead.
460///
461/// Every variable value is a string, and an unquoted YAML scalar becomes
462/// exactly the text it was written as: `port: 8080` is the string `8080`,
463/// `flag: true` is `true`, `version: 1.0` is `1.0`. That is the only rule
464/// that makes sense for a substitution engine — what is in the file is what
465/// goes into the request, with no round trip through a number or a bool to
466/// round `1.0` down to `1` or to re-spell `true` as `True`. Quoting changes
467/// nothing, so `'8080'` is there for anyone who would rather be explicit.
468///
469/// A variable value that is a *sequence or a mapping* is a parse error, and
470/// that is the rule keeping environments flat: `staging:` with variables
471/// nested underneath fails to load rather than half-working — there is no
472/// environment inheritance. `auth:` is exempt from this — it is a mapping
473/// on purpose — but only `auth` is; any other nested key is still rejected
474/// exactly as before.
475///
476/// `Deserialize` is hand-written rather than `#[derive(Deserialize)]` with
477/// `#[serde(flatten)]` on `variables`: `flatten` deserializes the whole
478/// document through `serde`'s generic `Content` capture first, and that
479/// buffering loses `serde_yaml`'s laxness at the leaves — a captured integer
480/// or float no longer coerces to a string the way a value read straight off
481/// the source text does, and `serde_yaml::Value`'s own `Number` has the same
482/// problem (it does not even retain `1.0` vs `1` as written). Either would
483/// silently break every existing environment file with a bare number/bool
484/// value the moment `auth:` support was added — exactly the backward
485/// compatibility this format change is not allowed to cost. Walking the map
486/// by hand and calling `next_value::<String>()` per key, below, asks
487/// `serde_yaml` for a string directly off that key's own source node, which
488/// is the same call (and the same laxness) `BTreeMap<String, String>`'s own
489/// `Deserialize` impl has always made.
490#[derive(Debug, Clone, Default)]
491#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
492pub struct EnvironmentFile {
493 #[cfg_attr(feature = "schema", schemars(default))]
494 pub auth: Option<Auth>,
495 #[cfg_attr(feature = "schema", schemars(flatten))]
496 pub variables: BTreeMap<String, String>,
497}
498
499/// The exact mirror of [`EnvironmentFile`]'s hand-written `Deserialize`
500/// above: `auth` (when set) plus every variable, all as sibling top-level
501/// keys in one flat map — never `{auth: ..., variables: {...}}`, which is
502/// what a derived `Serialize` would produce and not a shape
503/// [`Deserialize`](struct@EnvironmentFile)'s own `Visitor` (or a hand-written
504/// environment file) recognises. Variables are written in `BTreeMap` order
505/// (i.e. sorted by name) — deterministic, and irrelevant to substitution,
506/// which looks values up by name rather than position. `auth` is written
507/// first when present, matching the position most hand-written environment
508/// files already put it in (see this module's own doc comment).
509impl Serialize for EnvironmentFile {
510 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
511 where
512 S: serde::Serializer,
513 {
514 use serde::ser::SerializeMap;
515
516 let mut map =
517 serializer.serialize_map(Some(self.variables.len() + self.auth.is_some() as usize))?;
518 if let Some(auth) = &self.auth {
519 map.serialize_entry("auth", auth)?;
520 }
521 for (name, value) in &self.variables {
522 map.serialize_entry(name, value)?;
523 }
524 map.end()
525 }
526}
527
528impl<'de> Deserialize<'de> for EnvironmentFile {
529 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
530 where
531 D: serde::Deserializer<'de>,
532 {
533 struct Visitor;
534
535 impl<'de> serde::de::Visitor<'de> for Visitor {
536 type Value = EnvironmentFile;
537
538 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539 f.write_str("a mapping of variable name to value, with an optional `auth` block")
540 }
541
542 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
543 where
544 A: serde::de::MapAccess<'de>,
545 {
546 let mut file = EnvironmentFile::default();
547 while let Some(key) = map.next_key::<String>()? {
548 if key == "auth" {
549 file.auth = Some(map.next_value::<Auth>()?);
550 } else {
551 file.variables.insert(key, map.next_value::<String>()?);
552 }
553 }
554 Ok(file)
555 }
556 }
557
558 deserializer.deserialize_map(Visitor)
559 }
560}
561
562/// Parse an environment file's raw shape — see [`EnvironmentFile`].
563fn parse(
564 yaml: &str,
565 wrap: impl Fn(serde_yaml::Error) -> SendraError,
566) -> Result<EnvironmentFile, SendraError> {
567 // An empty file, or one that is only comments, is YAML null. Creating the
568 // file before filling it in is too reasonable to be an error, so read it as
569 // an environment with no variables — the same call `ConfigFile` makes.
570 let probe: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(&wrap)?;
571 if probe.is_null() {
572 return Ok(EnvironmentFile::default());
573 }
574 serde_yaml::from_str(yaml).map_err(&wrap)
575}
576
577/// Walk up from `start_dir` looking for `.sendra/environments/<name>.yaml`,
578/// returning the first one found.
579///
580/// The same search, and the same "nearest wins, no stacking" rule, as
581/// [`find_project_config`](crate::config::find_project_config): an environment
582/// at the repository root applies from anywhere inside the repository.
583///
584/// There is no global equivalent. A config file holds preferences that travel
585/// with a person (a `User-Agent`, a timeout); an environment holds the hosts and
586/// keys of one particular API, which belongs to the project it describes, not to
587/// the machine that project is checked out on.
588pub fn find_environment(start_dir: &Path, name: &str) -> Option<PathBuf> {
589 start_dir
590 .ancestors()
591 .map(|dir| environment_path(dir, name))
592 .find(|candidate| candidate.is_file())
593}
594
595/// Where the environment called `name` lives for the project rooted at `root`.
596pub fn environment_path(root: &Path, name: &str) -> PathBuf {
597 root.join(PROJECT_DIR_NAME)
598 .join(ENVIRONMENTS_DIR_NAME)
599 .join(format!("{name}.yaml"))
600}
601
602/// Replace every `open`…`close` placeholder in `text` with whatever `resolve`
603/// returns for the name inside it.
604///
605/// Two things are deliberately *not* errors, because each is far likelier to be
606/// text that happens to contain a brace than a mistyped placeholder: an
607/// unterminated `open` (the rest of the string is literal), and an empty name
608/// such as `{{}}` (emitted as written — there is no variable to name in a "no
609/// variable named ``" message). A `{{name}}` with a real name in it, on the
610/// other hand, is unambiguously a reference, and must resolve or fail.
611///
612/// Substitution is not recursive: what `resolve` hands back is copied out
613/// verbatim and never scanned again.
614fn expand(
615 text: &str,
616 open: &str,
617 close: &str,
618 mut resolve: impl FnMut(&str) -> Result<String, SendraError>,
619) -> Result<String, SendraError> {
620 if !text.contains(open) {
621 return Ok(text.to_string());
622 }
623
624 let mut out = String::with_capacity(text.len());
625 let mut rest = text;
626
627 while let Some(start) = rest.find(open) {
628 let after_open = &rest[start + open.len()..];
629 let Some(end) = after_open.find(close) else {
630 // Unterminated: nothing left in the string can be a placeholder.
631 break;
632 };
633
634 let name = after_open[..end].trim();
635 if name.is_empty() {
636 // Keep the delimiter as written and carry on looking after it.
637 out.push_str(&rest[..start + open.len()]);
638 rest = after_open;
639 continue;
640 }
641
642 out.push_str(&rest[..start]);
643 out.push_str(&resolve(name)?);
644 rest = &after_open[end + close.len()..];
645 }
646
647 out.push_str(rest);
648 Ok(out)
649}
650
651/// "`path/to/env.yaml` (available: a, b)" and its awkward cases, for the message
652/// [`VariableNotFound`](SendraError::VariableNotFound) shows.
653pub(crate) fn describe_variables(environment: &Option<PathBuf>, available: &[String]) -> String {
654 match (environment, available.is_empty()) {
655 (Some(path), false) => {
656 format!("`{}` (available: {})", path.display(), available.join(", "))
657 }
658 (Some(path), true) => format!("`{}`, which defines no variables", path.display()),
659 (None, false) => format!(
660 "the active environment (available: {})",
661 available.join(", ")
662 ),
663 (None, true) => "the active environment: no environment file was found".to_string(),
664 }
665}
666
667/// The " or captured earlier in this run (...)" half of a
668/// [`VariableNotFound`](SendraError::VariableNotFound) message, or nothing at
669/// all when this run has captured nothing.
670///
671/// Its own clause rather than extra entries in `available`, because the two
672/// lists have different answers to "where do I go to add this name": one is a
673/// file to edit, the other is a `capture:` block on an earlier request. A run
674/// with no captures produces the empty string, so the message every single
675/// request has ever printed is unchanged.
676pub(crate) fn describe_captured(captured: &[String]) -> String {
677 if captured.is_empty() {
678 String::new()
679 } else {
680 format!(" — captured so far in this run: {}", captured.join(", "))
681 }
682}
683
684/// "`path/to/env.yaml`", or a stand-in when the environment came from nowhere.
685pub(crate) fn describe_environment(environment: &Option<PathBuf>) -> String {
686 match environment {
687 Some(path) => format!("`{}`", path.display()),
688 None => "the active environment".to_string(),
689 }
690}
691
692#[cfg(test)]
693pub(crate) mod test_helpers {
694 use super::Environment;
695 use std::collections::BTreeMap;
696
697 /// An environment built in memory, with a fixed stand-in for the OS
698 /// environment so `${VAR}` is testable without `std::env::set_var`.
699 pub(crate) fn environment(variables: &[(&str, &str)], os_env: &[(&str, &str)]) -> Environment {
700 Environment {
701 variables: pairs(variables),
702 auth: None,
703 source: None,
704 captured: BTreeMap::new(),
705 os_env_override: Some(pairs(os_env)),
706 }
707 }
708
709 pub(crate) fn pairs(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
710 entries
711 .iter()
712 .map(|(key, value)| (key.to_string(), value.to_string()))
713 .collect()
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use super::test_helpers::{environment, pairs};
720 use super::*;
721
722 use crate::Request;
723
724 /// Write `contents` to `path`, creating the directories above it.
725 fn write(path: &Path, contents: &str) {
726 std::fs::create_dir_all(path.parent().expect("a file has a parent")).unwrap();
727 std::fs::write(path, contents).unwrap();
728 }
729
730 #[test]
731 fn a_missing_variable_is_a_typed_error_listing_what_is_available() {
732 let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}/x'\n").unwrap();
733 let environment = environment(&[("host", "example.com"), ("port", "443")], &[]);
734
735 let err = environment
736 .apply(&request)
737 .expect_err("`base_url` is not defined");
738
739 match &err {
740 SendraError::VariableNotFound {
741 name, available, ..
742 } => {
743 assert_eq!(name, "base_url");
744 assert_eq!(available, &["host".to_string(), "port".to_string()]);
745 }
746 other => panic!("expected VariableNotFound, got {other:?}"),
747 }
748 // Not a panic, and not a silent empty string: the message names the
749 // variable and offers the ones that do exist.
750 let message = err.to_string();
751 assert!(message.contains("base_url"), "got {message}");
752 assert!(message.contains("host, port"), "got {message}");
753 }
754
755 #[test]
756 fn a_missing_variable_error_names_the_environment_file_it_looked_in() {
757 let temp = tempfile::tempdir().unwrap();
758 let path = environment_path(temp.path(), "staging");
759 write(&path, "host: example.com\n");
760
761 let environment = Environment::from_path(&path).unwrap();
762 let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}'\n").unwrap();
763
764 let err = environment.apply(&request).unwrap_err();
765 match &err {
766 SendraError::VariableNotFound { environment, .. } => {
767 assert_eq!(environment.as_deref(), Some(path.as_path()))
768 }
769 other => panic!("expected VariableNotFound, got {other:?}"),
770 }
771 assert!(
772 err.to_string().contains("staging.yaml"),
773 "the message should name the file to fix: {err}"
774 );
775 }
776
777 #[test]
778 fn a_missing_variable_with_no_environment_file_says_so() {
779 let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}'\n").unwrap();
780 let err = Environment::default().apply(&request).unwrap_err();
781 let message = err.to_string();
782 assert!(message.contains("base_url"), "got {message}");
783 assert!(
784 message.contains("no environment file was found"),
785 "an empty available-list must not read as `(available: )`: {message}"
786 );
787 }
788
789 #[test]
790 fn an_os_variable_is_read_from_the_environment_at_use_time() {
791 let request = Request::from_yaml_str(
792 "method: GET\nurl: https://example.com\nheaders:\n Authorization: '{{api_key}}'\n",
793 )
794 .unwrap();
795 // The environment file holds the *reference*, never the secret.
796 let environment = environment(&[("api_key", "${API_KEY}")], &[("API_KEY", "live-token")]);
797
798 let applied = environment.apply(&request).unwrap();
799
800 assert_eq!(applied.header("Authorization"), Some("live-token"));
801 }
802
803 #[test]
804 fn an_os_variable_can_be_embedded_in_a_larger_value() {
805 let request = Request::from_yaml_str(
806 "method: GET\nurl: https://example.com\nheaders:\n Authorization: '{{auth}}'\n",
807 )
808 .unwrap();
809 let environment = environment(&[("auth", "Bearer ${API_KEY}!")], &[("API_KEY", "abc")]);
810
811 let applied = environment.apply(&request).unwrap();
812 assert_eq!(applied.header("Authorization"), Some("Bearer abc!"));
813 }
814
815 #[test]
816 fn a_missing_os_variable_is_a_typed_error_not_an_empty_string() {
817 let request = Request::from_yaml_str("method: GET\nurl: '{{host}}'\n").unwrap();
818 // Nothing in the stand-in OS environment, so `${API_KEY}` has no value.
819 let environment = environment(&[("host", "https://x/${API_KEY}")], &[]);
820
821 let err = environment.apply(&request).expect_err("API_KEY is not set");
822 match &err {
823 SendraError::EnvVarNotSet { name, variable, .. } => {
824 assert_eq!(name, "API_KEY");
825 // The error says which environment variable pulled it in, so
826 // there is somewhere to go and look.
827 assert_eq!(variable, "host");
828 }
829 other => panic!("expected EnvVarNotSet, got {other:?}"),
830 }
831 let message = err.to_string();
832 assert!(message.contains("API_KEY"), "got {message}");
833 }
834
835 #[test]
836 fn a_missing_os_variable_is_reported_against_the_real_os_environment_too() {
837 // The tests above use the stand-in; this one exercises the real
838 // `std::env` path with a name nothing could plausibly have set. It
839 // reads the environment and never writes it, so it is safe beside
840 // every other test in the suite.
841 let request = Request::from_yaml_str("method: GET\nurl: '{{token}}'\n").unwrap();
842 let environment = Environment {
843 variables: pairs(&[("token", "${SENDRA_TEST_DEFINITELY_NOT_SET_9F3A}")]),
844 auth: None,
845 source: None,
846 captured: BTreeMap::new(),
847 os_env_override: None,
848 };
849
850 let err = environment.apply(&request).expect_err("no such variable");
851 assert!(
852 matches!(err, SendraError::EnvVarNotSet { .. }),
853 "got {err:?}"
854 );
855 }
856
857 #[test]
858 fn an_unused_variable_with_a_missing_os_variable_does_not_fail_the_run() {
859 // Resolution is lazy: an environment listing five secrets must not
860 // demand all five to send the one request that needs none of them.
861 let request = Request::from_yaml_str("method: GET\nurl: '{{host}}'\n").unwrap();
862 let environment = environment(
863 &[("host", "https://example.com"), ("unused", "${NOT_SET}")],
864 &[],
865 );
866
867 let applied = environment.apply(&request).expect("`unused` is not used");
868 assert_eq!(applied.url, "https://example.com");
869 }
870
871 #[test]
872 fn parses_a_flat_environment_file() {
873 let environment = Environment::from_yaml_str(
874 "base_url: https://staging.example.com\napi_key: ${API_KEY}\n",
875 )
876 .unwrap();
877
878 assert_eq!(environment.names(), vec!["api_key", "base_url"]);
879 // Stored verbatim: `${API_KEY}` is resolved on use, not on read, so the
880 // secret is never held in the parsed file.
881 assert_eq!(
882 environment.variables.get("api_key").map(String::as_str),
883 Some("${API_KEY}")
884 );
885 }
886
887 #[test]
888 fn an_empty_environment_file_is_an_empty_environment_not_an_error() {
889 let environment = Environment::from_yaml_str("# nothing yet\n")
890 .expect("creating the file before filling it in is reasonable");
891 assert!(environment.is_empty());
892 }
893
894 #[test]
895 fn an_unquoted_scalar_substitutes_as_the_text_it_was_written_as() {
896 // The property that matters for a substitution engine: no value takes a
897 // round trip through a number or a bool on the way in, so `1.0` cannot
898 // arrive as `1`, and quoting is a matter of taste rather than of meaning.
899 let environment =
900 Environment::from_yaml_str("port: 8080\nquoted: '8080'\nversion: 1.0\nflag: true\n")
901 .expect("a plain scalar is a perfectly good variable value");
902
903 for (name, expected) in [
904 ("port", "8080"),
905 ("quoted", "8080"),
906 ("version", "1.0"),
907 ("flag", "true"),
908 ] {
909 assert_eq!(
910 environment.variables.get(name).map(String::as_str),
911 Some(expected),
912 "`{name}` should substitute as written"
913 );
914 }
915 }
916
917 #[test]
918 fn a_nested_environment_file_is_rejected() {
919 // Flat files only; "staging extends base" is a non-goal, and a
920 // parse error is a better answer than half-supporting it.
921 let err = Environment::from_yaml_str("staging:\n base_url: https://x\n")
922 .expect_err("environments do not nest");
923 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
924
925 let err = Environment::from_yaml_str("hosts:\n - https://x\n")
926 .expect_err("a variable is one value, not a list");
927 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
928 }
929
930 #[test]
931 fn an_auth_block_parses_alongside_ordinary_variables() {
932 let environment = Environment::from_yaml_str(
933 "base_url: https://staging.example.com\nauth:\n bearer: '{{token}}'\n",
934 )
935 .unwrap();
936
937 // `auth` is reserved, not folded into `variables` as an ordinary
938 // entry — the same way every other top-level key still is.
939 assert_eq!(environment.names(), vec!["base_url"]);
940 assert!(!environment.variables.contains_key("auth"));
941 let auth = environment.auth.expect("the auth block was parsed");
942 assert_eq!(auth.bearer.as_deref(), Some("{{token}}"));
943 }
944
945 #[test]
946 fn an_environment_file_with_no_auth_key_leaves_auth_none() {
947 let environment = Environment::from_yaml_str("base_url: https://example.com\n").unwrap();
948 assert!(environment.auth.is_none());
949 }
950
951 #[test]
952 fn an_auth_value_that_is_not_a_mapping_is_a_typed_error() {
953 let err = Environment::from_yaml_str("auth: not-a-mapping\n")
954 .expect_err("auth must be a bearer/basic/api_key mapping");
955 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
956 }
957
958 #[test]
959 fn an_auth_block_naming_an_unknown_field_is_a_typed_error() {
960 let err = Environment::from_yaml_str("auth:\n bogus: x\n")
961 .expect_err("Auth::deny_unknown_fields rejects it");
962 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
963 }
964
965 // --- Serializing back to YAML / saving to disk ------------------------
966
967 #[test]
968 fn to_yaml_string_round_trips_variables_and_auth() {
969 let mut environment = Environment::from_yaml_str(
970 "base_url: https://staging.example.com\nauth:\n bearer: '{{token}}'\n",
971 )
972 .unwrap();
973 environment
974 .variables
975 .insert("port".to_string(), "443".to_string());
976
977 let yaml = environment.to_yaml_string().unwrap();
978 let reloaded = Environment::from_yaml_str(&yaml).unwrap();
979
980 assert_eq!(reloaded.variables, environment.variables);
981 assert_eq!(reloaded.auth, environment.auth);
982 }
983
984 #[test]
985 fn to_yaml_string_of_an_empty_environment_round_trips_to_the_same_empty_environment() {
986 // The zero-variables case: an environment with nothing in it must
987 // serialize and reload cleanly, not as some special "empty" marker —
988 // see this module's own doc comment on why an empty file is not an
989 // error.
990 let environment = Environment::default();
991 let yaml = environment.to_yaml_string().unwrap();
992 let reloaded = Environment::from_yaml_str(&yaml).unwrap();
993
994 assert!(reloaded.variables.is_empty());
995 assert!(reloaded.auth.is_none());
996 }
997
998 #[test]
999 fn save_to_path_writes_the_environment_and_a_reload_from_disk_matches() {
1000 let temp = tempfile::tempdir().unwrap();
1001 let path = environment_path(temp.path(), "staging");
1002 write(&path, "base_url: https://old.example.com\n");
1003
1004 let mut environment = Environment::from_path(&path).unwrap();
1005 environment.variables.insert(
1006 "base_url".to_string(),
1007 "https://new.example.com".to_string(),
1008 );
1009 environment
1010 .variables
1011 .insert("token".to_string(), "abc123".to_string());
1012
1013 environment.save_to_path(&path).unwrap();
1014
1015 // The proof itself: a fresh `Environment::from_path`, not anything
1016 // still held in memory.
1017 let reloaded = Environment::from_path(&path).unwrap();
1018 assert_eq!(
1019 reloaded.variables.get("base_url").map(String::as_str),
1020 Some("https://new.example.com")
1021 );
1022 assert_eq!(
1023 reloaded.variables.get("token").map(String::as_str),
1024 Some("abc123")
1025 );
1026 }
1027
1028 #[test]
1029 fn save_to_path_can_write_an_environment_down_to_zero_variables() {
1030 // The other half of the zero-variables investigation, against real
1031 // disk this time: deleting every variable and saving must produce a
1032 // file that reloads as a real, valid, empty environment — never an
1033 // error, and never a file that fails to write at all. Unlike
1034 // `Document::Collection` (which `Collection::validate` refuses to
1035 // save empty), `Environment` has no such rule: a flat map has no
1036 // "must have at least one entry" requirement.
1037 let temp = tempfile::tempdir().unwrap();
1038 let path = environment_path(temp.path(), "staging");
1039 write(&path, "base_url: https://example.com\n");
1040
1041 let mut environment = Environment::from_path(&path).unwrap();
1042 environment.variables.clear();
1043
1044 environment
1045 .save_to_path(&path)
1046 .expect("saving down to zero variables must succeed");
1047
1048 let reloaded = Environment::from_path(&path).unwrap();
1049 assert!(reloaded.variables.is_empty());
1050 assert!(reloaded.auth.is_none());
1051 }
1052
1053 #[test]
1054 fn save_to_path_can_write_an_environment_up_from_zero_variables() {
1055 let temp = tempfile::tempdir().unwrap();
1056 let path = environment_path(temp.path(), "staging");
1057 write(&path, "");
1058
1059 let mut environment = Environment::from_path(&path).unwrap();
1060 assert!(environment.variables.is_empty());
1061 environment
1062 .variables
1063 .insert("base_url".to_string(), "https://example.com".to_string());
1064
1065 environment.save_to_path(&path).unwrap();
1066
1067 let reloaded = Environment::from_path(&path).unwrap();
1068 assert_eq!(
1069 reloaded.variables.get("base_url").map(String::as_str),
1070 Some("https://example.com")
1071 );
1072 }
1073
1074 #[test]
1075 fn save_to_path_leaves_no_temp_file_behind_on_success() {
1076 let temp = tempfile::tempdir().unwrap();
1077 let path = environment_path(temp.path(), "staging");
1078 write(&path, "base_url: https://example.com\n");
1079
1080 Environment::from_path(&path)
1081 .unwrap()
1082 .save_to_path(&path)
1083 .unwrap();
1084
1085 let entries: Vec<_> = std::fs::read_dir(path.parent().unwrap())
1086 .unwrap()
1087 .filter_map(Result::ok)
1088 .map(|entry| entry.file_name())
1089 .collect();
1090 assert_eq!(
1091 entries,
1092 vec![path.file_name().unwrap().to_os_string()],
1093 "no stray sendra-tmp- file should be left behind: {entries:?}"
1094 );
1095 }
1096
1097 #[test]
1098 fn save_to_path_refuses_to_write_an_invalid_environment_and_touches_nothing() {
1099 let temp = tempfile::tempdir().unwrap();
1100 let path = environment_path(temp.path(), "staging");
1101 let original = "base_url: https://example.com\nauth:\n bearer: '{{token}}'\n";
1102 write(&path, original);
1103
1104 let mut environment = Environment::from_path(&path).unwrap();
1105 // Break `auth`'s own mutual-exclusivity rule directly on the
1106 // in-memory value — `Auth`'s own fields are `pub`, so this needs no
1107 // API beyond what `save_to_path`'s caller already has.
1108 environment.auth.as_mut().unwrap().basic = Some(crate::BasicAuth {
1109 user: "u".to_string(),
1110 pass: "p".to_string(),
1111 });
1112
1113 let err = environment
1114 .save_to_path(&path)
1115 .expect_err("bearer + basic together must be refused");
1116 assert!(
1117 matches!(err, SendraError::InvalidEnvironment { .. }),
1118 "got {err:?}"
1119 );
1120
1121 // Untouched: still exactly the original bytes.
1122 let on_disk = std::fs::read_to_string(&path).unwrap();
1123 assert_eq!(on_disk, original);
1124 }
1125
1126 #[test]
1127 fn malformed_yaml_in_an_environment_file_is_a_typed_error_carrying_the_path() {
1128 let temp = tempfile::tempdir().unwrap();
1129 let path = environment_path(temp.path(), "default");
1130 write(&path, "base_url: [oops\n");
1131
1132 let err = Environment::resolve_from(temp.path(), "default")
1133 .expect_err("malformed yaml must error");
1134 match err {
1135 SendraError::EnvParse { path: reported, .. } => assert_eq!(reported, path),
1136 other => panic!("expected EnvParse, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn a_missing_environment_file_is_the_empty_environment_not_an_error() {
1142 let temp = tempfile::tempdir().unwrap();
1143 let environment = Environment::resolve_from(temp.path(), "default")
1144 .expect("no environment file is an ordinary state");
1145 assert_eq!(environment, Environment::default());
1146 assert!(environment.source.is_none());
1147 }
1148
1149 #[test]
1150 fn the_environment_at_the_project_root_is_found_from_a_nested_subdirectory() {
1151 let temp = tempfile::tempdir().unwrap();
1152 let root = temp.path().join("project");
1153 let path = environment_path(&root, "default");
1154 write(&path, "base_url: https://example.com\n");
1155
1156 let nested = root.join("crates").join("api").join("tests");
1157 std::fs::create_dir_all(&nested).unwrap();
1158
1159 let environment = Environment::resolve_from(&nested, "default").unwrap();
1160 assert_eq!(environment.source.as_deref(), Some(path.as_path()));
1161 assert_eq!(
1162 environment.variables.get("base_url").map(String::as_str),
1163 Some("https://example.com")
1164 );
1165 }
1166
1167 #[test]
1168 fn environments_are_selected_by_name() {
1169 let temp = tempfile::tempdir().unwrap();
1170 write(
1171 &environment_path(temp.path(), "staging"),
1172 "base_url: https://staging.example.com\n",
1173 );
1174 write(
1175 &environment_path(temp.path(), "prod"),
1176 "base_url: https://api.example.com\n",
1177 );
1178
1179 for (name, expected) in [
1180 ("staging", "https://staging.example.com"),
1181 ("prod", "https://api.example.com"),
1182 ] {
1183 let environment = Environment::resolve_from(temp.path(), name).unwrap();
1184 assert_eq!(
1185 environment.variables.get("base_url").map(String::as_str),
1186 Some(expected),
1187 "`{name}` should have loaded its own file"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn the_nearest_environment_wins_over_one_further_up() {
1194 let temp = tempfile::tempdir().unwrap();
1195 let outer = temp.path().join("outer");
1196 write(&environment_path(&outer, "default"), "which: outer\n");
1197 let inner = outer.join("inner");
1198 write(&environment_path(&inner, "default"), "which: inner\n");
1199
1200 let environment = Environment::resolve_from(&inner, "default").unwrap();
1201 assert_eq!(
1202 environment.variables.get("which").map(String::as_str),
1203 Some("inner")
1204 );
1205 }
1206
1207 #[test]
1208 fn the_default_environment_lives_where_the_readme_says_it_does() {
1209 // The layout is a documented path, so pin it: naming a different
1210 // environment changes which file is read, never where it lives.
1211 let path = environment_path(Path::new("/project"), DEFAULT_ENVIRONMENT_NAME);
1212 assert!(
1213 path.ends_with(Path::new(".sendra/environments/default.yaml")),
1214 "got {}",
1215 path.display()
1216 );
1217 }
1218
1219 // --- captured variables ----------------------------------------------
1220
1221 /// The store as it stands after one request captured `auth_token`.
1222 fn captured(pairs_in: &[(&str, &str)]) -> BTreeMap<String, String> {
1223 pairs(pairs_in)
1224 }
1225
1226 #[test]
1227 fn a_captured_variable_substitutes_exactly_like_a_file_one() {
1228 let request = Request::from_yaml_str(
1229 "method: GET
1230url: '{{base_url}}/me?t={{auth_token}}'
1231",
1232 )
1233 .unwrap();
1234 let environment = environment(&[("base_url", "https://example.com")], &[]);
1235
1236 // Before anything is captured the reference has nothing behind it...
1237 assert!(environment.apply(&request).is_err());
1238
1239 // ...and once it does, it resolves through the same single pass.
1240 let view = environment.with_captured(&captured(&[("auth_token", "abc123")]));
1241 let applied = view.apply(&request).expect("both variables resolve");
1242 assert_eq!(applied.url, "https://example.com/me?t=abc123");
1243 }
1244
1245 #[test]
1246 fn a_view_never_changes_the_environment_it_was_built_from() {
1247 // The property the run loop depends on: request 1 substitutes against
1248 // an environment that a later `with_captured` cannot reach back into.
1249 let environment = environment(&[("base_url", "https://example.com")], &[]);
1250 let view = environment.with_captured(&captured(&[("token", "t")]));
1251
1252 assert_eq!(view.captured_names(), vec!["token".to_string()]);
1253 assert!(
1254 environment.captured_names().is_empty(),
1255 "the original must not have grown a capture"
1256 );
1257 assert!(environment
1258 .apply(
1259 &Request::from_yaml_str(
1260 "method: GET
1261url: '{{token}}'
1262"
1263 )
1264 .unwrap()
1265 )
1266 .is_err());
1267 }
1268
1269 #[test]
1270 fn a_captured_value_is_data_and_is_never_read_as_a_reference() {
1271 // A token that happens to contain `${...}` or `{{...}}` is text a
1272 // server sent, not a line someone wrote in a file: substitution is one
1273 // pass and what it hands back is copied out verbatim.
1274 let request = Request::from_yaml_str(
1275 "method: GET
1276url: 'https://x/{{token}}'
1277",
1278 )
1279 .unwrap();
1280 let view = environment(&[], &[("HOME", "/root")])
1281 .with_captured(&captured(&[("token", "${HOME}-{{base_url}}")]));
1282
1283 let applied = view.apply(&request).expect("the captured value is data");
1284 assert_eq!(applied.url, "https://x/${HOME}-{{base_url}}");
1285 }
1286
1287 #[test]
1288 fn a_missing_variable_names_what_was_captured_as_well_as_what_the_file_has() {
1289 let request = Request::from_yaml_str(
1290 "method: GET
1291url: '{{nope}}'
1292",
1293 )
1294 .unwrap();
1295 let view = environment(&[("base_url", "https://example.com")], &[])
1296 .with_captured(&captured(&[("auth_token", "abc")]));
1297
1298 let err = view.apply(&request).expect_err("`nope` is neither");
1299 match &err {
1300 SendraError::VariableNotFound {
1301 available,
1302 captured,
1303 ..
1304 } => {
1305 // Kept in separate lists: one names a file to edit, the other
1306 // names a `capture:` block on an earlier request.
1307 assert_eq!(available, &["base_url".to_string()]);
1308 assert_eq!(captured, &["auth_token".to_string()]);
1309 }
1310 other => panic!("expected VariableNotFound, got {other:?}"),
1311 }
1312
1313 let message = err.to_string();
1314 assert!(message.contains("base_url"), "got {message}");
1315 assert!(message.contains("auth_token"), "got {message}");
1316 }
1317
1318 #[test]
1319 fn a_run_that_captured_nothing_prints_the_message_it_always_printed() {
1320 // The clause is additive: nothing captured, nothing said about it.
1321 let request = Request::from_yaml_str(
1322 "method: GET
1323url: '{{nope}}'
1324",
1325 )
1326 .unwrap();
1327 let message = environment(&[("base_url", "x")], &[])
1328 .apply(&request)
1329 .unwrap_err()
1330 .to_string();
1331 assert!(!message.contains("captured"), "got {message}");
1332 }
1333}