use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub skills: Vec<Skill>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub harness: Harness,
pub model: Option<String>,
pub timeout: Option<u64>,
pub env: Vec<EnvVar>,
pub profile: Option<String>,
pub commits: bool,
pub autoinstall: bool,
pub result: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvVar {
pub key: String,
pub rule: EnvRule,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvRule {
Require { src: String, message: String },
Default { src: String, default: String },
Constant(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Harness {
Opencode,
}
impl Harness {
pub fn parse(s: &str) -> Option<Harness> {
match s {
"opencode" => Some(Harness::Opencode),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Harness::Opencode => "opencode",
}
}
pub fn known() -> &'static [&'static str] {
&["opencode"]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Node {
Scalar(String),
Map(Vec<(String, Node)>),
}
struct Line {
indent: usize,
key: String,
inline: Option<String>,
lineno: usize,
}
pub fn demo_yaml() -> &'static str {
"# .scsh.yml — Scoped Skills Helper project configuration\n\
# Generated by `scsh --init-demo-project`. Edit freely; see the README for the schema.\n\
# The whole file is just your skills — scsh builds them on a built-in base image\n\
# (Debian, with opencode + a dev toolchain added). Run `scsh help .scsh.yml` for fields.\n\
\n\
# One or more scoped skills, keyed by skill name (matching a .skills/<name>/).\n\
# scsh builds the image once, then runs EVERY skill in parallel under its\n\
# harness, each in its own container with a fresh clone of this repo mounted.\n\
# harness: the built-in scsh harness that runs the skill (only `opencode`\n\
# today). The opencode harness runs, inside the container:\n\
# opencode -m <model> run \"run skill <name>\"\n\
# model: optional model the harness passes to the tool (omit for its default).\n\
# timeout: optional wall-clock limit (seconds); scsh kills the container and\n\
# fails the skill if its harness run exceeds it.\n\
# env: optional host variables to forward into the container, each a\n\
# `KEY: <spec>`. ${VAR} (or $VAR) requires VAR — scsh refuses the\n\
# skill if it is unset; ${VAR:-default} forwards VAR or injects\n\
# `default` when unset (${VAR:-} = empty); ${VAR:?message} requires\n\
# VAR, refusing with your message. A bare literal sets that literal.\n\
# profile: optional; a skill in a profile runs ONLY under `scsh run --profile <name>`,\n\
# not by default — use it for skills that need variables which may be unset.\n\
# commits: optional true/false (default false). When true, scsh brings commits the\n\
# skill makes in its clone back onto your branch (rebased; or saved to a\n\
# distinct scsh/incoming/<skill>-… branch if they don't apply cleanly).\n\
# It's a real side effect: running again adds the commit(s) again.\n\
# result: a repo-relative path the skill MUST create. scsh fails that skill's\n\
# run — and the whole invocation — if it is missing; otherwise it\n\
# copies the file back into your repo (backing up any existing one).\n\
# Keep it under the gitignored tmp/.\n\
skills:\n\
\x20\x20# add forwards A and B with injected defaults (${A:-2}): scsh resolves the\n\
\x20\x20# value, so the skill always sees A and B and reports their sum. Runs by\n\
\x20\x20# default — try `scsh run` or `A=10 B=20 scsh run`. It is commit-enabled:\n\
\x20\x20# it appends the sum to add_log.txt and commits it, and scsh rebases that\n\
\x20\x20# commit onto your branch (run it twice and you get two commits).\n\
\x20\x20add:\n\
\x20\x20\x20\x20harness: opencode\n\
\x20\x20\x20\x20model: openai/gpt-5.4-mini-fast\n\
\x20\x20\x20\x20timeout: 600\n\
\x20\x20\x20\x20commits: true\n\
\x20\x20\x20\x20env:\n\
\x20\x20\x20\x20\x20\x20- A: ${A:-2}\n\
\x20\x20\x20\x20\x20\x20- B: ${B:-3}\n\
\x20\x20\x20\x20result: tmp/add_result.json\n\
\x20\x20# multiply needs X and Y (no defaults), so it lives in the `multiply`\n\
\x20\x20# profile and declares them required (${X}/${Y}). A bare `scsh run` skips\n\
\x20\x20# it; `X=6 Y=7 scsh run --profile multiply` runs it; without X/Y, scsh\n\
\x20\x20# itself refuses it before the container starts.\n\
\x20\x20multiply:\n\
\x20\x20\x20\x20harness: opencode\n\
\x20\x20\x20\x20model: openai/gpt-5.4-mini-fast\n\
\x20\x20\x20\x20timeout: 600\n\
\x20\x20\x20\x20profile: multiply\n\
\x20\x20\x20\x20env:\n\
\x20\x20\x20\x20\x20\x20- X: ${X}\n\
\x20\x20\x20\x20\x20\x20- Y: ${Y}\n\
\x20\x20\x20\x20result: tmp/multiply_result.json\n"
}
pub fn bundled_skills() -> [(&'static str, &'static str); 1] {
[(
".skills/scsh-harness-demo-and-selftest/SKILL.md",
include_str!("../.skills/scsh-harness-demo-and-selftest/SKILL.md"),
)]
}
pub fn demo_skills() -> [(&'static str, &'static str, bool); 4] {
[
(".skills/add/SKILL.md", include_str!("../.skills/add/SKILL.md"), false),
(".skills/add/scripts/add.py", include_str!("../.skills/add/scripts/add.py"), true),
(".skills/multiply/SKILL.md", include_str!("../.skills/multiply/SKILL.md"), false),
(".skills/multiply/scripts/multiply.py", include_str!("../.skills/multiply/scripts/multiply.py"), true),
]
}
pub fn extract_skill_block(yaml: &str, name: &str) -> Option<String> {
let want = format!("{name}:");
let lines: Vec<&str> = yaml.lines().collect();
let start =
lines.iter().position(|l| l.starts_with(" ") && !l[2..].starts_with(' ') && l[2..].trim_end() == want)?;
let mut out = String::new();
out.push_str(lines[start]);
out.push('\n');
for &l in &lines[start + 1..] {
if !l.trim().is_empty() {
let indent = l.len() - l.trim_start().len();
if indent < 4 {
break; }
if l.trim_start().starts_with("autoinstall:") {
continue; }
}
out.push_str(l);
out.push('\n');
}
while out.ends_with("\n\n") {
out.pop();
}
Some(out)
}
pub fn validate(src: &str) -> Result<Config, Vec<String>> {
let entries = match parse_yaml(src) {
Ok(e) => e,
Err(e) => return Err(vec![format!("invalid YAML: {e}")]),
};
let mut errors = Vec::new();
let mut top: BTreeMap<&str, &Node> = BTreeMap::new();
for (k, v) in &entries {
if top.insert(k.as_str(), v).is_some() {
errors.push(format!("duplicate top-level key '{k}'"));
}
}
const KNOWN: &[&str] = &["skills"];
for (k, _) in &entries {
if !KNOWN.contains(&k.as_str()) {
errors.push(format!("unknown top-level key '{k}' (the only top-level key is 'skills')"));
}
}
let mut skills = Vec::new();
match top.get("skills").copied() {
None => errors.push("missing required key 'skills'".into()),
Some(Node::Scalar(_)) => errors.push("'skills' must be a mapping of named skills".into()),
Some(Node::Map(m)) if m.is_empty() => errors.push("'skills' must define at least one skill".into()),
Some(Node::Map(m)) => {
let mut seen: BTreeMap<&str, ()> = BTreeMap::new();
for (name, node) in m {
if name.trim().is_empty() {
errors.push("a skill name must not be empty".into());
continue;
}
if seen.insert(name.as_str(), ()).is_some() {
errors.push(format!("duplicate skill '{name}'"));
}
match node {
Node::Scalar(_) => {
errors.push(format!("skill '{name}' must be a mapping with 'harness' and 'result'"));
}
Node::Map(fields) => {
if let Some(skill) = validate_skill(name, fields, &mut errors) {
skills.push(skill);
}
}
}
}
}
}
if errors.is_empty() {
Ok(Config { skills })
} else {
Err(errors)
}
}
fn validate_skill(name: &str, fields: &[(String, Node)], errors: &mut Vec<String>) -> Option<Skill> {
let mut fm: BTreeMap<&str, &Node> = BTreeMap::new();
for (k, v) in fields {
if fm.insert(k.as_str(), v).is_some() {
errors.push(format!("duplicate key 'skills.{name}.{k}'"));
}
}
const SK: &[&str] = &["harness", "model", "timeout", "env", "profile", "commits", "autoinstall", "result"];
for (k, _) in fields {
if !SK.contains(&k.as_str()) {
errors.push(format!(
"unknown key 'skills.{name}.{k}' (allowed: harness, model, timeout, env, profile, commits, autoinstall, result)"
));
}
}
let harness = match fm.get("harness").copied() {
None => {
errors.push(format!("skill '{name}' is missing required key 'harness'"));
None
}
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.harness' must be a string, not a mapping"));
None
}
Some(Node::Scalar(s)) => match Harness::parse(s.trim()) {
Some(h) => Some(h),
None => {
errors.push(format!(
"'skills.{name}.harness' is '{}', not a known harness (known: {})",
s.trim(),
Harness::known().join(", ")
));
None
}
},
};
let model = match fm.get("model").copied() {
None => None,
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.model' must be a string, not a mapping"));
None
}
Some(Node::Scalar(s)) => {
let s = s.trim();
if s.is_empty() {
errors.push(format!("'skills.{name}.model' must not be empty (omit the key for the harness default)"));
None
} else {
Some(s.to_string())
}
}
};
let result = match fm.get("result").copied() {
None => {
errors.push(format!("skill '{name}' is missing required key 'result' (the output file it must produce)"));
None
}
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.result' must be a string, not a mapping"));
None
}
Some(Node::Scalar(s)) => {
let s = s.trim();
if s.is_empty() {
errors.push(format!("'skills.{name}.result' must not be empty"));
None
} else if !is_safe_relative(s) {
errors
.push(format!("'skills.{name}.result' must be a path inside the repo (got '{s}'): no leading '/', no '..'"));
None
} else {
Some(s.to_string())
}
}
};
let timeout = match fm.get("timeout").copied() {
None => None,
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.timeout' must be an integer number of seconds, not a mapping"));
None
}
Some(Node::Scalar(s)) => match s.trim().parse::<u64>() {
Ok(n) if n >= 1 => Some(n),
Ok(_) => {
errors.push(format!("'skills.{name}.timeout' must be a positive number of seconds"));
None
}
Err(_) => {
errors.push(format!("'skills.{name}.timeout' must be an integer number of seconds (got '{}')", s.trim()));
None
}
},
};
let env = match fm.get("env").copied() {
None => Vec::new(),
Some(Node::Scalar(_)) => {
errors.push(format!("'skills.{name}.env' must be a list of `KEY: <spec>` entries"));
Vec::new()
}
Some(Node::Map(entries)) => validate_env(name, entries, errors),
};
let profile = match fm.get("profile").copied() {
None => None,
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.profile' must be a string, not a mapping"));
None
}
Some(Node::Scalar(s)) => {
let s = s.trim();
if s.is_empty() {
errors.push(format!("'skills.{name}.profile' must not be empty (omit the key to run the skill by default)"));
None
} else {
Some(s.to_string())
}
}
};
let commits = match fm.get("commits").copied() {
None => false,
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.commits' must be true or false, not a mapping"));
false
}
Some(Node::Scalar(s)) => match s.trim() {
"true" => true,
"false" => false,
other => {
errors.push(format!("'skills.{name}.commits' must be true or false (got '{other}')"));
false
}
},
};
let autoinstall = match fm.get("autoinstall").copied() {
None => true,
Some(Node::Map(_)) => {
errors.push(format!("'skills.{name}.autoinstall' must be true or false, not a mapping"));
true
}
Some(Node::Scalar(s)) => match s.trim() {
"true" => true,
"false" => false,
other => {
errors.push(format!("'skills.{name}.autoinstall' must be true or false (got '{other}')"));
true
}
},
};
match (harness, result) {
(Some(harness), Some(result)) => {
Some(Skill { name: name.to_string(), harness, model, timeout, env, profile, commits, autoinstall, result })
}
_ => None,
}
}
fn validate_env(skill: &str, entries: &[(String, Node)], errors: &mut Vec<String>) -> Vec<EnvVar> {
let mut out = Vec::new();
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
for (raw_key, node) in entries {
let key = raw_key.strip_prefix("- ").unwrap_or(raw_key).trim().to_string();
if !is_env_name(&key) {
errors.push(format!("'skills.{skill}.env': '{key}' is not a valid environment variable name"));
continue;
}
if seen.insert(key.clone(), ()).is_some() {
errors.push(format!("'skills.{skill}.env': duplicate variable '{key}'"));
}
let spec = match node {
Node::Scalar(s) => s.trim(),
Node::Map(_) => {
errors.push(format!("'skills.{skill}.env.{key}' must be a string spec, not a mapping"));
continue;
}
};
match parse_env_spec(spec) {
Ok(rule) => out.push(EnvVar { key, rule }),
Err(e) => errors.push(format!("'skills.{skill}.env.{key}': {e}")),
}
}
out
}
fn parse_env_spec(value: &str) -> Result<EnvRule, String> {
const SHAPE: &str = "an env value must be a literal, ${VAR}, ${VAR:-default}, or ${VAR:?message}";
let value = value.trim();
if let Some(inner) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
let inner = inner.trim();
match inner.find(':') {
None => {
if is_env_name(inner) {
Ok(EnvRule::Require { src: inner.to_string(), message: required_message(inner) })
} else {
Err(format!("'{inner}' is not a valid environment variable name"))
}
}
Some(i) => {
let src = inner[..i].trim().to_string();
if !is_env_name(&src) {
return Err(format!("'{src}' is not a valid environment variable name"));
}
let rest = &inner[i + 1..];
if let Some(default) = rest.strip_prefix('-') {
Ok(EnvRule::Default { src, default: default.to_string() })
} else if let Some(message) = rest.strip_prefix('?') {
Ok(EnvRule::Require { src, message: message.trim().to_string() })
} else {
Err(SHAPE.to_string())
}
}
}
} else if let Some(name) = bare_var(value) {
Ok(EnvRule::Require { src: name.to_string(), message: required_message(name) })
} else if value.contains("${") {
Err(SHAPE.to_string())
} else {
Ok(EnvRule::Constant(value.to_string()))
}
}
fn bare_var(value: &str) -> Option<&str> {
let name = value.strip_prefix('$')?;
is_env_name(name).then_some(name)
}
fn required_message(name: &str) -> String {
format!(
"Environmental variable {name} is not provided, use the ${{{name}:-}} syntax to allow for empty values as defaults"
)
}
fn is_env_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn is_safe_relative(p: &str) -> bool {
if p.is_empty() || p.starts_with('/') || p.contains('\\') || p.contains('\0') {
return false;
}
p.split('/').all(|c| c != ".." && c != ".")
}
fn parse_yaml(src: &str) -> Result<Vec<(String, Node)>, String> {
let mut lines = Vec::new();
for (i, raw) in src.lines().enumerate() {
let lineno = i + 1;
let content = strip_comment(raw);
let trimmed = content.trim_end();
if trimmed.trim().is_empty() {
continue;
}
let indent = trimmed.len() - trimmed.trim_start().len();
if trimmed[..indent].contains('\t') {
return Err(format!("line {lineno}: tab in indentation; use spaces"));
}
let body = &trimmed[indent..];
let colon = find_colon(body).ok_or_else(|| format!("line {lineno}: expected 'key: value'"))?;
let key = body[..colon].trim().to_string();
if key.is_empty() {
return Err(format!("line {lineno}: empty key"));
}
let rest = body[colon + 1..].trim();
let inline = if rest.is_empty() { None } else { Some(unquote(rest)) };
lines.push(Line { indent, key, inline, lineno });
}
let mut idx = 0;
let entries = parse_block(&lines, &mut idx, 0)?;
if idx != lines.len() {
return Err(format!("line {}: unexpected indentation", lines[idx].lineno));
}
Ok(entries)
}
fn parse_block(lines: &[Line], idx: &mut usize, indent: usize) -> Result<Vec<(String, Node)>, String> {
let mut entries = Vec::new();
while *idx < lines.len() {
let line = &lines[*idx];
if line.indent < indent {
break;
}
if line.indent > indent {
return Err(format!("line {}: unexpected indentation", line.lineno));
}
let key = line.key.clone();
match &line.inline {
Some(v) => {
entries.push((key, Node::Scalar(v.clone())));
*idx += 1;
}
None => {
*idx += 1;
if *idx < lines.len() && lines[*idx].indent > indent {
let child_indent = lines[*idx].indent;
let children = parse_block(lines, idx, child_indent)?;
entries.push((key, Node::Map(children)));
} else {
entries.push((key, Node::Map(Vec::new())));
}
}
}
}
Ok(entries)
}
fn strip_comment(line: &str) -> String {
let mut out = String::new();
let (mut in_s, mut in_d, mut prev_ws) = (false, false, true);
for c in line.chars() {
match c {
'\'' if !in_d => {
in_s = !in_s;
out.push(c);
prev_ws = false;
}
'"' if !in_s => {
in_d = !in_d;
out.push(c);
prev_ws = false;
}
'#' if !in_s && !in_d && prev_ws => break,
_ => {
prev_ws = c.is_whitespace();
out.push(c);
}
}
}
out
}
fn find_colon(s: &str) -> Option<usize> {
let (mut in_s, mut in_d) = (false, false);
for (i, c) in s.char_indices() {
match c {
'\'' if !in_d => in_s = !in_s,
'"' if !in_s => in_d = !in_d,
':' if !in_s && !in_d => return Some(i),
_ => {}
}
}
None
}
fn unquote(s: &str) -> String {
let b = s.as_bytes();
if s.len() >= 2 {
let (first, last) = (b[0], b[s.len() - 1]);
if first == b'"' && last == b'"' {
return unescape_double(&s[1..s.len() - 1]);
}
if first == b'\'' && last == b'\'' {
return s[1..s.len() - 1].replace("''", "'");
}
}
s.to_string()
}
fn unescape_double(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn one_skill(extra_fields: &str) -> String {
format!("skills:\n s:\n{extra_fields}")
}
#[test]
fn demo_config_is_valid() {
let cfg = validate(demo_yaml()).expect("demo config should validate");
assert_eq!(cfg.skills.len(), 2);
let add = cfg.skills.iter().find(|s| s.name == "add").expect("add present");
assert_eq!(add.harness, Harness::Opencode);
assert_eq!(add.model.as_deref(), Some("openai/gpt-5.4-mini-fast"));
assert_eq!(add.timeout, Some(600));
assert_eq!(add.result, "tmp/add_result.json");
assert_eq!(add.profile, None);
assert!(add.commits, "add is commit-enabled (it commits the sum)");
assert_eq!(
add.env,
vec![
EnvVar { key: "A".into(), rule: EnvRule::Default { src: "A".into(), default: "2".into() } },
EnvVar { key: "B".into(), rule: EnvRule::Default { src: "B".into(), default: "3".into() } },
]
);
let mul = cfg.skills.iter().find(|s| s.name == "multiply").expect("multiply present");
assert_eq!(mul.profile.as_deref(), Some("multiply"));
assert_eq!(mul.result, "tmp/multiply_result.json");
assert!(!mul.commits, "multiply does not contribute commits");
assert_eq!(
mul.env,
vec![
EnvVar { key: "X".into(), rule: EnvRule::Require { src: "X".into(), message: required_message("X") } },
EnvVar { key: "Y".into(), rule: EnvRule::Require { src: "Y".into(), message: required_message("Y") } },
]
);
}
#[test]
fn bundled_skills_ship_the_demo_selftest() {
let skills = bundled_skills();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].0, ".skills/scsh-harness-demo-and-selftest/SKILL.md");
assert!(
skills[0].1.contains("name: scsh-harness-demo-and-selftest"),
"the bundled skill should be the demo/self-test"
);
}
#[test]
fn commits_is_an_optional_boolean() {
let yes = one_skill(" harness: opencode\n commits: true\n result: tmp/x.json\n");
assert!(validate(&yes).unwrap().skills[0].commits);
let no = one_skill(" harness: opencode\n commits: false\n result: tmp/x.json\n");
assert!(!validate(&no).unwrap().skills[0].commits);
let default = one_skill(" harness: opencode\n result: tmp/x.json\n");
assert!(!validate(&default).unwrap().skills[0].commits, "commits defaults to false");
let bad = one_skill(" harness: opencode\n commits: yes\n result: tmp/x.json\n");
assert!(validate(&bad).unwrap_err().iter().any(|e| e.contains("'skills.s.commits' must be true or false")));
}
#[test]
fn autoinstall_is_an_optional_boolean_defaulting_true() {
let default = one_skill(" harness: opencode\n result: tmp/x.json\n");
assert!(validate(&default).unwrap().skills[0].autoinstall, "autoinstall defaults to true");
let no = one_skill(" harness: opencode\n autoinstall: false\n result: tmp/x.json\n");
assert!(!validate(&no).unwrap().skills[0].autoinstall);
let yes = one_skill(" harness: opencode\n autoinstall: true\n result: tmp/x.json\n");
assert!(validate(&yes).unwrap().skills[0].autoinstall);
let bad = one_skill(" harness: opencode\n autoinstall: nope\n result: tmp/x.json\n");
assert!(validate(&bad).unwrap_err().iter().any(|e| e.contains("'skills.s.autoinstall' must be true or false")));
}
#[test]
fn extract_skill_block_keeps_fields_and_drops_autoinstall() {
let yaml = "skills:\n alpha:\n autoinstall: false\n harness: opencode\n profile: rev\n \
result: tmp/a.json\n beta:\n harness: opencode\n result: tmp/b.json\n";
let block = extract_skill_block(yaml, "alpha").expect("alpha present");
assert!(
block.contains(" alpha:") && block.contains(" harness: opencode") && block.contains(" profile: rev")
);
assert!(!block.contains("autoinstall"), "the autoinstall directive is dropped");
assert!(!block.contains("beta"), "only alpha's block, not the next skill");
let cfg = validate(&format!("skills:\n{block}")).expect("merged block is valid");
assert_eq!(cfg.skills.len(), 1);
assert_eq!(cfg.skills[0].name, "alpha");
assert!(cfg.skills[0].autoinstall, "the merged consumer entry has no autoinstall → defaults true");
assert!(extract_skill_block(yaml, "missing").is_none());
}
#[test]
fn profile_is_an_optional_non_empty_string() {
let with = one_skill(" harness: opencode\n profile: full\n result: tmp/x.json\n");
assert_eq!(validate(&with).unwrap().skills[0].profile.as_deref(), Some("full"));
let without = one_skill(" harness: opencode\n result: tmp/x.json\n");
assert_eq!(validate(&without).unwrap().skills[0].profile, None);
let empty = one_skill(" harness: opencode\n profile: \"\"\n result: tmp/x.json\n");
assert!(validate(&empty).unwrap_err().iter().any(|e| e.contains("'skills.s.profile' must not be empty")));
}
#[test]
fn env_specs_parse_list_and_mapping_forms() {
let list = one_skill(
" harness: opencode\n env:\n - A: ${A:-5}\n - X: ${X:?need X}\n - R: ${R}\n - S: $S\n - E: ${E:-}\n - LIT: hello\n result: tmp/x.json\n",
);
let env = &validate(&list).unwrap().skills[0].env;
assert_eq!(env[0], EnvVar { key: "A".into(), rule: EnvRule::Default { src: "A".into(), default: "5".into() } });
assert_eq!(
env[1],
EnvVar { key: "X".into(), rule: EnvRule::Require { src: "X".into(), message: "need X".into() } }
);
assert!(matches!(&env[2].rule, EnvRule::Require { src, message } if src == "R" && message.contains("${R:-}")));
assert!(matches!(&env[3].rule, EnvRule::Require { src, message } if src == "S" && message.contains("${S:-}")));
assert_eq!(env[4], EnvVar { key: "E".into(), rule: EnvRule::Default { src: "E".into(), default: "".into() } });
assert_eq!(env[5], EnvVar { key: "LIT".into(), rule: EnvRule::Constant("hello".into()) });
let map = one_skill(" harness: opencode\n env:\n A: ${A:-5}\n result: tmp/x.json\n");
assert_eq!(
validate(&map).unwrap().skills[0].env,
vec![EnvVar { key: "A".into(), rule: EnvRule::Default { src: "A".into(), default: "5".into() } }]
);
}
#[test]
fn bare_var_is_required_with_guidance_message() {
for spec in ["${A}", "$A"] {
let src = one_skill(&format!(" harness: opencode\n env:\n - A: {spec}\n result: tmp/x.json\n"));
let rule = &validate(&src).unwrap().skills[0].env[0].rule;
match rule {
EnvRule::Require { src, message } => {
assert_eq!(src, "A");
assert!(message.contains("Environmental variable A is not provided"), "got {message}");
assert!(message.contains("${A:-}"), "message should suggest the empty-default syntax; got {message}");
}
other => panic!("expected Require for {spec}, got {other:?}"),
}
}
}
#[test]
fn timeout_is_an_optional_positive_integer() {
let with = one_skill(" harness: opencode\n timeout: 30\n result: tmp/x.json\n");
assert_eq!(validate(&with).unwrap().skills[0].timeout, Some(30));
let without = one_skill(" harness: opencode\n result: tmp/x.json\n");
assert_eq!(validate(&without).unwrap().skills[0].timeout, None);
let zero = one_skill(" harness: opencode\n timeout: 0\n result: tmp/x.json\n");
assert!(validate(&zero).unwrap_err().iter().any(|e| e.contains("positive number of seconds")));
let bad = one_skill(" harness: opencode\n timeout: soon\n result: tmp/x.json\n");
assert!(validate(&bad).unwrap_err().iter().any(|e| e.contains("integer number of seconds")));
}
#[test]
fn env_rejects_bad_specs_names_and_dups() {
let bad_spec = one_skill(" harness: opencode\n env:\n - A: ${A:@x}\n result: tmp/x.json\n");
assert!(validate(&bad_spec).unwrap_err().iter().any(|e| e.contains("env.A")), "got {:?}", validate(&bad_spec));
let bad_name = one_skill(" harness: opencode\n env:\n - 1BAD: ${X:-1}\n result: tmp/x.json\n");
assert!(validate(&bad_name).unwrap_err().iter().any(|e| e.contains("not a valid environment variable name")));
let dup =
one_skill(" harness: opencode\n env:\n - A: ${A:-1}\n - A: ${A:-2}\n result: tmp/x.json\n");
assert!(validate(&dup).unwrap_err().iter().any(|e| e.contains("duplicate variable 'A'")));
}
#[test]
fn parses_two_level_skills_in_file_order() {
let src = "skills:\n build:\n harness: opencode\n result: tmp/out.json\n test:\n harness: opencode\n model: m\n result: tmp/test.json\n";
let cfg = validate(src).unwrap();
assert_eq!(cfg.skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["build", "test"]);
assert_eq!(cfg.skills[0].model, None); assert_eq!(cfg.skills[1].model.as_deref(), Some("m"));
}
#[test]
fn result_is_required_per_skill() {
let errs = validate(&one_skill(" harness: opencode\n")).unwrap_err();
assert!(errs.iter().any(|e| e.contains("missing required key 'result'")), "got {errs:?}");
}
#[test]
fn harness_is_required_and_validated() {
let missing = validate(&one_skill(" result: tmp/x.json\n")).unwrap_err();
assert!(missing.iter().any(|e| e.contains("missing required key 'harness'")), "got {missing:?}");
let unknown = validate(&one_skill(" harness: bogus\n result: tmp/x.json\n")).unwrap_err();
assert!(unknown.iter().any(|e| e.contains("not a known harness")), "got {unknown:?}");
}
#[test]
fn result_rejects_paths_outside_the_repo() {
for bad in ["/etc/passwd", "../escape.json", "a/../../b.json"] {
let errs = validate(&one_skill(&format!(" harness: opencode\n result: {bad}\n"))).unwrap_err();
assert!(
errs.iter().any(|e| e.contains("must be a path inside the repo")),
"expected a result-path error for {bad}, got {errs:?}"
);
}
}
#[test]
fn unknown_skill_key_reported() {
let errs = validate(&one_skill(" harness: opencode\n result: tmp/x.json\n bogus: y\n")).unwrap_err();
assert!(errs.iter().any(|e| e.contains("unknown key 'skills.s.bogus'")), "got {errs:?}");
}
#[test]
fn at_least_one_skill_required() {
let errs = validate("skills:\n").unwrap_err();
assert!(errs.iter().any(|e| e.contains("at least one skill")), "got {errs:?}");
}
#[test]
fn duplicate_skill_reported() {
let src = "skills:\n s:\n harness: opencode\n result: tmp/a.json\n s:\n harness: opencode\n result: tmp/b.json\n";
let errs = validate(src).unwrap_err();
assert!(errs.iter().any(|e| e.contains("duplicate skill 's'")), "got {errs:?}");
}
#[test]
fn strips_comments_full_line_and_trailing() {
let src = "# a comment\nskills:\n s:\n harness: opencode # trailing comment\n result: tmp/x.json\n";
let cfg = validate(src).unwrap();
assert_eq!(cfg.skills.len(), 1);
assert_eq!(cfg.skills[0].name, "s");
}
#[test]
fn hash_inside_quotes_is_not_a_comment() {
let src = one_skill(" harness: opencode\n model: \"gpt#1\"\n result: tmp/x.json\n");
let cfg = validate(&src).unwrap();
assert_eq!(cfg.skills[0].model.as_deref(), Some("gpt#1"));
}
#[test]
fn missing_skills_reported() {
let errs = validate("# nothing here\n").unwrap_err();
assert!(errs.iter().any(|e| e.contains("missing required key 'skills'")), "got {errs:?}");
}
#[test]
fn skills_must_be_mapping() {
let errs = validate("skills: hello\n").unwrap_err();
assert!(errs.iter().any(|e| e.contains("mapping")));
}
#[test]
fn unknown_top_level_key_reported() {
let errs =
validate(&format!("{}version: 1\n", one_skill(" harness: opencode\n result: tmp/x.json\n"))).unwrap_err();
assert!(errs.iter().any(|e| e.contains("unknown top-level key 'version'")), "got {errs:?}");
assert!(errs.iter().any(|e| e.contains("only top-level key is 'skills'")), "got {errs:?}");
}
#[test]
fn duplicate_top_level_key_reported() {
let errs = validate(
"skills:\n s:\n harness: opencode\n result: tmp/a.json\nskills:\n t:\n harness: opencode\n result: tmp/b.json\n",
)
.unwrap_err();
assert!(errs.iter().any(|e| e.contains("duplicate top-level key 'skills'")), "got {errs:?}");
}
#[test]
fn all_problems_collected_at_once() {
let errs = validate("nope: 1\nskills:\n s:\n bogus: y\n").unwrap_err();
assert!(errs.len() >= 3, "expected several problems, got {errs:?}");
}
}