use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone, Default)]
pub(crate) struct Vars {
values: BTreeMap<String, String>,
absent: Vec<String>,
}
impl Vars {
pub(crate) fn absent_names(&self) -> impl Iterator<Item = &String> {
self.absent.iter()
}
pub(crate) fn is_absent_option(&self, template: &str) -> bool {
template
.strip_prefix('{')
.and_then(|t| t.strip_suffix('}'))
.is_some_and(|name| self.absent.iter().any(|absent| absent == name))
}
pub(crate) fn set_secret(&mut self, name: &str, value: String) {
self.values.insert(format!("secret:{name}"), value);
}
pub(crate) fn absent_secret(&mut self, name: &str) {
self.absent.push(format!("secret:{name}"));
}
}
pub(crate) fn secret_names(template: &Value) -> Vec<String> {
fn walk(value: &Value, found: &mut Vec<String>) {
match value {
Value::String(text) => {
let mut rest = text.as_str();
while let Some(open) = rest.find("{secret:") {
let tail = &rest[open + 8..];
if let Some(end) = tail.find('}') {
found.push(tail[..end].to_owned());
rest = &tail[end + 1..];
} else {
break;
}
}
}
Value::Array(items) => items.iter().for_each(|item| walk(item, found)),
Value::Object(map) => map.values().for_each(|value| walk(value, found)),
_ => {}
}
}
let mut found = Vec::new();
walk(template, &mut found);
found
}
pub(crate) fn contains_placeholder(template: &Value, name: &str) -> bool {
match template {
Value::String(text) => contains_placeholder_text(text, name),
Value::Array(items) => items.iter().any(|item| contains_placeholder(item, name)),
Value::Object(fields) => fields
.values()
.any(|value| contains_placeholder(value, name)),
_ => false,
}
}
pub(crate) fn contains_placeholder_text(text: &str, name: &str) -> bool {
text.contains(&format!("{{{name}}}"))
}
#[derive(Default)]
pub(crate) struct VarSpec<'a> {
pub session_id: Option<&'a str>,
pub cwd: &'a str,
pub prompt: &'a str,
pub model: Option<&'a str>,
pub effort: Option<&'a str>,
pub mode: Option<&'a str>,
pub instructions: Option<&'a str>,
pub permission: Option<&'a str>,
pub turn_id: Option<&'a str>,
pub provider: Option<&'a str>,
}
pub(crate) fn vars(input: VarSpec<'_>) -> Vars {
let absent = [
("model", input.model),
("modelId", input.model),
(
"modelProvider",
input
.model
.and_then(|model| model.split_once('/').map(|(provider, _)| provider)),
),
("effort", input.effort),
("mode", input.mode),
("instructions", input.instructions),
("permission", input.permission),
("provider", input.provider),
("sessionId", input.session_id),
("turnId", input.turn_id),
]
.into_iter()
.filter(|(_, value)| value.is_none())
.map(|(name, _)| name.to_owned())
.collect();
let mut vars = BTreeMap::new();
vars.insert("cwd".to_owned(), input.cwd.to_owned());
vars.insert("prompt".to_owned(), input.prompt.to_owned());
let model = input.model.unwrap_or_default();
vars.insert("model".to_owned(), model.to_owned());
let (model_provider, model_id) = model.split_once('/').unwrap_or(("", model));
vars.insert("modelProvider".to_owned(), model_provider.to_owned());
vars.insert("modelId".to_owned(), model_id.to_owned());
vars.insert(
"provider".to_owned(),
input.provider.unwrap_or_default().to_owned(),
);
vars.insert(
"effort".to_owned(),
input.effort.unwrap_or_default().to_owned(),
);
vars.insert("mode".to_owned(), input.mode.unwrap_or_default().to_owned());
vars.insert(
"instructions".to_owned(),
input.instructions.unwrap_or_default().to_owned(),
);
vars.insert(
"permission".to_owned(),
input.permission.unwrap_or_default().to_owned(),
);
if let Some(session) = input.session_id {
vars.insert("sessionId".to_owned(), session.to_owned());
}
if let Some(turn) = input.turn_id {
vars.insert("turnId".to_owned(), turn.to_owned());
}
Vars {
values: vars,
absent,
}
}
pub(crate) fn substitute_text(template: &str, vars: &Vars) -> String {
let mut output = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
output.push_str(&rest[..open]);
let tail = &rest[open + 1..];
if let Some(end) = tail.find(['{', '}']) {
if tail.as_bytes()[end] == b'}' {
if let Some(value) = vars.values.get(&tail[..end]) {
output.push_str(value);
} else {
output.push('{');
output.push_str(&tail[..=end]);
}
rest = &tail[end + 1..];
continue;
}
}
output.push('{');
rest = tail;
}
output.push_str(rest);
output
}
pub(crate) fn substitute(template: &Value, vars: &Vars) -> Value {
match template {
Value::String(text) => Value::String(substitute_text(text, vars)),
Value::Array(items) => {
Value::Array(items.iter().map(|item| substitute(item, vars)).collect())
}
Value::Object(map) => Value::Object(
map.iter()
.filter_map(|(key, value)| {
let resolved = substitute(value, vars);
let absent = matches!(value, Value::String(t) if vars.is_absent_option(t));
(!absent).then(|| (key.clone(), resolved))
})
.collect(),
),
other => other.clone(),
}
}
pub(crate) fn extract_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut cursor = value;
for segment in path.split('.') {
cursor = cursor.get(segment)?;
}
Some(cursor)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn required_empty_text_and_provided_empty_instructions_are_preserved() {
let variables = vars(VarSpec {
prompt: "",
instructions: Some(""),
..Default::default()
});
assert_eq!(
substitute(
&json!({"text":"{prompt}","instructions":"{instructions}","model":"{model}"}),
&variables
),
json!({"text":"","instructions":""})
);
}
#[test]
fn substitution_never_reinterprets_inserted_values() {
let input = "explain {provider} \"quoted\"\nnext";
let vars = vars(VarSpec {
prompt: input,
instructions: Some("keep {sessionId}"),
session_id: Some("binding-{provider}"),
..Default::default()
});
assert_eq!(
substitute_text("{prompt}|{instructions}|{sessionId}", &vars),
format!("{input}|keep {{sessionId}}|binding-{{provider}}")
);
assert_eq!(
substitute(
&json!({"text":"prefix {prompt} suffix", "model":"{model}",
"instructions":"{instructions}"}),
&vars
),
json!({"text":format!("prefix {input} suffix"),"instructions":"keep {sessionId}"})
);
assert_eq!(substitute_text("{{prompt}}", &vars), format!("{{{input}}}"));
assert_eq!(substitute_text("{unknown}", &vars), "{unknown}");
}
}