mod substitute;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::collection::unique_temp_path;
use crate::config::PROJECT_DIR_NAME;
use crate::{Auth, SendraError};
const ENVIRONMENTS_DIR_NAME: &str = "environments";
pub const DEFAULT_ENVIRONMENT_NAME: &str = "default";
const OS_VAR_OPEN: &str = "${";
const OS_VAR_CLOSE: &str = "}";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Environment {
pub variables: BTreeMap<String, String>,
pub auth: Option<Auth>,
pub source: Option<PathBuf>,
captured: BTreeMap<String, String>,
os_env_override: Option<BTreeMap<String, String>>,
}
impl Environment {
pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
let file = parse(yaml, SendraError::ParseStr)?;
Self::from_file(file, None)
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| SendraError::EnvIo {
path: path.to_path_buf(),
source,
})?;
let file = parse(&raw, |source| SendraError::EnvParse {
path: path.to_path_buf(),
source,
})?;
Self::from_file(file, Some(path.to_path_buf()))
}
fn from_file(file: EnvironmentFile, source: Option<PathBuf>) -> Result<Self, SendraError> {
if let Some(auth) = &file.auth {
if let Err(reason) = auth.validate_exclusivity() {
return Err(SendraError::InvalidEnvironment {
path: source,
reason,
});
}
if let Some(oauth) = &auth.oauth {
if let Err(reason) = oauth.validate_grant_fields() {
return Err(SendraError::InvalidEnvironment {
path: source,
reason,
});
}
}
}
Ok(Self {
variables: file.variables,
auth: file.auth,
source,
captured: BTreeMap::new(),
os_env_override: None,
})
}
pub fn resolve(name: &str) -> Result<Self, SendraError> {
let cwd = std::env::current_dir().map_err(SendraError::CurrentDir)?;
Self::resolve_from(&cwd, name)
}
pub fn resolve_from(start_dir: &Path, name: &str) -> Result<Self, SendraError> {
match find_environment(start_dir, name) {
Some(path) => Self::from_path(path),
None => Ok(Self::default()),
}
}
pub fn with_captured(&self, captured: &BTreeMap<String, String>) -> Self {
Self {
variables: self.variables.clone(),
auth: self.auth.clone(),
source: self.source.clone(),
captured: captured.clone(),
os_env_override: self.os_env_override.clone(),
}
}
pub fn names(&self) -> Vec<String> {
self.variables.keys().cloned().collect()
}
pub fn captured_names(&self) -> Vec<String> {
self.captured.keys().cloned().collect()
}
pub fn is_empty(&self) -> bool {
self.variables.is_empty() && self.captured.is_empty()
}
fn lookup(&self, name: &str) -> Result<String, SendraError> {
if let Some(value) = self.captured.get(name) {
return Ok(value.clone());
}
let value = self
.variables
.get(name)
.ok_or_else(|| SendraError::VariableNotFound {
name: name.to_string(),
available: self.names(),
environment: self.source.clone(),
captured: self.captured_names(),
})?;
expand(value, OS_VAR_OPEN, OS_VAR_CLOSE, |os_var| {
self.os_var(os_var, name)
})
}
fn os_var(&self, os_var: &str, referenced_by: &str) -> Result<String, SendraError> {
let found = match &self.os_env_override {
Some(fixed) => fixed.get(os_var).cloned(),
None => std::env::var(os_var).ok(),
};
found.ok_or_else(|| SendraError::EnvVarNotSet {
name: os_var.to_string(),
variable: referenced_by.to_string(),
environment: self.source.clone(),
})
}
pub fn validate(&self) -> Result<(), SendraError> {
let Some(auth) = &self.auth else {
return Ok(());
};
let invalid = |reason: String| {
Err(SendraError::InvalidEnvironment {
path: self.source.clone(),
reason,
})
};
if let Err(reason) = auth.validate_exclusivity() {
return invalid(reason);
}
if let Some(oauth) = &auth.oauth {
if let Err(reason) = oauth.validate_grant_fields() {
return invalid(reason);
}
}
Ok(())
}
pub fn to_yaml_string(&self) -> Result<String, SendraError> {
let file = EnvironmentFile {
auth: self.auth.clone(),
variables: self.variables.clone(),
};
serde_yaml::to_string(&file).map_err(SendraError::Serialize)
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SendraError> {
self.validate()?;
let path = path.as_ref();
let yaml = self.to_yaml_string()?;
let temp_path = unique_temp_path(path);
std::fs::write(&temp_path, yaml.as_bytes()).map_err(|source| SendraError::EnvSaveIo {
path: path.to_path_buf(),
source,
})?;
std::fs::rename(&temp_path, path).map_err(|source| {
let _ = std::fs::remove_file(&temp_path);
SendraError::EnvSaveIo {
path: path.to_path_buf(),
source,
}
})
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EnvironmentFile {
#[cfg_attr(feature = "schema", schemars(default))]
pub auth: Option<Auth>,
#[cfg_attr(feature = "schema", schemars(flatten))]
pub variables: BTreeMap<String, String>,
}
impl Serialize for EnvironmentFile {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut map =
serializer.serialize_map(Some(self.variables.len() + self.auth.is_some() as usize))?;
if let Some(auth) = &self.auth {
map.serialize_entry("auth", auth)?;
}
for (name, value) in &self.variables {
map.serialize_entry(name, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for EnvironmentFile {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = EnvironmentFile;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a mapping of variable name to value, with an optional `auth` block")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut file = EnvironmentFile::default();
while let Some(key) = map.next_key::<String>()? {
if key == "auth" {
file.auth = Some(map.next_value::<Auth>()?);
} else {
file.variables.insert(key, map.next_value::<String>()?);
}
}
Ok(file)
}
}
deserializer.deserialize_map(Visitor)
}
}
fn parse(
yaml: &str,
wrap: impl Fn(serde_yaml::Error) -> SendraError,
) -> Result<EnvironmentFile, SendraError> {
let probe: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(&wrap)?;
if probe.is_null() {
return Ok(EnvironmentFile::default());
}
serde_yaml::from_str(yaml).map_err(&wrap)
}
pub fn find_environment(start_dir: &Path, name: &str) -> Option<PathBuf> {
start_dir
.ancestors()
.map(|dir| environment_path(dir, name))
.find(|candidate| candidate.is_file())
}
pub fn environment_path(root: &Path, name: &str) -> PathBuf {
root.join(PROJECT_DIR_NAME)
.join(ENVIRONMENTS_DIR_NAME)
.join(format!("{name}.yaml"))
}
fn expand(
text: &str,
open: &str,
close: &str,
mut resolve: impl FnMut(&str) -> Result<String, SendraError>,
) -> Result<String, SendraError> {
if !text.contains(open) {
return Ok(text.to_string());
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find(open) {
let after_open = &rest[start + open.len()..];
let Some(end) = after_open.find(close) else {
break;
};
let name = after_open[..end].trim();
if name.is_empty() {
out.push_str(&rest[..start + open.len()]);
rest = after_open;
continue;
}
out.push_str(&rest[..start]);
out.push_str(&resolve(name)?);
rest = &after_open[end + close.len()..];
}
out.push_str(rest);
Ok(out)
}
pub(crate) fn describe_variables(environment: &Option<PathBuf>, available: &[String]) -> String {
match (environment, available.is_empty()) {
(Some(path), false) => {
format!("`{}` (available: {})", path.display(), available.join(", "))
}
(Some(path), true) => format!("`{}`, which defines no variables", path.display()),
(None, false) => format!(
"the active environment (available: {})",
available.join(", ")
),
(None, true) => "the active environment: no environment file was found".to_string(),
}
}
pub(crate) fn describe_captured(captured: &[String]) -> String {
if captured.is_empty() {
String::new()
} else {
format!(" — captured so far in this run: {}", captured.join(", "))
}
}
pub(crate) fn describe_environment(environment: &Option<PathBuf>) -> String {
match environment {
Some(path) => format!("`{}`", path.display()),
None => "the active environment".to_string(),
}
}
#[cfg(test)]
pub(crate) mod test_helpers {
use super::Environment;
use std::collections::BTreeMap;
pub(crate) fn environment(variables: &[(&str, &str)], os_env: &[(&str, &str)]) -> Environment {
Environment {
variables: pairs(variables),
auth: None,
source: None,
captured: BTreeMap::new(),
os_env_override: Some(pairs(os_env)),
}
}
pub(crate) fn pairs(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
entries
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::test_helpers::{environment, pairs};
use super::*;
use crate::Request;
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().expect("a file has a parent")).unwrap();
std::fs::write(path, contents).unwrap();
}
#[test]
fn a_missing_variable_is_a_typed_error_listing_what_is_available() {
let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}/x'\n").unwrap();
let environment = environment(&[("host", "example.com"), ("port", "443")], &[]);
let err = environment
.apply(&request)
.expect_err("`base_url` is not defined");
match &err {
SendraError::VariableNotFound {
name, available, ..
} => {
assert_eq!(name, "base_url");
assert_eq!(available, &["host".to_string(), "port".to_string()]);
}
other => panic!("expected VariableNotFound, got {other:?}"),
}
let message = err.to_string();
assert!(message.contains("base_url"), "got {message}");
assert!(message.contains("host, port"), "got {message}");
}
#[test]
fn a_missing_variable_error_names_the_environment_file_it_looked_in() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
write(&path, "host: example.com\n");
let environment = Environment::from_path(&path).unwrap();
let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}'\n").unwrap();
let err = environment.apply(&request).unwrap_err();
match &err {
SendraError::VariableNotFound { environment, .. } => {
assert_eq!(environment.as_deref(), Some(path.as_path()))
}
other => panic!("expected VariableNotFound, got {other:?}"),
}
assert!(
err.to_string().contains("staging.yaml"),
"the message should name the file to fix: {err}"
);
}
#[test]
fn a_missing_variable_with_no_environment_file_says_so() {
let request = Request::from_yaml_str("method: GET\nurl: '{{base_url}}'\n").unwrap();
let err = Environment::default().apply(&request).unwrap_err();
let message = err.to_string();
assert!(message.contains("base_url"), "got {message}");
assert!(
message.contains("no environment file was found"),
"an empty available-list must not read as `(available: )`: {message}"
);
}
#[test]
fn an_os_variable_is_read_from_the_environment_at_use_time() {
let request = Request::from_yaml_str(
"method: GET\nurl: https://example.com\nheaders:\n Authorization: '{{api_key}}'\n",
)
.unwrap();
let environment = environment(&[("api_key", "${API_KEY}")], &[("API_KEY", "live-token")]);
let applied = environment.apply(&request).unwrap();
assert_eq!(applied.header("Authorization"), Some("live-token"));
}
#[test]
fn an_os_variable_can_be_embedded_in_a_larger_value() {
let request = Request::from_yaml_str(
"method: GET\nurl: https://example.com\nheaders:\n Authorization: '{{auth}}'\n",
)
.unwrap();
let environment = environment(&[("auth", "Bearer ${API_KEY}!")], &[("API_KEY", "abc")]);
let applied = environment.apply(&request).unwrap();
assert_eq!(applied.header("Authorization"), Some("Bearer abc!"));
}
#[test]
fn a_missing_os_variable_is_a_typed_error_not_an_empty_string() {
let request = Request::from_yaml_str("method: GET\nurl: '{{host}}'\n").unwrap();
let environment = environment(&[("host", "https://x/${API_KEY}")], &[]);
let err = environment.apply(&request).expect_err("API_KEY is not set");
match &err {
SendraError::EnvVarNotSet { name, variable, .. } => {
assert_eq!(name, "API_KEY");
assert_eq!(variable, "host");
}
other => panic!("expected EnvVarNotSet, got {other:?}"),
}
let message = err.to_string();
assert!(message.contains("API_KEY"), "got {message}");
}
#[test]
fn a_missing_os_variable_is_reported_against_the_real_os_environment_too() {
let request = Request::from_yaml_str("method: GET\nurl: '{{token}}'\n").unwrap();
let environment = Environment {
variables: pairs(&[("token", "${SENDRA_TEST_DEFINITELY_NOT_SET_9F3A}")]),
auth: None,
source: None,
captured: BTreeMap::new(),
os_env_override: None,
};
let err = environment.apply(&request).expect_err("no such variable");
assert!(
matches!(err, SendraError::EnvVarNotSet { .. }),
"got {err:?}"
);
}
#[test]
fn an_unused_variable_with_a_missing_os_variable_does_not_fail_the_run() {
let request = Request::from_yaml_str("method: GET\nurl: '{{host}}'\n").unwrap();
let environment = environment(
&[("host", "https://example.com"), ("unused", "${NOT_SET}")],
&[],
);
let applied = environment.apply(&request).expect("`unused` is not used");
assert_eq!(applied.url, "https://example.com");
}
#[test]
fn parses_a_flat_environment_file() {
let environment = Environment::from_yaml_str(
"base_url: https://staging.example.com\napi_key: ${API_KEY}\n",
)
.unwrap();
assert_eq!(environment.names(), vec!["api_key", "base_url"]);
assert_eq!(
environment.variables.get("api_key").map(String::as_str),
Some("${API_KEY}")
);
}
#[test]
fn an_empty_environment_file_is_an_empty_environment_not_an_error() {
let environment = Environment::from_yaml_str("# nothing yet\n")
.expect("creating the file before filling it in is reasonable");
assert!(environment.is_empty());
}
#[test]
fn an_unquoted_scalar_substitutes_as_the_text_it_was_written_as() {
let environment =
Environment::from_yaml_str("port: 8080\nquoted: '8080'\nversion: 1.0\nflag: true\n")
.expect("a plain scalar is a perfectly good variable value");
for (name, expected) in [
("port", "8080"),
("quoted", "8080"),
("version", "1.0"),
("flag", "true"),
] {
assert_eq!(
environment.variables.get(name).map(String::as_str),
Some(expected),
"`{name}` should substitute as written"
);
}
}
#[test]
fn a_nested_environment_file_is_rejected() {
let err = Environment::from_yaml_str("staging:\n base_url: https://x\n")
.expect_err("environments do not nest");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
let err = Environment::from_yaml_str("hosts:\n - https://x\n")
.expect_err("a variable is one value, not a list");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn an_auth_block_parses_alongside_ordinary_variables() {
let environment = Environment::from_yaml_str(
"base_url: https://staging.example.com\nauth:\n bearer: '{{token}}'\n",
)
.unwrap();
assert_eq!(environment.names(), vec!["base_url"]);
assert!(!environment.variables.contains_key("auth"));
let auth = environment.auth.expect("the auth block was parsed");
assert_eq!(auth.bearer.as_deref(), Some("{{token}}"));
}
#[test]
fn an_environment_file_with_no_auth_key_leaves_auth_none() {
let environment = Environment::from_yaml_str("base_url: https://example.com\n").unwrap();
assert!(environment.auth.is_none());
}
#[test]
fn an_auth_value_that_is_not_a_mapping_is_a_typed_error() {
let err = Environment::from_yaml_str("auth: not-a-mapping\n")
.expect_err("auth must be a bearer/basic/api_key mapping");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn an_auth_block_naming_an_unknown_field_is_a_typed_error() {
let err = Environment::from_yaml_str("auth:\n bogus: x\n")
.expect_err("Auth::deny_unknown_fields rejects it");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn to_yaml_string_round_trips_variables_and_auth() {
let mut environment = Environment::from_yaml_str(
"base_url: https://staging.example.com\nauth:\n bearer: '{{token}}'\n",
)
.unwrap();
environment
.variables
.insert("port".to_string(), "443".to_string());
let yaml = environment.to_yaml_string().unwrap();
let reloaded = Environment::from_yaml_str(&yaml).unwrap();
assert_eq!(reloaded.variables, environment.variables);
assert_eq!(reloaded.auth, environment.auth);
}
#[test]
fn to_yaml_string_of_an_empty_environment_round_trips_to_the_same_empty_environment() {
let environment = Environment::default();
let yaml = environment.to_yaml_string().unwrap();
let reloaded = Environment::from_yaml_str(&yaml).unwrap();
assert!(reloaded.variables.is_empty());
assert!(reloaded.auth.is_none());
}
#[test]
fn save_to_path_writes_the_environment_and_a_reload_from_disk_matches() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
write(&path, "base_url: https://old.example.com\n");
let mut environment = Environment::from_path(&path).unwrap();
environment.variables.insert(
"base_url".to_string(),
"https://new.example.com".to_string(),
);
environment
.variables
.insert("token".to_string(), "abc123".to_string());
environment.save_to_path(&path).unwrap();
let reloaded = Environment::from_path(&path).unwrap();
assert_eq!(
reloaded.variables.get("base_url").map(String::as_str),
Some("https://new.example.com")
);
assert_eq!(
reloaded.variables.get("token").map(String::as_str),
Some("abc123")
);
}
#[test]
fn save_to_path_can_write_an_environment_down_to_zero_variables() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
write(&path, "base_url: https://example.com\n");
let mut environment = Environment::from_path(&path).unwrap();
environment.variables.clear();
environment
.save_to_path(&path)
.expect("saving down to zero variables must succeed");
let reloaded = Environment::from_path(&path).unwrap();
assert!(reloaded.variables.is_empty());
assert!(reloaded.auth.is_none());
}
#[test]
fn save_to_path_can_write_an_environment_up_from_zero_variables() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
write(&path, "");
let mut environment = Environment::from_path(&path).unwrap();
assert!(environment.variables.is_empty());
environment
.variables
.insert("base_url".to_string(), "https://example.com".to_string());
environment.save_to_path(&path).unwrap();
let reloaded = Environment::from_path(&path).unwrap();
assert_eq!(
reloaded.variables.get("base_url").map(String::as_str),
Some("https://example.com")
);
}
#[test]
fn save_to_path_leaves_no_temp_file_behind_on_success() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
write(&path, "base_url: https://example.com\n");
Environment::from_path(&path)
.unwrap()
.save_to_path(&path)
.unwrap();
let entries: Vec<_> = std::fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.file_name())
.collect();
assert_eq!(
entries,
vec![path.file_name().unwrap().to_os_string()],
"no stray sendra-tmp- file should be left behind: {entries:?}"
);
}
#[test]
fn save_to_path_refuses_to_write_an_invalid_environment_and_touches_nothing() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "staging");
let original = "base_url: https://example.com\nauth:\n bearer: '{{token}}'\n";
write(&path, original);
let mut environment = Environment::from_path(&path).unwrap();
environment.auth.as_mut().unwrap().basic = Some(crate::BasicAuth {
user: "u".to_string(),
pass: "p".to_string(),
});
let err = environment
.save_to_path(&path)
.expect_err("bearer + basic together must be refused");
assert!(
matches!(err, SendraError::InvalidEnvironment { .. }),
"got {err:?}"
);
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, original);
}
#[test]
fn malformed_yaml_in_an_environment_file_is_a_typed_error_carrying_the_path() {
let temp = tempfile::tempdir().unwrap();
let path = environment_path(temp.path(), "default");
write(&path, "base_url: [oops\n");
let err = Environment::resolve_from(temp.path(), "default")
.expect_err("malformed yaml must error");
match err {
SendraError::EnvParse { path: reported, .. } => assert_eq!(reported, path),
other => panic!("expected EnvParse, got {other:?}"),
}
}
#[test]
fn a_missing_environment_file_is_the_empty_environment_not_an_error() {
let temp = tempfile::tempdir().unwrap();
let environment = Environment::resolve_from(temp.path(), "default")
.expect("no environment file is an ordinary state");
assert_eq!(environment, Environment::default());
assert!(environment.source.is_none());
}
#[test]
fn the_environment_at_the_project_root_is_found_from_a_nested_subdirectory() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
let path = environment_path(&root, "default");
write(&path, "base_url: https://example.com\n");
let nested = root.join("crates").join("api").join("tests");
std::fs::create_dir_all(&nested).unwrap();
let environment = Environment::resolve_from(&nested, "default").unwrap();
assert_eq!(environment.source.as_deref(), Some(path.as_path()));
assert_eq!(
environment.variables.get("base_url").map(String::as_str),
Some("https://example.com")
);
}
#[test]
fn environments_are_selected_by_name() {
let temp = tempfile::tempdir().unwrap();
write(
&environment_path(temp.path(), "staging"),
"base_url: https://staging.example.com\n",
);
write(
&environment_path(temp.path(), "prod"),
"base_url: https://api.example.com\n",
);
for (name, expected) in [
("staging", "https://staging.example.com"),
("prod", "https://api.example.com"),
] {
let environment = Environment::resolve_from(temp.path(), name).unwrap();
assert_eq!(
environment.variables.get("base_url").map(String::as_str),
Some(expected),
"`{name}` should have loaded its own file"
);
}
}
#[test]
fn the_nearest_environment_wins_over_one_further_up() {
let temp = tempfile::tempdir().unwrap();
let outer = temp.path().join("outer");
write(&environment_path(&outer, "default"), "which: outer\n");
let inner = outer.join("inner");
write(&environment_path(&inner, "default"), "which: inner\n");
let environment = Environment::resolve_from(&inner, "default").unwrap();
assert_eq!(
environment.variables.get("which").map(String::as_str),
Some("inner")
);
}
#[test]
fn the_default_environment_lives_where_the_readme_says_it_does() {
let path = environment_path(Path::new("/project"), DEFAULT_ENVIRONMENT_NAME);
assert!(
path.ends_with(Path::new(".sendra/environments/default.yaml")),
"got {}",
path.display()
);
}
fn captured(pairs_in: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs(pairs_in)
}
#[test]
fn a_captured_variable_substitutes_exactly_like_a_file_one() {
let request = Request::from_yaml_str(
"method: GET
url: '{{base_url}}/me?t={{auth_token}}'
",
)
.unwrap();
let environment = environment(&[("base_url", "https://example.com")], &[]);
assert!(environment.apply(&request).is_err());
let view = environment.with_captured(&captured(&[("auth_token", "abc123")]));
let applied = view.apply(&request).expect("both variables resolve");
assert_eq!(applied.url, "https://example.com/me?t=abc123");
}
#[test]
fn a_view_never_changes_the_environment_it_was_built_from() {
let environment = environment(&[("base_url", "https://example.com")], &[]);
let view = environment.with_captured(&captured(&[("token", "t")]));
assert_eq!(view.captured_names(), vec!["token".to_string()]);
assert!(
environment.captured_names().is_empty(),
"the original must not have grown a capture"
);
assert!(environment
.apply(
&Request::from_yaml_str(
"method: GET
url: '{{token}}'
"
)
.unwrap()
)
.is_err());
}
#[test]
fn a_captured_value_is_data_and_is_never_read_as_a_reference() {
let request = Request::from_yaml_str(
"method: GET
url: 'https://x/{{token}}'
",
)
.unwrap();
let view = environment(&[], &[("HOME", "/root")])
.with_captured(&captured(&[("token", "${HOME}-{{base_url}}")]));
let applied = view.apply(&request).expect("the captured value is data");
assert_eq!(applied.url, "https://x/${HOME}-{{base_url}}");
}
#[test]
fn a_missing_variable_names_what_was_captured_as_well_as_what_the_file_has() {
let request = Request::from_yaml_str(
"method: GET
url: '{{nope}}'
",
)
.unwrap();
let view = environment(&[("base_url", "https://example.com")], &[])
.with_captured(&captured(&[("auth_token", "abc")]));
let err = view.apply(&request).expect_err("`nope` is neither");
match &err {
SendraError::VariableNotFound {
available,
captured,
..
} => {
assert_eq!(available, &["base_url".to_string()]);
assert_eq!(captured, &["auth_token".to_string()]);
}
other => panic!("expected VariableNotFound, got {other:?}"),
}
let message = err.to_string();
assert!(message.contains("base_url"), "got {message}");
assert!(message.contains("auth_token"), "got {message}");
}
#[test]
fn a_run_that_captured_nothing_prints_the_message_it_always_printed() {
let request = Request::from_yaml_str(
"method: GET
url: '{{nope}}'
",
)
.unwrap();
let message = environment(&[("base_url", "x")], &[])
.apply(&request)
.unwrap_err()
.to_string();
assert!(!message.contains("captured"), "got {message}");
}
}