use std::collections::BTreeMap;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct RuntimeVars {
pub static_vars: BTreeMap<String, String>,
pub session_id: Option<Uuid>,
pub session_name: Option<String>,
pub pane_count: u32,
pub focused_pane: u32,
}
impl RuntimeVars {
#[must_use]
pub const fn new(static_vars: BTreeMap<String, String>) -> Self {
Self {
static_vars,
session_id: None,
session_name: None,
pane_count: 0,
focused_pane: 0,
}
}
pub fn resolve(&self, template: &str) -> String {
let mut result = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 2 < bytes.len()
&& bytes[i] == b'$'
&& bytes[i + 1] == b'$'
&& bytes[i + 2] == b'{'
{
result.push('$');
result.push('{');
i += 3;
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
if let Some(close) = template[i + 2..].find('}') {
let var_name = &template[i + 2..i + 2 + close];
if var_name.is_empty() {
tracing::warn!("empty variable reference: ${{}}");
result.push_str("${}");
i += 3; continue;
}
if let Some(value) = self.lookup(var_name) {
result.push_str(&value);
} else {
tracing::warn!("unresolved variable reference: ${{{var_name}}}");
result.push_str(&template[i..=(i + 2 + close)]);
}
i += 2 + close + 1;
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
#[must_use]
pub fn resolve_opt(&self, value: &str) -> String {
if value.contains("${") {
self.resolve(value)
} else {
value.to_string()
}
}
#[must_use]
pub fn resolve_bytes(&self, bytes: &[u8]) -> Vec<u8> {
if let Ok(s) = std::str::from_utf8(bytes)
&& s.contains("${")
{
return self.resolve(s).into_bytes();
}
bytes.to_vec()
}
fn lookup(&self, name: &str) -> Option<String> {
match name {
"SESSION_ID" => {
return self.session_id.map(|id| id.to_string());
}
"SESSION_NAME" => {
return self.session_name.clone();
}
"PANE_COUNT" => {
return Some(self.pane_count.to_string());
}
"FOCUSED_PANE" => {
return Some(self.focused_pane.to_string());
}
_ => {}
}
if let Some(value) = self.static_vars.get(name) {
return Some(value.clone());
}
std::env::var(name).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_static_var() {
let mut vars = BTreeMap::new();
vars.insert("GREETING".to_string(), "hello".to_string());
let rv = RuntimeVars::new(vars);
assert_eq!(rv.resolve("echo ${GREETING}"), "echo hello");
}
#[test]
fn resolve_env_var() {
let Some(home) = std::env::var("HOME").ok() else {
return;
};
let rv = RuntimeVars::new(BTreeMap::new());
assert_eq!(rv.resolve("val=${HOME}"), format!("val={home}"));
}
#[test]
fn resolve_runtime_session_id() {
let mut rv = RuntimeVars::new(BTreeMap::new());
let id = Uuid::nil();
rv.session_id = Some(id);
assert_eq!(rv.resolve("session=${SESSION_ID}"), format!("session={id}"));
}
#[test]
fn resolve_runtime_pane_count() {
let mut rv = RuntimeVars::new(BTreeMap::new());
rv.pane_count = 3;
assert_eq!(rv.resolve("panes=${PANE_COUNT}"), "panes=3");
}
#[test]
fn unresolved_left_as_is() {
let rv = RuntimeVars::new(BTreeMap::new());
assert_eq!(
rv.resolve("val=${UNKNOWN_VAR_ABC}"),
"val=${UNKNOWN_VAR_ABC}"
);
}
#[test]
fn no_substitution_markers_unchanged() {
let rv = RuntimeVars::new(BTreeMap::new());
assert_eq!(rv.resolve("plain text"), "plain text");
}
#[test]
fn multiple_substitutions() {
let mut vars = BTreeMap::new();
vars.insert("A".to_string(), "1".to_string());
vars.insert("B".to_string(), "2".to_string());
let rv = RuntimeVars::new(vars);
assert_eq!(rv.resolve("${A}+${B}"), "1+2");
}
#[test]
fn static_takes_priority_over_env() {
let Some(_home) = std::env::var("HOME").ok() else {
return;
};
let mut vars = BTreeMap::new();
vars.insert("HOME".to_string(), "static-val".to_string());
let rv = RuntimeVars::new(vars);
assert_eq!(rv.resolve("${HOME}"), "static-val");
}
#[test]
fn resolve_bytes_with_substitution() {
let mut vars = BTreeMap::new();
vars.insert("CMD".to_string(), "ls".to_string());
let rv = RuntimeVars::new(vars);
let input = b"echo ${CMD}\r";
let output = rv.resolve_bytes(input);
assert_eq!(output, b"echo ls\r");
}
#[test]
fn double_dollar_escapes_to_literal() {
let rv = RuntimeVars::new(BTreeMap::new());
assert_eq!(rv.resolve("$${HOME}"), "${HOME}");
}
#[test]
fn double_dollar_mid_string() {
let mut vars = BTreeMap::new();
vars.insert("X".to_string(), "resolved".to_string());
let rv = RuntimeVars::new(vars);
assert_eq!(
rv.resolve("before $${LITERAL} ${X} after"),
"before ${LITERAL} resolved after"
);
}
}