use crate::config::NamedEnvironmentConfig;
use crate::error::TarnError;
use crate::model::Location;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser};
use yaml_rust2::scanner::Marker;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvSource {
InlineEnvBlock,
DefaultEnvFile {
path: String,
},
NamedEnvFile {
path: String,
env_name: String,
},
NamedProfileVars {
env_name: String,
},
LocalEnvFile {
path: String,
},
CliVar,
}
impl EnvSource {
pub fn label(&self) -> &str {
match self {
EnvSource::InlineEnvBlock => "inline env: block",
EnvSource::DefaultEnvFile { .. } => "default env file",
EnvSource::NamedEnvFile { .. } => "named env file",
EnvSource::NamedProfileVars { .. } => "named profile vars",
EnvSource::LocalEnvFile { .. } => "local env file",
EnvSource::CliVar => "CLI --var",
}
}
pub fn source_file(&self) -> Option<&str> {
match self {
EnvSource::DefaultEnvFile { path }
| EnvSource::NamedEnvFile { path, .. }
| EnvSource::LocalEnvFile { path } => Some(path.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvEntry {
pub value: String,
pub source: EnvSource,
pub declaration_range: Option<Location>,
}
pub fn resolve_env(
inline_env: &HashMap<String, String>,
env_name: Option<&str>,
cli_vars: &[(String, String)],
base_dir: &Path,
) -> Result<HashMap<String, String>, TarnError> {
resolve_env_with_profiles(
inline_env,
env_name,
cli_vars,
base_dir,
"tarn.env.yaml",
&HashMap::new(),
)
}
pub fn resolve_env_with_file(
inline_env: &HashMap<String, String>,
env_name: Option<&str>,
cli_vars: &[(String, String)],
base_dir: &Path,
env_file_name: &str,
) -> Result<HashMap<String, String>, TarnError> {
resolve_env_with_profiles(
inline_env,
env_name,
cli_vars,
base_dir,
env_file_name,
&HashMap::new(),
)
}
pub fn resolve_env_with_profiles(
inline_env: &HashMap<String, String>,
env_name: Option<&str>,
cli_vars: &[(String, String)],
base_dir: &Path,
env_file_name: &str,
profiles: &HashMap<String, NamedEnvironmentConfig>,
) -> Result<HashMap<String, String>, TarnError> {
let mut env = HashMap::new();
for (k, v) in inline_env {
env.insert(k.clone(), v.clone());
}
let default_env_file = base_dir.join(env_file_name);
if default_env_file.exists() {
let file_env = load_env_file(&default_env_file)?;
for (k, v) in file_env {
env.insert(k, v);
}
}
if let Some(name) = env_name {
let named_env_file = profiles
.get(name)
.and_then(|profile| profile.env_file.as_ref().map(|path| base_dir.join(path)))
.unwrap_or_else(|| base_dir.join(env_variant_filename(env_file_name, name)));
if named_env_file.exists() {
let file_env = load_env_file(&named_env_file)?;
for (k, v) in file_env {
env.insert(k, v);
}
}
if let Some(profile) = profiles.get(name) {
for (k, v) in &profile.vars {
env.insert(k.clone(), v.clone());
}
}
}
let local_env_file = base_dir.join(env_variant_filename(env_file_name, "local"));
if local_env_file.exists() {
let file_env = load_env_file(&local_env_file)?;
for (k, v) in file_env {
env.insert(k, v);
}
}
let resolved: HashMap<String, String> = env
.into_iter()
.map(|(k, v)| {
let resolved_v = resolve_shell_vars(&v);
(k, resolved_v)
})
.collect();
env = resolved;
for (k, v) in cli_vars {
env.insert(k.clone(), v.clone());
}
Ok(env)
}
pub fn resolve_env_with_sources(
inline_env: &HashMap<String, String>,
env_name: Option<&str>,
cli_vars: &[(String, String)],
base_dir: &Path,
env_file_name: &str,
profiles: &HashMap<String, NamedEnvironmentConfig>,
) -> Result<BTreeMap<String, EnvEntry>, TarnError> {
let mut env: BTreeMap<String, EnvEntry> = BTreeMap::new();
for (k, v) in inline_env {
env.insert(
k.clone(),
EnvEntry {
value: v.clone(),
source: EnvSource::InlineEnvBlock,
declaration_range: None,
},
);
}
let default_env_file = base_dir.join(env_file_name);
if default_env_file.exists() {
let loaded = load_env_file_with_locations(&default_env_file)?;
let display = default_env_file.display().to_string();
for (k, v) in loaded.values {
let declaration_range = loaded.locations.get(&k).cloned();
env.insert(
k,
EnvEntry {
value: v,
source: EnvSource::DefaultEnvFile {
path: display.clone(),
},
declaration_range,
},
);
}
}
if let Some(name) = env_name {
let named_env_file = profiles
.get(name)
.and_then(|profile| profile.env_file.as_ref().map(|path| base_dir.join(path)))
.unwrap_or_else(|| base_dir.join(env_variant_filename(env_file_name, name)));
if named_env_file.exists() {
let loaded = load_env_file_with_locations(&named_env_file)?;
let display = named_env_file.display().to_string();
for (k, v) in loaded.values {
let declaration_range = loaded.locations.get(&k).cloned();
env.insert(
k,
EnvEntry {
value: v,
source: EnvSource::NamedEnvFile {
path: display.clone(),
env_name: name.to_owned(),
},
declaration_range,
},
);
}
}
if let Some(profile) = profiles.get(name) {
for (k, v) in &profile.vars {
env.insert(
k.clone(),
EnvEntry {
value: v.clone(),
source: EnvSource::NamedProfileVars {
env_name: name.to_owned(),
},
declaration_range: None,
},
);
}
}
}
let local_env_file = base_dir.join(env_variant_filename(env_file_name, "local"));
if local_env_file.exists() {
let loaded = load_env_file_with_locations(&local_env_file)?;
let display = local_env_file.display().to_string();
for (k, v) in loaded.values {
let declaration_range = loaded.locations.get(&k).cloned();
env.insert(
k,
EnvEntry {
value: v,
source: EnvSource::LocalEnvFile {
path: display.clone(),
},
declaration_range,
},
);
}
}
for entry in env.values_mut() {
entry.value = resolve_shell_vars(&entry.value);
}
for (k, v) in cli_vars {
env.insert(
k.clone(),
EnvEntry {
value: v.clone(),
source: EnvSource::CliVar,
declaration_range: None,
},
);
}
Ok(env)
}
fn env_variant_filename(env_file_name: &str, suffix: &str) -> PathBuf {
let path = Path::new(env_file_name);
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(env_file_name);
match path.extension().and_then(|ext| ext.to_str()) {
Some(ext) => PathBuf::from(format!("{stem}.{suffix}.{ext}")),
None => PathBuf::from(format!("{stem}.{suffix}")),
}
}
fn load_env_file(path: &Path) -> Result<HashMap<String, String>, TarnError> {
let content = std::fs::read_to_string(path).map_err(|e| {
TarnError::Config(format!("Failed to read env file {}: {}", path.display(), e))
})?;
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(&content).map_err(|e| {
TarnError::Config(format!(
"Failed to parse env file {}: {}",
path.display(),
e
))
})?;
Ok(map
.into_iter()
.map(|(k, v)| {
let s = match v {
serde_yaml::Value::String(s) => s,
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Null => String::new(),
other => format!("{:?}", other),
};
(k, s)
})
.collect())
}
struct LoadedEnvFile {
values: HashMap<String, String>,
locations: HashMap<String, Location>,
}
fn load_env_file_with_locations(path: &Path) -> Result<LoadedEnvFile, TarnError> {
let values = load_env_file(path)?;
let content = std::fs::read_to_string(path).map_err(|e| {
TarnError::Config(format!("Failed to read env file {}: {}", path.display(), e))
})?;
let locations = scan_top_level_key_locations(&content, &path.display().to_string());
Ok(LoadedEnvFile { values, locations })
}
pub fn scan_top_level_key_locations(
content: &str,
display_path: &str,
) -> HashMap<String, Location> {
let mut out = HashMap::new();
let mut sink = LocationSink { events: Vec::new() };
let mut parser = Parser::new_from_str(content);
if parser.load(&mut sink, true).is_err() {
return out;
}
let events = &sink.events;
let mut i = 0usize;
while i < events.len() {
if matches!(events[i].0, Event::MappingStart(_, _)) {
i += 1;
break;
}
i += 1;
}
while i < events.len() {
match &events[i].0 {
Event::MappingEnd => break,
Event::Scalar(key, _, _, _) => {
let key = key.clone();
let key_marker = events[i].1;
i += 1;
if i < events.len() {
let (value_event, value_marker) = &events[i];
let (loc_line, loc_col) = match value_event {
Event::Scalar(_, _, _, _) => (value_marker.line(), value_marker.col() + 1),
_ => (key_marker.line(), key_marker.col() + 1),
};
out.insert(
key,
Location {
file: display_path.to_owned(),
line: loc_line,
column: loc_col,
},
);
i = skip_node(events, i);
} else {
break;
}
}
_ => {
i += 1;
}
}
}
out
}
pub fn inline_env_locations_from_source(
content: &str,
display_path: &str,
) -> HashMap<String, Location> {
let mut out = HashMap::new();
let mut sink = LocationSink { events: Vec::new() };
let mut parser = Parser::new_from_str(content);
if parser.load(&mut sink, true).is_err() {
return out;
}
let events = &sink.events;
let mut i = 0usize;
while i < events.len() {
if matches!(events[i].0, Event::MappingStart(_, _)) {
i += 1;
break;
}
i += 1;
}
while i < events.len() {
match &events[i].0 {
Event::MappingEnd => break,
Event::Scalar(key, _, _, _) => {
let key = key.clone();
i += 1;
if key == "env" {
if i < events.len() {
if matches!(events[i].0, Event::MappingStart(_, _)) {
i += 1;
while i < events.len() {
match &events[i].0 {
Event::MappingEnd => {
break;
}
Event::Scalar(inner_key, _, _, _) => {
let inner_key = inner_key.clone();
let key_marker = events[i].1;
i += 1;
let (loc_line, loc_col) =
if let Some((val_event, val_marker)) = events.get(i) {
match val_event {
Event::Scalar(_, _, _, _) => {
(val_marker.line(), val_marker.col() + 1)
}
_ => (key_marker.line(), key_marker.col() + 1),
}
} else {
(key_marker.line(), key_marker.col() + 1)
};
out.insert(
inner_key,
Location {
file: display_path.to_owned(),
line: loc_line,
column: loc_col,
},
);
i = skip_node(events, i);
}
_ => {
i = skip_node(events, i);
}
}
}
return out;
} else {
return out;
}
}
} else {
i = skip_node(events, i);
}
}
_ => {
i += 1;
}
}
}
out
}
fn skip_node(events: &[(Event, Marker)], mut i: usize) -> usize {
if i >= events.len() {
return i;
}
let start = &events[i].0;
match start {
Event::Scalar(_, _, _, _) | Event::Alias(_) => i + 1,
Event::SequenceStart(_, _) => {
i += 1;
let mut depth = 1i32;
while i < events.len() && depth > 0 {
match &events[i].0 {
Event::SequenceStart(_, _) | Event::MappingStart(_, _) => depth += 1,
Event::SequenceEnd | Event::MappingEnd => depth -= 1,
_ => {}
}
i += 1;
}
i
}
Event::MappingStart(_, _) => {
i += 1;
let mut depth = 1i32;
while i < events.len() && depth > 0 {
match &events[i].0 {
Event::MappingStart(_, _) | Event::SequenceStart(_, _) => depth += 1,
Event::MappingEnd | Event::SequenceEnd => depth -= 1,
_ => {}
}
i += 1;
}
i
}
_ => i + 1,
}
}
struct LocationSink {
events: Vec<(Event, Marker)>,
}
impl MarkedEventReceiver for LocationSink {
fn on_event(&mut self, ev: Event, mark: Marker) {
self.events.push((ev, mark));
}
}
fn resolve_shell_vars(value: &str) -> String {
let mut result = value.to_string();
while let Some(start) = result.find("${") {
if let Some(end) = result[start..].find('}') {
let var_name = &result[start + 2..start + end];
let replacement = std::env::var(var_name).unwrap_or_default();
result = format!(
"{}{}{}",
&result[..start],
replacement,
&result[start + end + 1..]
);
} else {
break;
}
}
result
}
pub fn parse_cli_vars(vars: &[String]) -> Result<Vec<(String, String)>, TarnError> {
vars.iter()
.map(|s| {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() != 2 {
Err(TarnError::Config(format!(
"Invalid --var format '{}': expected key=value",
s
)))
} else {
Ok((parts[0].to_string(), parts[1].to_string()))
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn setup_env_files(dir: &TempDir, files: &[(&str, &str)]) {
for (name, content) in files {
let path = dir.path().join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
}
#[test]
fn inline_env_is_base_layer() {
let dir = TempDir::new().unwrap();
let mut inline = HashMap::new();
inline.insert("base_url".into(), "http://localhost:3000".into());
let env = resolve_env(&inline, None, &[], dir.path()).unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://localhost:3000");
}
#[test]
fn env_file_overrides_inline() {
let dir = TempDir::new().unwrap();
setup_env_files(&dir, &[("tarn.env.yaml", "base_url: http://from-file")]);
let mut inline = HashMap::new();
inline.insert("base_url".into(), "http://inline".into());
let env = resolve_env(&inline, None, &[], dir.path()).unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://from-file");
}
#[test]
fn named_env_overrides_default() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.staging.yaml", "base_url: http://staging"),
],
);
let env = resolve_env(&HashMap::new(), Some("staging"), &[], dir.path()).unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://staging");
}
#[test]
fn named_profile_uses_custom_env_file_and_vars() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default\nregion: local"),
("env.staging.yaml", "base_url: http://from-profile-file"),
],
);
let mut profiles = HashMap::new();
profiles.insert(
"staging".into(),
NamedEnvironmentConfig {
env_file: Some("env.staging.yaml".into()),
vars: HashMap::from([("region".into(), "eu-west-1".into())]),
},
);
let env = resolve_env_with_profiles(
&HashMap::new(),
Some("staging"),
&[],
dir.path(),
"tarn.env.yaml",
&profiles,
)
.unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://from-profile-file");
assert_eq!(env.get("region").unwrap(), "eu-west-1");
}
#[test]
fn local_env_overrides_named() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.staging.yaml", "base_url: http://staging"),
("tarn.env.local.yaml", "base_url: http://local"),
],
);
let env = resolve_env(&HashMap::new(), Some("staging"), &[], dir.path()).unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://local");
}
#[test]
fn cli_var_overrides_all() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.local.yaml", "base_url: http://local"),
],
);
let mut inline = HashMap::new();
inline.insert("base_url".into(), "http://inline".into());
let cli_vars = vec![("base_url".into(), "http://cli".into())];
let env = resolve_env(&inline, None, &cli_vars, dir.path()).unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://cli");
}
#[test]
fn resolve_shell_variable() {
std::env::set_var("HIVE_TEST_SECRET", "s3cret");
let result = resolve_shell_vars("password is ${HIVE_TEST_SECRET}");
assert_eq!(result, "password is s3cret");
std::env::remove_var("HIVE_TEST_SECRET");
}
#[test]
fn resolve_missing_shell_variable_becomes_empty() {
let result = resolve_shell_vars("${HIVE_NONEXISTENT_VAR}");
assert_eq!(result, "");
}
#[test]
fn resolve_multiple_shell_variables() {
std::env::set_var("HIVE_TEST_A", "alpha");
std::env::set_var("HIVE_TEST_B", "beta");
let result = resolve_shell_vars("${HIVE_TEST_A} and ${HIVE_TEST_B}");
assert_eq!(result, "alpha and beta");
std::env::remove_var("HIVE_TEST_A");
std::env::remove_var("HIVE_TEST_B");
}
#[test]
fn no_shell_vars_unchanged() {
let result = resolve_shell_vars("no variables here");
assert_eq!(result, "no variables here");
}
#[test]
fn load_env_file_with_various_types() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[(
"test.yaml",
"string_val: hello\nnumber_val: 42\nbool_val: true\nnull_val: null",
)],
);
let env = load_env_file(&dir.path().join("test.yaml")).unwrap();
assert_eq!(env.get("string_val").unwrap(), "hello");
assert_eq!(env.get("number_val").unwrap(), "42");
assert_eq!(env.get("bool_val").unwrap(), "true");
assert_eq!(env.get("null_val").unwrap(), "");
}
#[test]
fn load_env_file_missing() {
let result = load_env_file(Path::new("/nonexistent/file.yaml"));
assert!(result.is_err());
}
#[test]
fn load_env_file_invalid_yaml() {
let dir = TempDir::new().unwrap();
setup_env_files(&dir, &[("bad.yaml", "not: [valid: yaml")]);
let result = load_env_file(&dir.path().join("bad.yaml"));
assert!(result.is_err());
}
#[test]
fn parse_cli_vars_valid() {
let vars = vec!["key=value".into(), "another=with=equals".into()];
let result = parse_cli_vars(&vars).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0], ("key".into(), "value".into()));
assert_eq!(result[1], ("another".into(), "with=equals".into()));
}
#[test]
fn parse_cli_vars_invalid() {
let vars = vec!["no_equals_sign".into()];
let result = parse_cli_vars(&vars);
assert!(result.is_err());
}
#[test]
fn parse_cli_vars_empty() {
let vars: Vec<String> = vec![];
let result = parse_cli_vars(&vars).unwrap();
assert!(result.is_empty());
}
#[test]
fn variables_from_different_layers_merge() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[("tarn.env.yaml", "file_only: from_file\nbase_url: from_file")],
);
let mut inline = HashMap::new();
inline.insert("inline_only".into(), "from_inline".into());
inline.insert("base_url".into(), "from_inline".into());
let env = resolve_env(&inline, None, &[], dir.path()).unwrap();
assert_eq!(env.get("inline_only").unwrap(), "from_inline");
assert_eq!(env.get("file_only").unwrap(), "from_file");
assert_eq!(env.get("base_url").unwrap(), "from_file"); }
#[test]
fn missing_named_env_file_is_ok() {
let dir = TempDir::new().unwrap();
let env = resolve_env(&HashMap::new(), Some("staging"), &[], dir.path()).unwrap();
assert!(env.is_empty());
}
#[test]
fn custom_env_file_supports_named_and_local_variants() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("custom.env.yaml", "base_url: http://default"),
("custom.env.staging.yaml", "base_url: http://staging"),
("custom.env.local.yaml", "token: secret"),
],
);
let env = resolve_env_with_file(
&HashMap::new(),
Some("staging"),
&[],
dir.path(),
"custom.env.yaml",
)
.unwrap();
assert_eq!(env.get("base_url").unwrap(), "http://staging");
assert_eq!(env.get("token").unwrap(), "secret");
}
#[test]
fn env_variant_filename_inserts_suffix_before_extension() {
assert_eq!(
env_variant_filename("tarn.env.yaml", "local"),
PathBuf::from("tarn.env.local.yaml")
);
assert_eq!(
env_variant_filename("custom.env.yaml", "staging"),
PathBuf::from("custom.env.staging.yaml")
);
}
#[test]
fn resolve_env_with_sources_tags_inline_entries() {
let dir = TempDir::new().unwrap();
let mut inline = HashMap::new();
inline.insert("base_url".into(), "http://localhost:3000".into());
let env = resolve_env_with_sources(
&inline,
None,
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
assert_eq!(entry.value, "http://localhost:3000");
assert!(matches!(entry.source, EnvSource::InlineEnvBlock));
}
#[test]
fn resolve_env_with_sources_reports_default_env_file_path() {
let dir = TempDir::new().unwrap();
setup_env_files(&dir, &[("tarn.env.yaml", "base_url: http://from-file")]);
let env = resolve_env_with_sources(
&HashMap::new(),
None,
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
assert_eq!(entry.value, "http://from-file");
match &entry.source {
EnvSource::DefaultEnvFile { path } => {
assert!(path.ends_with("tarn.env.yaml"), "got path: {}", path);
}
other => panic!("expected DefaultEnvFile source, got {:?}", other),
}
}
#[test]
fn resolve_env_with_sources_named_env_file_wins_over_default() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.staging.yaml", "base_url: http://staging"),
],
);
let env = resolve_env_with_sources(
&HashMap::new(),
Some("staging"),
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
assert_eq!(entry.value, "http://staging");
match &entry.source {
EnvSource::NamedEnvFile { env_name, path } => {
assert_eq!(env_name, "staging");
assert!(path.ends_with("tarn.env.staging.yaml"));
}
other => panic!("expected NamedEnvFile source, got {:?}", other),
}
}
#[test]
fn resolve_env_with_sources_cli_var_has_highest_priority() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.local.yaml", "base_url: http://local"),
],
);
let cli_vars = vec![("base_url".into(), "http://cli".into())];
let env = resolve_env_with_sources(
&HashMap::new(),
None,
&cli_vars,
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
assert_eq!(entry.value, "http://cli");
assert!(matches!(entry.source, EnvSource::CliVar));
}
#[test]
fn resolve_env_with_sources_local_env_file_overrides_named() {
let dir = TempDir::new().unwrap();
setup_env_files(
&dir,
&[
("tarn.env.yaml", "base_url: http://default"),
("tarn.env.staging.yaml", "base_url: http://staging"),
("tarn.env.local.yaml", "base_url: http://local"),
],
);
let env = resolve_env_with_sources(
&HashMap::new(),
Some("staging"),
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
assert_eq!(entry.value, "http://local");
assert!(matches!(entry.source, EnvSource::LocalEnvFile { .. }));
}
#[test]
fn resolve_env_with_sources_populates_file_declaration_range() {
let dir = TempDir::new().unwrap();
setup_env_files(&dir, &[("tarn.env.yaml", "base_url: http://from-file\n")]);
let env = resolve_env_with_sources(
&HashMap::new(),
None,
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
let entry = env.get("base_url").unwrap();
let range = entry
.declaration_range
.as_ref()
.expect("env files must populate declaration_range");
assert_eq!(range.line, 1, "single-line env file -> line 1");
assert!(range.column >= 1);
assert!(range.file.ends_with("tarn.env.yaml"));
}
#[test]
fn resolve_env_with_sources_cli_var_has_no_declaration_range() {
let dir = TempDir::new().unwrap();
let cli_vars = vec![("token".into(), "abc".into())];
let env = resolve_env_with_sources(
&HashMap::new(),
None,
&cli_vars,
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
assert!(env.get("token").unwrap().declaration_range.is_none());
}
#[test]
fn resolve_env_with_sources_inline_entries_start_with_no_declaration_range() {
let dir = TempDir::new().unwrap();
let mut inline = HashMap::new();
inline.insert("base_url".into(), "http://inline".into());
let env = resolve_env_with_sources(
&inline,
None,
&[],
dir.path(),
"tarn.env.yaml",
&HashMap::new(),
)
.unwrap();
assert!(env.get("base_url").unwrap().declaration_range.is_none());
}
#[test]
fn scan_top_level_key_locations_for_bare_env_file_points_at_value_scalar() {
let yaml = "base_url: http://localhost\ntoken: secret\n";
let out = scan_top_level_key_locations(yaml, "/tmp/tarn.env.yaml");
let base = out.get("base_url").expect("base_url present");
assert_eq!(base.line, 1);
assert!(base.column >= 10);
let token = out.get("token").expect("token present");
assert_eq!(token.line, 2);
}
#[test]
fn inline_env_locations_from_source_finds_keys_inside_env_block() {
let yaml = "\
name: Sample
env:
base_url: http://localhost
token: secret
steps:
- name: step1
request:
method: GET
url: http://localhost
";
let out = inline_env_locations_from_source(yaml, "/tmp/t.tarn.yaml");
let base = out.get("base_url").expect("base_url present");
assert_eq!(base.line, 3);
let token = out.get("token").expect("token present");
assert_eq!(token.line, 4);
assert!(!out.contains_key("name"));
}
#[test]
fn inline_env_locations_returns_empty_when_no_env_block() {
let yaml = "name: No env\nsteps:\n - name: a\n request: {method: GET, url: http://x}\n";
let out = inline_env_locations_from_source(yaml, "/tmp/t.tarn.yaml");
assert!(out.is_empty());
}
#[test]
fn env_source_label_and_source_file_accessors_are_stable() {
assert_eq!(EnvSource::InlineEnvBlock.label(), "inline env: block");
assert_eq!(EnvSource::CliVar.label(), "CLI --var");
assert_eq!(EnvSource::InlineEnvBlock.source_file(), None);
assert_eq!(EnvSource::CliVar.source_file(), None);
assert_eq!(
EnvSource::DefaultEnvFile {
path: "/tmp/tarn.env.yaml".into()
}
.source_file(),
Some("/tmp/tarn.env.yaml")
);
}
}