Skip to main content

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}