use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct SubstitutionContext {
pub arguments: Vec<String>,
pub skill_dir: Option<String>,
pub session_id: Option<String>,
pub extra_env: HashMap<String, String>,
}
pub fn substitute_skill_body(body: &str, ctx: &SubstitutionContext) -> String {
let bytes = body.as_bytes();
let mut out = String::with_capacity(body.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b != b'$' {
let ch_len = utf8_char_len(b);
out.push_str(&body[i..i + ch_len]);
i += ch_len;
continue;
}
let next = bytes.get(i + 1).copied();
match next {
Some(b'$') => {
out.push('$');
i += 2;
}
Some(b'{') => {
if let Some(close) = find_ascii(bytes, i + 2, b'}') {
let name = &body[i + 2..close];
out.push_str(&resolve_named(name, ctx, &body[i..close + 1]));
i = close + 1;
} else {
out.push('$');
i += 1;
}
}
Some(b'A') if body[i..].starts_with("$ARGUMENTS") => {
out.push_str(&ctx.arguments.join(" "));
i += "$ARGUMENTS".len();
}
Some(b) if b.is_ascii_digit() => {
let start = i + 1;
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
let digits = &body[start..end];
let idx: usize = digits.parse().unwrap_or(0);
if idx == 0 {
out.push_str(&body[i..end]);
} else if let Some(arg) = ctx.arguments.get(idx - 1) {
out.push_str(arg);
} else {
out.push_str(&body[i..end]);
}
i = end;
}
_ => {
out.push('$');
i += 1;
}
}
}
out
}
fn resolve_named(name: &str, ctx: &SubstitutionContext, original: &str) -> String {
match name {
"HARN_SKILL_DIR" => ctx
.skill_dir
.clone()
.unwrap_or_else(|| original.to_string()),
"HARN_SESSION_ID" => ctx
.session_id
.clone()
.unwrap_or_else(|| original.to_string()),
other => ctx.extra_env.get(other).cloned().unwrap_or_else(|| {
if other.starts_with("HARN_") {
std::env::var(other).unwrap_or_else(|_| original.to_string())
} else {
original.to_string()
}
}),
}
}
fn find_ascii(bytes: &[u8], from: usize, target: u8) -> Option<usize> {
bytes[from..]
.iter()
.position(|b| *b == target)
.map(|p| from + p)
}
fn utf8_char_len(first_byte: u8) -> usize {
if first_byte < 0xC0 {
1
} else if first_byte < 0xE0 {
2
} else if first_byte < 0xF0 {
3
} else {
4
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arguments_joined_with_spaces() {
let ctx = SubstitutionContext {
arguments: vec!["alpha".into(), "beta".into(), "gamma".into()],
..Default::default()
};
assert_eq!(
substitute_skill_body("run $ARGUMENTS now", &ctx),
"run alpha beta gamma now"
);
}
#[test]
fn positional_args_are_one_based() {
let ctx = SubstitutionContext {
arguments: vec!["alpha".into(), "beta".into()],
..Default::default()
};
assert_eq!(substitute_skill_body("$1 / $2", &ctx), "alpha / beta");
}
#[test]
fn missing_positional_arg_preserves_placeholder() {
let ctx = SubstitutionContext {
arguments: vec!["only".into()],
..Default::default()
};
assert_eq!(substitute_skill_body("$1 / $2", &ctx), "only / $2");
}
#[test]
fn skill_dir_and_session_id() {
let ctx = SubstitutionContext {
skill_dir: Some("/tmp/skills/deploy".into()),
session_id: Some("sess-abc".into()),
..Default::default()
};
assert_eq!(
substitute_skill_body("cd ${HARN_SKILL_DIR} && echo ${HARN_SESSION_ID}", &ctx),
"cd /tmp/skills/deploy && echo sess-abc"
);
}
#[test]
fn unknown_named_placeholder_looks_up_extra_env() {
let mut extra = HashMap::new();
extra.insert("HARN_USER_NAME".to_string(), "kenneth".to_string());
let ctx = SubstitutionContext {
extra_env: extra,
..Default::default()
};
assert_eq!(
substitute_skill_body("hi ${HARN_USER_NAME}!", &ctx),
"hi kenneth!"
);
}
#[test]
fn harn_named_placeholder_falls_back_to_process_env() {
std::env::set_var("HARN_CI_SUBSTITUTE_TEST_VALUE", "from-env");
let ctx = SubstitutionContext::default();
assert_eq!(
substitute_skill_body("${HARN_CI_SUBSTITUTE_TEST_VALUE}", &ctx),
"from-env"
);
std::env::remove_var("HARN_CI_SUBSTITUTE_TEST_VALUE");
}
#[test]
fn unknown_named_placeholder_passes_through_when_unset() {
let ctx = SubstitutionContext::default();
std::env::remove_var("HARN_CI_UNLIKELY_VAR_NAME_XYZ");
let body = "value=${HARN_CI_UNLIKELY_VAR_NAME_XYZ}";
assert_eq!(substitute_skill_body(body, &ctx), body);
}
#[test]
fn non_harn_placeholder_does_not_read_process_env() {
std::env::set_var("CI_SUBSTITUTE_TEST_SECRET", "secret-value");
let ctx = SubstitutionContext::default();
assert_eq!(
substitute_skill_body("key=${CI_SUBSTITUTE_TEST_SECRET}", &ctx),
"key=${CI_SUBSTITUTE_TEST_SECRET}"
);
std::env::remove_var("CI_SUBSTITUTE_TEST_SECRET");
}
#[test]
fn dollar_dollar_escapes_to_literal_dollar() {
let ctx = SubstitutionContext::default();
assert_eq!(substitute_skill_body("price: $$5", &ctx), "price: $5");
}
#[test]
fn lone_dollar_passes_through() {
let ctx = SubstitutionContext::default();
assert_eq!(
substitute_skill_body("cost is $ then done", &ctx),
"cost is $ then done"
);
}
#[test]
fn dollar_zero_is_reserved_and_passes_through() {
let ctx = SubstitutionContext {
arguments: vec!["first".into()],
..Default::default()
};
assert_eq!(substitute_skill_body("$0 -> $1", &ctx), "$0 -> first");
}
#[test]
fn utf8_bodies_are_preserved() {
let ctx = SubstitutionContext {
arguments: vec!["🚀".into()],
..Default::default()
};
assert_eq!(
substitute_skill_body("emoji → $1 ← end", &ctx),
"emoji → 🚀 ← end"
);
}
}