use std::collections::HashMap;
use std::sync::LazyLock;
use anyhow::{Result, anyhow, bail};
use indexmap::IndexMap;
use regex::Regex;
use serde_yaml::Value;
use crate::issues::{Issue, IssueSink};
pub const RESERVED: &[&str] = &["env", "org", "region"];
static VAR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}").unwrap()
});
#[derive(Debug)]
pub struct Resolver {
vars: HashMap<String, String>,
}
impl Resolver {
pub fn build(
file_vars: &IndexMap<String, String>,
cli_vars: &[(String, String)],
org: String,
region: String,
) -> Result<Self> {
for k in file_vars.keys() {
if RESERVED.contains(&k.as_str()) {
bail!(
"variable `{k}` is reserved and cannot be set in the project file's `variables` section"
);
}
}
let mut vars: HashMap<String, String> = file_vars
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (k, v) in cli_vars {
vars.insert(k.clone(), v.clone());
}
vars.entry("env".to_string())
.or_insert_with(|| "prod".to_string());
vars.insert("org".to_string(), org);
vars.insert("region".to_string(), region);
Ok(Self { vars })
}
#[allow(dead_code)]
pub fn get(&self, name: &str) -> Option<&str> {
self.vars.get(name).map(String::as_str)
}
#[allow(dead_code)]
pub fn resolve_string(&self, s: &str) -> Result<String> {
let (out, missing) = self.resolve_string_inner(s);
if let Some(name) = missing.into_iter().next() {
return Err(anyhow!("undefined variable `{name}` (in `{s}`)"));
}
Ok(out)
}
pub fn resolve_string_collecting(&self, s: &str, issues: &mut Vec<Issue>) -> String {
let (out, missing) = self.resolve_string_inner(s);
for name in missing {
issues.push_issue(format!("undefined variable `{name}` (in `{s}`)"));
}
out
}
fn resolve_string_inner(&self, s: &str) -> (String, Vec<String>) {
let mut missing: Vec<String> = Vec::new();
let result = VAR_RE.replace_all(s, |caps: ®ex::Captures| {
let name = &caps[1];
match self.vars.get(name) {
Some(v) => v.clone(),
None => {
if !missing.iter().any(|n| n == name) {
missing.push(name.to_string());
}
String::new()
}
}
});
(result.into_owned(), missing)
}
pub fn resolve_value(&self, v: &mut Value, issues: &mut Vec<Issue>) {
match v {
Value::String(s) => {
let resolved = self.resolve_string_collecting(s, issues);
*s = resolved;
}
Value::Sequence(seq) => {
for item in seq {
self.resolve_value(item, issues);
}
}
Value::Mapping(map) => {
for (_, val) in map.iter_mut() {
self.resolve_value(val, issues);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r(pairs: &[(&str, &str)], org: &str, region: &str) -> Resolver {
let file: IndexMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Resolver::build(&file, &[], org.to_string(), region.to_string()).unwrap()
}
#[test]
fn resolves_simple() {
let r = r(&[("foo", "bar")], "myorg", "par");
assert_eq!(r.resolve_string("hello ${foo}").unwrap(), "hello bar");
}
#[test]
fn resolves_special_vars() {
let r = r(&[], "myorg", "par");
assert_eq!(r.resolve_string("${org}/${region}").unwrap(), "myorg/par");
assert_eq!(r.resolve_string("${env}").unwrap(), "prod");
}
#[test]
fn env_override_via_cli() {
let file = IndexMap::new();
let r = Resolver::build(
&file,
&[("env".to_string(), "staging".to_string())],
"o".to_string(),
"par".to_string(),
)
.unwrap();
assert_eq!(r.resolve_string("${env}").unwrap(), "staging");
}
#[test]
fn cli_overrides_file() {
let file: IndexMap<String, String> = [("foo".to_string(), "fromfile".to_string())]
.into_iter()
.collect();
let r = Resolver::build(
&file,
&[("foo".to_string(), "fromcli".to_string())],
"o".to_string(),
"par".to_string(),
)
.unwrap();
assert_eq!(r.resolve_string("${foo}").unwrap(), "fromcli");
}
#[test]
fn rejects_reserved_in_file() {
let file: IndexMap<String, String> =
[("org".to_string(), "x".to_string())].into_iter().collect();
let err = Resolver::build(&file, &[], "o".to_string(), "par".to_string()).unwrap_err();
assert!(err.to_string().contains("reserved"));
}
#[test]
fn missing_var_errors() {
let r = r(&[], "o", "par");
let err = r.resolve_string("hello ${nope}").unwrap_err();
assert!(err.to_string().contains("nope"));
}
#[test]
fn multiple_in_one_string() {
let r = r(&[("a", "1"), ("b", "2")], "o", "par");
assert_eq!(r.resolve_string("${a}-${b}-${a}").unwrap(), "1-2-1");
}
#[test]
fn walks_value_tree() {
let r = r(&[("name", "world")], "o", "par");
let mut v: Value =
serde_yaml::from_str("greet: hello ${name}\nlist:\n - ${name}\n - other\n").unwrap();
let mut issues = Vec::new();
r.resolve_value(&mut v, &mut issues);
assert!(issues.is_empty());
let s = serde_yaml::to_string(&v).unwrap();
assert!(s.contains("hello world"));
assert!(s.contains("- world"));
}
#[test]
fn resolve_value_accumulates_missing_vars() {
let r = r(&[], "o", "par");
let mut v: Value = serde_yaml::from_str("a: ${x}\nb:\n - ${y}\n - ${z}\n").unwrap();
let mut issues = Vec::new();
r.resolve_value(&mut v, &mut issues);
assert_eq!(issues.len(), 3, "got: {issues:#?}");
}
#[test]
fn resolve_value_replaces_missing_with_empty() {
let r = r(&[("known", "ok")], "o", "par");
let mut v: Value =
serde_yaml::from_str("a: prefix-${unknown}-suffix\nb: ${known}").unwrap();
let mut issues = Vec::new();
r.resolve_value(&mut v, &mut issues);
assert_eq!(issues.len(), 1);
let s = serde_yaml::to_string(&v).unwrap();
assert!(s.contains("prefix--suffix"));
assert!(s.contains("ok"));
}
}