use std::collections::{HashMap, HashSet};
use crate::engine::ExecutionState;
fn substitute_variables_impl(
template: &str,
vars: &HashMap<String, String>,
strip_unresolved: bool,
) -> String {
let mut out = String::with_capacity(template.len());
let mut pos = 0;
let bytes = template.as_bytes();
while pos < bytes.len() {
if bytes[pos..].starts_with(b"{{") {
if let Some(end_rel) = template[pos + 2..].find("}}") {
let key = &template[pos + 2..pos + 2 + end_rel];
if let Some(value) = vars.get(key) {
out.push_str(value);
} else if !strip_unresolved {
out.push_str(&template[pos..pos + 2 + end_rel + 2]);
}
pos += 2 + end_rel + 2;
} else {
out.push_str(&template[pos..]);
pos = bytes.len();
}
} else {
let next = template[pos..]
.find("{{")
.map(|i| pos + i)
.unwrap_or(bytes.len());
out.push_str(&template[pos..next]);
pos = next;
}
}
out
}
pub fn substitute_variables(prompt: &str, vars: &HashMap<String, String>) -> String {
substitute_variables_impl(prompt, vars, true)
}
pub fn substitute_variables_keep_literal(template: &str, vars: &HashMap<String, String>) -> String {
substitute_variables_impl(template, vars, false)
}
pub fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub fn build_variable_map(state: &ExecutionState) -> HashMap<String, String> {
let mut vars: HashMap<String, String> = HashMap::new();
for (k, v) in &state.inputs {
vars.insert(k.clone(), v.clone());
}
for (k, v) in state.run_ctx.injected_variables() {
vars.insert(k.to_string(), v);
}
vars.insert("workflow_run_id".into(), state.workflow_run_id.clone());
let prior_context = state
.contexts
.last()
.map(|c| c.context.clone())
.unwrap_or_default();
vars.insert("prior_context".into(), prior_context);
let prior_contexts_json = if state.contexts.is_empty() {
"[]".to_string()
} else {
crate::helpers::serialize_or_empty_array(
&state.contexts,
"build_variable_map:prior_contexts",
)
};
vars.insert("prior_contexts".into(), prior_contexts_json);
if let Some(ref gf) = state.last_gate_feedback {
vars.insert("gate_feedback".into(), gf.clone());
}
if let Some(last_output) = state
.contexts
.iter()
.rev()
.find_map(|c| c.structured_output.as_ref())
{
vars.insert("prior_output".into(), last_output.clone());
}
if let Some(path) = state
.contexts
.iter()
.rev()
.find_map(|c| c.output_file.as_ref())
{
vars.insert("prior_output_file".into(), path.clone());
}
vars.insert("dry_run".into(), state.exec_config.dry_run.to_string());
let reserved: HashSet<&str> = state.run_ctx.injected_variables().into_keys().collect();
for c in &state.contexts {
let json = match &c.structured_output {
Some(j) => j,
None => continue,
};
let flow_output = match serde_json::from_str::<crate::helpers::FlowOutput>(json) {
Ok(out) => out,
Err(e) => {
tracing::warn!(
step = %c.step,
error = %e,
"build_variable_map: structured_output is not a valid FlowOutput JSON — \
{{name}} variable exports from this step will be unavailable",
);
continue;
}
};
for (key, value) in &flow_output.extras {
if reserved.contains(key.as_str()) || key == "workflow_run_id" {
tracing::warn!(
step = %c.step,
var = %key,
"script tried to export reserved variable name — ignoring",
);
continue;
}
if let Some(s) = value.as_str() {
vars.insert(key.clone(), s.to_string());
}
}
}
vars
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitute_strips_unresolved() {
let vars = HashMap::new();
let result = substitute_variables("hello {{unknown}}", &vars);
assert_eq!(result, "hello ");
}
#[test]
fn substitute_resolves_known_strips_unknown() {
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("name".into(), "world".to_string());
let result = substitute_variables("hello {{name}} and {{unknown}}", &vars);
assert_eq!(result, "hello world and ");
}
#[test]
fn substitute_keep_literal_preserves_unresolved() {
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("name".into(), "world".to_string());
let result = substitute_variables_keep_literal("hello {{name}} and {{unknown}}", &vars);
assert_eq!(result, "hello world and {{unknown}}");
}
#[test]
fn substitute_keep_literal_preserves_embedded_json() {
let json_value = r#"{"risks":["{{deterministic-review.score}}","other"]}"#.to_string();
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("prior_output".into(), json_value);
let result = substitute_variables_keep_literal("{{prior_output}}", &vars);
assert_eq!(
result,
r#"{"risks":["{{deterministic-review.score}}","other"]}"#
);
}
#[test]
fn substitute_no_double_substitution() {
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("a".into(), "{{b}}".to_string());
vars.insert("b".into(), "injected".to_string());
let result = substitute_variables_keep_literal("{{a}}", &vars);
assert_eq!(result, "{{b}}");
}
#[test]
fn shell_quote_no_double_substitution() {
let mut vars: HashMap<String, String> = HashMap::new();
vars.insert("cmd".into(), "'{{evil}}'".to_string()); vars.insert("evil".into(), ";rm -rf /".to_string());
let result = substitute_variables("run {{cmd}}", &vars);
assert_eq!(result, "run '{{evil}}'");
}
#[test]
fn build_variable_map_exposes_base_branch_from_prior_context() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-1".into(),
);
let vars = build_variable_map(&state);
assert!(
!vars.contains_key("base_branch"),
"no prior step → no base_branch variable"
);
state.contexts.push(crate::types::ContextEntry {
step: "resolve-pr-base".into(),
iteration: 0,
context: "release/0.10.0".into(),
markers: vec!["base_branch_resolved".into()],
structured_output: Some(
r#"{"markers":["base_branch_resolved"],"context":"release/0.10.0","base_branch":"release/0.10.0"}"#
.into(),
),
output_file: None,
});
let vars = build_variable_map(&state);
assert_eq!(
vars.get("base_branch").map(String::as_str),
Some("release/0.10.0"),
"base_branch must be exposed from prior structured_output"
);
state.contexts.push(crate::types::ContextEntry {
step: "detect-file-types".into(),
iteration: 0,
context: "code changes".into(),
markers: vec![],
structured_output: Some(r#"{"markers":[],"context":"Found 2 files"}"#.into()),
output_file: None,
});
let vars = build_variable_map(&state);
assert_eq!(
vars.get("base_branch").map(String::as_str),
Some("release/0.10.0"),
"later step without base_branch must not clobber the value"
);
state.contexts.push(crate::types::ContextEntry {
step: "override".into(),
iteration: 0,
context: "main".into(),
markers: vec![],
structured_output: Some(
r#"{"markers":[],"context":"main","base_branch":"main"}"#.into(),
),
output_file: None,
});
let vars = build_variable_map(&state);
assert_eq!(
vars.get("base_branch").map(String::as_str),
Some("main"),
"later step with base_branch must overwrite earlier value"
);
}
#[test]
fn substitute_uses_base_branch_from_variable_map() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-1".into(),
);
state.contexts.push(crate::types::ContextEntry {
step: "resolve-pr-base".into(),
iteration: 0,
context: "release/0.10.0".into(),
markers: vec![],
structured_output: Some(
r#"{"markers":[],"context":"release/0.10.0","base_branch":"release/0.10.0"}"#
.into(),
),
output_file: None,
});
let vars = build_variable_map(&state);
let rendered = substitute_variables("git diff origin/{{base_branch}}...HEAD", &vars);
assert_eq!(rendered, "git diff origin/release/0.10.0...HEAD");
}
#[test]
fn build_variable_map_exposes_arbitrary_string_extras() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-1".into(),
);
state.contexts.push(crate::types::ContextEntry {
step: "some-script".into(),
iteration: 0,
context: "ok".into(),
markers: vec![],
structured_output: Some(
r#"{"markers":[],"context":"ok","pr_number":"42","tag":"v1.2.3"}"#.into(),
),
output_file: None,
});
let vars = build_variable_map(&state);
assert_eq!(vars.get("pr_number").map(String::as_str), Some("42"));
assert_eq!(vars.get("tag").map(String::as_str), Some("v1.2.3"));
}
#[test]
fn build_variable_map_blocks_engine_injected_key_shadowing() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-real".into(),
);
{
let mut vars = std::collections::HashMap::new();
vars.insert("repo_path", "/repo/real".to_string());
vars.insert("ticket_id", "TICK-real".to_string());
state.run_ctx = Arc::new(crate::traits::run_context::NoopRunContext::with_vars(vars))
as Arc<dyn crate::traits::run_context::RunContext>;
}
let injected_keys: Vec<&'static str> =
state.run_ctx.injected_variables().into_keys().collect();
let mut malicious = serde_json::Map::new();
malicious.insert("markers".into(), serde_json::Value::Array(vec![]));
malicious.insert("context".into(), serde_json::Value::String("evil".into()));
for key in injected_keys
.iter()
.copied()
.chain(std::iter::once("workflow_run_id"))
{
malicious.insert(
key.into(),
serde_json::Value::String(format!("HIJACKED:{key}")),
);
}
let json = serde_json::to_string(&serde_json::Value::Object(malicious)).unwrap();
state.contexts.push(crate::types::ContextEntry {
step: "evil-script".into(),
iteration: 0,
context: "evil".into(),
markers: vec![],
structured_output: Some(json),
output_file: None,
});
let vars = build_variable_map(&state);
assert_eq!(
vars.get("workflow_run_id").map(String::as_str),
Some("run-real"),
"workflow_run_id must not be hijacked"
);
assert_eq!(
vars.get("repo_path").map(String::as_str),
Some("/repo/real"),
"repo_path must not be hijacked"
);
assert_eq!(
vars.get("ticket_id").map(String::as_str),
Some("TICK-real"),
"ticket_id must not be hijacked"
);
let injected_keys: Vec<&'static str> =
state.run_ctx.injected_variables().into_keys().collect();
for key in injected_keys
.iter()
.copied()
.chain(std::iter::once("workflow_run_id"))
{
if let Some(v) = vars.get(key) {
assert!(
!v.starts_with("HIJACKED:"),
"engine-injected key '{key}' was overridden by script export"
);
}
}
}
#[test]
fn build_variable_map_skips_steps_with_invalid_structured_output() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-1".into(),
);
state.contexts.push(crate::types::ContextEntry {
step: "broken-step".into(),
iteration: 0,
context: String::new(),
markers: vec![],
structured_output: Some("this is not json".into()),
output_file: None,
});
state.contexts.push(crate::types::ContextEntry {
step: "array-step".into(),
iteration: 0,
context: String::new(),
markers: vec![],
structured_output: Some(r#"["nope", "not an object"]"#.into()),
output_file: None,
});
state.contexts.push(crate::types::ContextEntry {
step: "good-step".into(),
iteration: 0,
context: "ok".into(),
markers: vec![],
structured_output: Some(r#"{"markers":[],"context":"ok","payload":"survived"}"#.into()),
output_file: None,
});
let vars = build_variable_map(&state);
assert!(!vars.contains_key("broken-step"));
assert_eq!(vars.get("payload").map(String::as_str), Some("survived"));
}
#[test]
fn build_variable_map_injects_ticket_url_and_friends_from_state_inputs() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-1".into(),
);
state.inputs.insert(
"ticket_url".into(),
"https://github.com/owner/repo/issues/42".into(),
);
state
.inputs
.insert("ticket_title".into(), "Fix something".into());
state
.inputs
.insert("ticket_body".into(), "body text".into());
state.inputs.insert("ticket_source_id".into(), "42".into());
state
.inputs
.insert("ticket_source_type".into(), "github".into());
state.inputs.insert("ticket_raw_json".into(), "{}".into());
state.inputs.insert("repo_name".into(), "owner/repo".into());
state
.inputs
.insert("user_var".into(), "user-supplied".into());
let vars = build_variable_map(&state);
assert_eq!(
vars.get("ticket_url").map(String::as_str),
Some("https://github.com/owner/repo/issues/42"),
"ticket_url must be exposed — this is the #2636 bug"
);
assert_eq!(
vars.get("ticket_title").map(String::as_str),
Some("Fix something"),
);
assert_eq!(
vars.get("ticket_body").map(String::as_str),
Some("body text")
);
assert_eq!(vars.get("ticket_source_id").map(String::as_str), Some("42"));
assert_eq!(
vars.get("ticket_source_type").map(String::as_str),
Some("github"),
);
assert_eq!(vars.get("ticket_raw_json").map(String::as_str), Some("{}"));
assert_eq!(
vars.get("repo_name").map(String::as_str),
Some("owner/repo")
);
assert_eq!(
vars.get("user_var").map(String::as_str),
Some("user-supplied"),
);
}
#[test]
fn build_variable_map_worktree_ctx_overrides_state_inputs_for_owned_keys() {
use crate::test_helpers::CountingPersistence;
use std::sync::Arc;
let cp = Arc::new(CountingPersistence::new());
let mut state = crate::test_helpers::make_test_execution_state(
cp as Arc<dyn crate::traits::persistence::WorkflowPersistence>,
"run-real".into(),
);
{
let mut vars = std::collections::HashMap::new();
vars.insert("ticket_id", "TICK-real".to_string());
vars.insert("repo_id", "repo-real".to_string());
vars.insert("repo_path", "/real".to_string());
state.run_ctx = Arc::new(crate::traits::run_context::NoopRunContext::with_vars(vars))
as Arc<dyn crate::traits::run_context::RunContext>;
}
state.inputs.insert("ticket_id".into(), "TICK-stale".into());
state.inputs.insert("repo_id".into(), "repo-stale".into());
state.inputs.insert("repo_path".into(), "/stale".into());
let vars = build_variable_map(&state);
assert_eq!(vars.get("ticket_id").map(String::as_str), Some("TICK-real"));
assert_eq!(vars.get("repo_id").map(String::as_str), Some("repo-real"));
assert_eq!(vars.get("repo_path").map(String::as_str), Some("/real"));
assert_eq!(
vars.get("workflow_run_id").map(String::as_str),
Some("run-real"),
);
}
}