use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use regex::{Captures, Regex};
static PLACEHOLDER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{\s*([^{}]+?)\s*\}\}").unwrap());
#[derive(Debug, Clone, PartialEq)]
pub enum ValueSource {
Literal,
ProcessEnv,
Ssm,
OnePassword,
Unknown,
}
#[derive(Debug, Clone)]
pub struct EnvVar {
pub key: String,
pub value: String,
pub source: ValueSource,
pub resolved: bool,
pub loading: bool,
pub original_value: String,
pub modified: bool,
pub user_added: bool,
pub raw: String,
}
pub const SECRET_MASK: &str = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}";
impl EnvVar {
pub fn user(key: String, value: String) -> Self {
EnvVar {
key,
original_value: value.clone(),
raw: value.clone(),
value,
source: ValueSource::Literal,
resolved: true,
loading: false,
modified: false,
user_added: true,
}
}
pub fn is_secret(&self) -> bool {
self.resolved && matches!(self.source, ValueSource::OnePassword | ValueSource::Ssm)
}
pub fn is_secret_source(&self) -> bool {
matches!(self.source, ValueSource::OnePassword | ValueSource::Ssm)
}
pub fn is_pending(&self) -> bool {
matches!(self.source, ValueSource::OnePassword | ValueSource::Ssm) && !self.resolved
}
pub fn is_failed(&self) -> bool {
!self.loading
&& !self.resolved
&& matches!(
self.source,
ValueSource::ProcessEnv | ValueSource::OnePassword | ValueSource::Ssm
)
}
pub fn reload(&mut self, index: usize) -> Option<PendingSecret> {
if !self.is_failed() {
return None;
}
let c = classify_raw(&self.raw);
self.source = c.source;
self.resolved = c.resolved;
self.loading = c.loading;
self.value = c.value.clone();
if !self.modified {
self.original_value = c.value;
}
c.secret.map(|kind| PendingSecret { index, kind })
}
pub fn display_value(&self) -> String {
if self.is_secret() {
SECRET_MASK.to_string()
} else {
self.value.clone()
}
}
pub fn set_user_value_secrecy(
&mut self,
v: String,
keep_secret: bool,
index: usize,
) -> Option<PendingSecret> {
self.modified = v != self.original_value;
let was_secret_source = self.is_secret_source();
let rewrite_raw = !was_secret_source || !keep_secret;
self.value = v.clone();
if !rewrite_raw {
self.resolved = true;
self.loading = false;
return None;
}
self.raw = v;
let c = classify_raw(&self.raw);
self.source = c.source;
self.resolved = c.resolved;
self.loading = c.loading;
self.value = c.value;
c.secret.map(|kind| PendingSecret { index, kind })
}
}
#[derive(Debug, Clone)]
pub struct Environment {
pub id: u64,
pub name: String,
pub vars: Vec<EnvVar>,
pub path: Option<std::path::PathBuf>,
pub git_origin: Option<crate::git_remote::GitOrigin>,
}
impl Environment {
pub fn to_vars_text(&self) -> String {
self.vars
.iter()
.map(|v| format!("{}={}", v.key, v.raw))
.collect::<Vec<_>>()
.join("\n")
}
pub fn apply_update(&mut self, update: &EnvUpdate) {
if update.env_id != self.id {
return;
}
if let Some(var) = self.vars.get_mut(update.index) {
var.loading = false;
match &update.value {
Some(v) => {
if !var.modified {
var.value = v.clone();
}
var.original_value = v.clone();
var.resolved = true;
}
None => var.resolved = false,
}
}
}
}
#[derive(Debug, Clone)]
pub enum SecretKind {
Op(String),
Ssm(String),
}
#[derive(Debug, Clone)]
pub struct PendingSecret {
pub index: usize,
pub kind: SecretKind,
}
#[derive(Debug, Clone)]
pub struct EnvUpdate {
pub env_id: u64,
pub index: usize,
pub value: Option<String>,
}
static NEXT_ENV_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_env_id() -> u64 {
NEXT_ENV_ID.fetch_add(1, Ordering::Relaxed)
}
pub trait SecretResolver {
fn resolve_op(&self, reference: &str) -> Option<String>;
fn resolve_ssm(&self, name: &str) -> Option<String>;
fn resolve_op_batch(&self, references: &[String]) -> Vec<Option<String>> {
references.iter().map(|r| self.resolve_op(r)).collect()
}
}
pub struct CliResolver;
impl SecretResolver for CliResolver {
fn resolve_op(&self, reference: &str) -> Option<String> {
run_cmd("op", &["read", reference])
}
fn resolve_ssm(&self, name: &str) -> Option<String> {
run_cmd(
"aws",
&[
"ssm",
"get-parameter",
"--name",
name,
"--with-decryption",
"--query",
"Parameter.Value",
"--output",
"text",
],
)
}
fn resolve_op_batch(&self, references: &[String]) -> Vec<Option<String>> {
if references.len() <= 1 {
return references.iter().map(|r| self.resolve_op(r)).collect();
}
match run_op_inject_batch(references) {
Some(values) => values,
None => references.iter().map(|r| self.resolve_op(r)).collect(),
}
}
}
fn run_op_inject_batch(references: &[String]) -> Option<Vec<Option<String>>> {
let markers: Vec<(String, String)> = references
.iter()
.map(|_| {
let id = uuid::Uuid::new_v4();
(
format!("--paperboy-op-begin-{id}--"),
format!("--paperboy-op-end-{id}--"),
)
})
.collect();
let mut template = String::new();
for (reference, (begin, end)) in references.iter().zip(&markers) {
template.push_str(begin);
template.push('\n');
template.push_str("{{ ");
template.push_str(reference);
template.push_str(" }}\n");
template.push_str(end);
template.push('\n');
}
let output = run_cmd_with_stdin("op", &["inject"], &template)?;
Some(
markers
.iter()
.map(|(begin, end)| {
let start = output.find(begin.as_str())? + begin.len();
let stop = output[start..].find(end.as_str())?;
Some(output[start..start + stop].trim_matches('\n').to_string())
})
.collect(),
)
}
fn run_cmd_with_stdin(program: &str, args: &[&str], input: &str) -> Option<String> {
use std::io::Write;
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(input.as_bytes()).ok()?;
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_cmd(program: &str, args: &[&str]) -> Option<String> {
let output = Command::new(program)
.args(args)
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout)
.trim_end_matches(['\n', '\r'])
.to_string();
(!value.is_empty()).then_some(value)
}
pub fn parse_vars(name: String, content: &str) -> Environment {
parse_vars_with(name, content, &CliResolver)
}
pub fn parse_vars_with(name: String, content: &str, resolver: &dyn SecretResolver) -> Environment {
let (mut env, pending) = parse_vars_pending(name, content);
resolve_pending_batched(&mut env, pending, resolver);
env
}
fn resolve_pending_batched(
env: &mut Environment,
pending: Vec<PendingSecret>,
resolver: &dyn SecretResolver,
) {
let mut op_refs = Vec::new();
let mut op_indices = Vec::new();
for p in &pending {
if let SecretKind::Op(reference) = &p.kind {
op_refs.push(reference.clone());
op_indices.push(p.index);
}
}
if !op_refs.is_empty() {
let values = resolver.resolve_op_batch(&op_refs);
for (index, value) in op_indices.into_iter().zip(values) {
env.apply_update(&EnvUpdate {
env_id: env.id,
index,
value,
});
}
}
for p in pending {
if let SecretKind::Ssm(name) = &p.kind {
let value = resolver.resolve_ssm(name);
env.apply_update(&EnvUpdate {
env_id: env.id,
index: p.index,
value,
});
}
}
}
pub fn parse_vars_pending(name: String, content: &str) -> (Environment, Vec<PendingSecret>) {
let mut vars = Vec::new();
let mut pending = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, raw)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_string();
let raw = raw.trim().to_string();
let index = vars.len();
vars.push(parse_var_pending(key, raw, index, &mut pending));
}
(
Environment {
id: next_env_id(),
name,
vars,
path: None,
git_origin: None,
},
pending,
)
}
struct Classified {
value: String,
source: ValueSource,
resolved: bool,
loading: bool,
secret: Option<SecretKind>,
}
fn classify_raw(raw: &str) -> Classified {
let Some(inner) = extract_template(raw) else {
return Classified {
value: raw.to_string(),
source: ValueSource::Literal,
resolved: true,
loading: false,
secret: None,
};
};
if let Some(env_name) = inner.strip_prefix("env:") {
match std::env::var(env_name.trim()) {
Ok(val) => Classified {
value: val,
source: ValueSource::ProcessEnv,
resolved: true,
loading: false,
secret: None,
},
Err(_) => Classified {
value: raw.to_string(),
source: ValueSource::ProcessEnv,
resolved: false,
loading: false,
secret: None,
},
}
} else if let Some(name) = inner.strip_prefix("ssm:") {
Classified {
value: raw.to_string(),
source: ValueSource::Ssm,
resolved: false,
loading: true,
secret: Some(SecretKind::Ssm(name.trim().to_string())),
}
} else if inner.starts_with("op://") {
Classified {
value: raw.to_string(),
source: ValueSource::OnePassword,
resolved: false,
loading: true,
secret: Some(SecretKind::Op(inner.to_string())),
}
} else {
Classified {
value: raw.to_string(),
source: ValueSource::Unknown,
resolved: false,
loading: false,
secret: None,
}
}
}
fn parse_var_pending(
key: String,
raw: String,
index: usize,
pending: &mut Vec<PendingSecret>,
) -> EnvVar {
let c = classify_raw(&raw);
if let Some(kind) = c.secret {
pending.push(PendingSecret { index, kind });
}
EnvVar {
key,
original_value: c.value.clone(),
value: c.value,
source: c.source,
resolved: c.resolved,
loading: c.loading,
modified: false,
user_added: false,
raw,
}
}
pub struct PendingEnvSecrets {
pub env_id: u64,
pub pending: Vec<PendingSecret>,
}
pub fn spawn_resolution_many(groups: Vec<PendingEnvSecrets>) -> Receiver<EnvUpdate> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
#[cfg(not(test))]
let resolver = CliResolver;
#[cfg(test)]
let resolver = NoopResolver;
let mut op_refs = Vec::new();
let mut op_targets = Vec::new();
let mut ssm_targets = Vec::new();
for group in groups {
for p in group.pending {
match p.kind {
SecretKind::Op(reference) => {
op_refs.push(reference);
op_targets.push((group.env_id, p.index));
}
SecretKind::Ssm(name) => ssm_targets.push((group.env_id, p.index, name)),
}
}
}
if !op_refs.is_empty() {
let values = resolver.resolve_op_batch(&op_refs);
for ((env_id, index), value) in op_targets.into_iter().zip(values) {
if tx
.send(EnvUpdate {
env_id,
index,
value,
})
.is_err()
{
return;
}
}
}
for (env_id, index, name) in ssm_targets {
let value = resolver.resolve_ssm(&name);
if tx
.send(EnvUpdate {
env_id,
index,
value,
})
.is_err()
{
break;
}
}
});
rx
}
pub fn spawn_resolution(env_id: u64, pending: Vec<PendingSecret>) -> Receiver<EnvUpdate> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
#[cfg(not(test))]
let resolver = CliResolver;
#[cfg(test)]
let resolver = NoopResolver;
let mut op_refs = Vec::new();
let mut op_indices = Vec::new();
let mut rest = Vec::new();
for p in pending {
match &p.kind {
SecretKind::Op(reference) => {
op_refs.push(reference.clone());
op_indices.push(p.index);
}
SecretKind::Ssm(_) => rest.push(p),
}
}
if !op_refs.is_empty() {
let values = resolver.resolve_op_batch(&op_refs);
for (index, value) in op_indices.into_iter().zip(values) {
if tx
.send(EnvUpdate {
env_id,
index,
value,
})
.is_err()
{
return;
}
}
}
for p in rest {
let SecretKind::Ssm(name) = &p.kind else {
unreachable!("rest only holds Ssm pending secrets")
};
let value = resolver.resolve_ssm(name);
if tx
.send(EnvUpdate {
env_id,
index: p.index,
value,
})
.is_err()
{
break;
}
}
});
rx
}
#[cfg(test)]
struct NoopResolver;
#[cfg(test)]
impl SecretResolver for NoopResolver {
fn resolve_op(&self, _reference: &str) -> Option<String> {
None
}
fn resolve_ssm(&self, _name: &str) -> Option<String> {
None
}
}
fn extract_template(s: &str) -> Option<&str> {
Some(s.trim().strip_prefix("{{")?.strip_suffix("}}")?.trim())
}
pub fn looks_like_env(content: &str) -> bool {
content.lines().any(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return false;
}
match line.split_once('=') {
Some((key, _)) => {
let key = key.trim();
!key.is_empty() && !key.contains(char::is_whitespace)
}
None => false,
}
})
}
pub fn referenced_keys(text: &str) -> Vec<String> {
let mut keys = Vec::new();
for caps in PLACEHOLDER.captures_iter(text) {
let key = caps[1].to_string();
if !keys.contains(&key) {
keys.push(key);
}
}
keys
}
pub fn substitute(text: &str, vars: &HashMap<String, String>) -> String {
PLACEHOLDER
.replace_all(text, |caps: &Captures| match vars.get(&caps[1]) {
Some(value) => value.clone(),
None => caps[0].to_string(),
})
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
struct MockResolver;
impl SecretResolver for MockResolver {
fn resolve_op(&self, reference: &str) -> Option<String> {
(reference == "op://Engineering/demo-api/token").then(|| "op-secret-123".to_string())
}
fn resolve_ssm(&self, name: &str) -> Option<String> {
(name == "/demo/api/password").then(|| "ssm-secret-xyz".to_string())
}
}
fn one(content: &str) -> EnvVar {
parse_vars_with("t".into(), content, &MockResolver)
.vars
.into_iter()
.next()
.unwrap()
}
struct BatchCountingResolver {
batch_calls: std::cell::RefCell<Vec<Vec<String>>>,
}
impl SecretResolver for BatchCountingResolver {
fn resolve_op(&self, _reference: &str) -> Option<String> {
panic!("resolve_op should not be called directly when resolve_op_batch is available");
}
fn resolve_ssm(&self, name: &str) -> Option<String> {
(name == "/demo/api/password").then(|| "ssm-secret-xyz".to_string())
}
fn resolve_op_batch(&self, references: &[String]) -> Vec<Option<String>> {
self.batch_calls.borrow_mut().push(references.to_vec());
references
.iter()
.map(|r| Some(format!("resolved-{r}")))
.collect()
}
}
#[test]
fn many_op_references_resolve_in_a_single_batch_call() {
let resolver = BatchCountingResolver {
batch_calls: std::cell::RefCell::new(Vec::new()),
};
let content = "A={{ op://v/i/a }}\nB=plain\nC={{ op://v/i/c }}\nD={{ ssm:/demo/api/password }}\nE={{ op://v/i/e }}";
let env = parse_vars_with("t".into(), content, &resolver);
let calls = resolver.batch_calls.borrow();
assert_eq!(
calls.len(),
1,
"all op:// references should resolve in exactly one batch call"
);
assert_eq!(calls[0], vec!["op://v/i/a", "op://v/i/c", "op://v/i/e"]);
drop(calls);
assert_eq!(env.vars[0].value, "resolved-op://v/i/a");
assert_eq!(env.vars[1].value, "plain");
assert_eq!(env.vars[2].value, "resolved-op://v/i/c");
assert_eq!(
env.vars[3].value, "ssm-secret-xyz",
"ssm references still resolve alongside the op batch"
);
assert_eq!(env.vars[4].value, "resolved-op://v/i/e");
assert!(env.vars.iter().all(|v| v.resolved));
}
#[test]
fn resolves_op_reference() {
let v = one("API_TOKEN={{ op://Engineering/demo-api/token }}");
assert_eq!(v.value, "op-secret-123");
assert!(v.resolved);
assert_eq!(v.source, ValueSource::OnePassword);
}
#[test]
fn resolves_ssm_reference() {
let v = one("API_PASSWORD={{ ssm:/demo/api/password }}");
assert_eq!(v.value, "ssm-secret-xyz");
assert!(v.resolved);
assert_eq!(v.source, ValueSource::Ssm);
}
#[test]
fn unresolvable_op_falls_back_to_raw() {
let v = one("X={{ op://Nope/missing/field }}");
assert_eq!(v.value, "{{ op://Nope/missing/field }}");
assert!(!v.resolved);
assert_eq!(v.source, ValueSource::OnePassword);
}
#[test]
fn unresolvable_ssm_falls_back_to_raw() {
let v = one("X={{ ssm:/no/such/param }}");
assert!(!v.resolved);
assert_eq!(v.source, ValueSource::Ssm);
}
#[test]
fn a_failed_op_reference_can_be_reloaded_once_the_provider_is_reachable() {
let mut env = parse_vars_with("t".into(), "X={{ op://Nope/missing/field }}", &MockResolver);
assert!(
env.vars[0].is_failed(),
"unresolved op:// reference should be flagged as failed"
);
struct NowWorksResolver;
impl SecretResolver for NowWorksResolver {
fn resolve_op(&self, reference: &str) -> Option<String> {
(reference == "op://Nope/missing/field").then(|| "now-resolved".to_string())
}
fn resolve_ssm(&self, _name: &str) -> Option<String> {
None
}
}
let pending = env.vars[0].reload(0);
assert!(
env.vars[0].loading,
"reload() should mark the variable loading again while it re-resolves"
);
let secret =
pending.expect("a provider reference should return a PendingSecret to resolve");
let value = NowWorksResolver
.resolve_op_batch(&[match &secret.kind {
SecretKind::Op(r) => r.clone(),
SecretKind::Ssm(_) => unreachable!(),
}])
.remove(0);
env.apply_update(&EnvUpdate {
env_id: env.id,
index: secret.index,
value,
});
assert!(env.vars[0].resolved);
assert!(!env.vars[0].is_failed());
assert_eq!(env.vars[0].value, "now-resolved");
}
#[test]
fn reload_is_a_no_op_for_a_variable_that_did_not_fail() {
let mut env = parse_vars_with("t".into(), "A=plain-value", &MockResolver);
assert!(!env.vars[0].is_failed());
assert!(
env.vars[0].reload(0).is_none(),
"reload() should do nothing for an already-resolved literal"
);
assert_eq!(env.vars[0].value, "plain-value");
}
#[test]
fn reload_re_reads_a_process_env_reference_that_previously_failed() {
unsafe { std::env::remove_var("PAPERBOY_TEST_RELOAD_VAR") };
let mut env = parse_vars_with(
"t".into(),
"X={{ env:PAPERBOY_TEST_RELOAD_VAR }}",
&MockResolver,
);
assert!(
env.vars[0].is_failed(),
"a missing process env var should be flagged as failed"
);
unsafe { std::env::set_var("PAPERBOY_TEST_RELOAD_VAR", "now-set") };
let pending = env.vars[0].reload(0);
assert!(
pending.is_none(),
"env: references resolve synchronously, with no PendingSecret to hand off"
);
assert!(env.vars[0].resolved);
assert_eq!(env.vars[0].value, "now-set");
unsafe { std::env::remove_var("PAPERBOY_TEST_RELOAD_VAR") };
}
#[test]
fn literal_values_are_untouched() {
let v = one("A=plain-value");
assert_eq!(v.value, "plain-value");
assert!(v.resolved);
assert_eq!(v.source, ValueSource::Literal);
}
#[test]
fn resolved_secrets_are_masked_for_display() {
let op = one("API_TOKEN={{ op://Engineering/demo-api/token }}");
assert!(op.is_secret());
assert_eq!(op.display_value(), SECRET_MASK);
assert_ne!(
op.value, SECRET_MASK,
"the real value is still available to send"
);
let ssm = one("API_PASSWORD={{ ssm:/demo/api/password }}");
assert!(ssm.is_secret());
assert_eq!(ssm.display_value(), SECRET_MASK);
}
#[test]
fn non_secrets_display_their_real_value() {
assert!(!one("A=plain").is_secret());
assert_eq!(one("A=plain").display_value(), "plain");
let unresolved = one("X={{ op://Nope/missing/field }}");
assert!(!unresolved.is_secret());
assert_eq!(unresolved.display_value(), "{{ op://Nope/missing/field }}");
}
#[test]
fn env_reference_reads_process_environment() {
unsafe { std::env::set_var("PAPERBOY_TEST_ENV_VAR", "from-env") };
let v = one("T={{ env:PAPERBOY_TEST_ENV_VAR }}");
assert_eq!(v.value, "from-env");
assert!(v.resolved);
assert_eq!(v.source, ValueSource::ProcessEnv);
unsafe { std::env::remove_var("PAPERBOY_TEST_ENV_VAR") };
}
#[test]
fn pending_parse_leaves_providers_loading() {
let content = "A=plain\nB={{ op://Vault/item/field }}\nC={{ ssm:/demo/p }}";
let (env, pending) = parse_vars_pending("t".into(), content);
assert_eq!(env.vars.len(), 3);
assert!(env.vars[0].resolved && !env.vars[0].loading);
assert!(env.vars[1].loading && !env.vars[1].resolved);
assert_eq!(env.vars[1].source, ValueSource::OnePassword);
assert!(env.vars[2].loading && !env.vars[2].resolved);
assert_eq!(env.vars[2].source, ValueSource::Ssm);
assert_eq!(pending.len(), 2);
}
#[test]
fn apply_update_resolves_success_and_ignores_other_envs() {
let (mut env, pending) = parse_vars_pending("t".into(), "B={{ op://V/i/f }}");
let idx = pending[0].index;
env.apply_update(&EnvUpdate {
env_id: env.id,
index: idx,
value: Some("secret".into()),
});
assert!(env.vars[idx].resolved && !env.vars[idx].loading);
assert_eq!(env.vars[idx].value, "secret");
assert!(env.vars[idx].is_secret());
env.apply_update(&EnvUpdate {
env_id: env.id + 99,
index: idx,
value: Some("x".into()),
});
assert_eq!(env.vars[idx].value, "secret");
}
#[test]
fn user_edits_flag_modified_relative_to_the_original() {
let mut v = one("NAME=plain");
assert!(!v.modified, "freshly loaded vars are unmodified");
assert_eq!(v.original_value, "plain");
v.set_user_value_secrecy("changed".into(), true, 0);
assert!(v.modified, "a different value marks the var modified");
assert_eq!(v.value, "changed");
assert_eq!(
v.original_value, "plain",
"the original is preserved for reset"
);
v.set_user_value_secrecy("plain".into(), true, 0);
assert!(
!v.modified,
"restoring the original value clears the modified flag"
);
}
#[test]
fn resolution_sets_the_reset_target_and_keeps_user_edits() {
let (mut env, pending) = parse_vars_pending("t".into(), "B={{ op://V/i/f }}");
let idx = pending[0].index;
env.vars[idx].set_user_value_secrecy("user-typed".into(), true, idx);
assert!(env.vars[idx].modified);
env.apply_update(&EnvUpdate {
env_id: env.id,
index: idx,
value: Some("real-secret".into()),
});
assert_eq!(env.vars[idx].value, "user-typed", "user edit is preserved");
assert_eq!(
env.vars[idx].original_value, "real-secret",
"reset restores the resolved value"
);
assert!(env.vars[idx].resolved);
}
#[test]
fn apply_update_failure_stays_pending() {
let (mut env, _p) = parse_vars_pending("t".into(), "B={{ ssm:/demo/p }}");
env.apply_update(&EnvUpdate {
env_id: env.id,
index: 0,
value: None,
});
assert!(!env.vars[0].resolved);
assert!(!env.vars[0].loading);
assert!(env.vars[0].is_pending());
}
#[test]
fn referenced_keys_finds_unique_placeholders() {
let keys = referenced_keys("{{ BASE_URL }}/x/{{TOKEN}} plain {{ BASE_URL }}");
assert_eq!(keys, vec!["BASE_URL".to_string(), "TOKEN".to_string()]);
assert!(referenced_keys("no placeholders here").is_empty());
}
#[test]
fn substitute_replaces_known_keys_regardless_of_inner_spacing() {
let vars = HashMap::from([("TOKEN".to_string(), "secret".to_string())]);
assert_eq!(substitute("{{TOKEN}}", &vars), "secret");
assert_eq!(substitute("{{ TOKEN }}", &vars), "secret");
assert_eq!(substitute("a {{ TOKEN }} b", &vars), "a secret b");
}
#[test]
fn substitute_keeps_unknown_placeholders_verbatim() {
let vars = HashMap::new();
assert_eq!(substitute("{{ MISSING }}", &vars), "{{ MISSING }}");
}
#[test]
fn substitute_does_not_re_expand_inserted_values() {
let vars = HashMap::from([("A".to_string(), "{{ B }}".to_string())]);
assert_eq!(substitute("{{ A }}", &vars), "{{ B }}");
}
#[test]
fn looks_like_env_accepts_vars_files_and_rejects_collections() {
assert!(looks_like_env(
"# comment\nTOKEN={{ op://a/b/c }}\nBASE=http://x"
));
assert!(looks_like_env("A=1"));
let collection =
"# Health\nGET http://127.0.0.1:8080/health\nAuthorization: Bearer x\nHTTP 200";
assert!(!looks_like_env(collection));
assert!(!looks_like_env("\n\n# just a comment\n"));
assert!(!looks_like_env("GET http://x?a=b"));
}
}