use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TaskFile {
pub(crate) env: Vec<(String, String)>,
pub(crate) jobs: Vec<Job>,
pub(crate) warnings: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Job {
pub name: String,
pub description: String,
pub args: Vec<Arg>,
pub requires: Vec<String>,
pub agent_allow: bool,
pub(crate) lang: String,
pub(crate) script: String,
pub(crate) opts: Vec<String>,
pub(crate) env: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Arg {
pub name: String,
pub variadic: bool,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Invocation {
pub program: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingArg(pub String);
impl std::fmt::Display for MissingArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "missing value for argument `{}`", self.0)
}
}
impl std::error::Error for MissingArg {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DepError {
Missing { task: String, required_by: String },
Cycle(String),
}
impl std::fmt::Display for DepError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DepError::Missing { task, required_by } => {
write!(f, "task {required_by:?} requires unknown task {task:?}")
}
DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
}
}
}
impl std::error::Error for DepError {}
#[derive(Debug)]
pub enum RunError {
NotFound(String),
NotAllowed(String),
Injects { task: String, args: Vec<String> },
MissingArg(MissingArg),
Dependency(DepError),
Io(std::io::Error),
}
impl std::fmt::Display for RunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RunError::NotFound(name) => write!(f, "no task named {name:?}"),
RunError::NotAllowed(name) => write!(
f,
"task {name:?} is not available to agents (it lacks `Agent: allow`)"
),
RunError::Injects { task, args } => write!(
f,
"task {task:?} interpolates argument(s) [{}] into its script via {{{{ }}}} \
(raw substitution, an injection risk with agent-supplied values); it must \
read them from the environment instead (\"$arg\", os.environ[\"arg\"], ...) \
before an agent can run it. Refused.",
args.join(", ")
),
RunError::MissingArg(e) => e.fmt(f),
RunError::Dependency(e) => e.fmt(f),
RunError::Io(e) => e.fmt(f),
}
}
}
impl std::error::Error for RunError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RunError::MissingArg(e) => Some(e),
RunError::Dependency(e) => Some(e),
RunError::Io(e) => Some(e),
_ => None,
}
}
}
pub(crate) fn dependency_order(
target: &str,
requires_of: impl Fn(&str) -> Option<Vec<String>>,
) -> Result<Vec<String>, DepError> {
struct Frame {
name: String,
deps: Vec<String>,
next: usize,
}
let mut order = Vec::new();
let mut done = BTreeSet::new();
let mut on_stack = BTreeSet::new();
let mut stack: Vec<Frame> = Vec::new();
let deps = requires_of(target).ok_or_else(|| DepError::Missing {
task: target.to_string(),
required_by: target.to_string(),
})?;
on_stack.insert(target.to_string());
stack.push(Frame {
name: target.to_string(),
deps,
next: 0,
});
loop {
let descend = {
let Some(frame) = stack.last_mut() else { break };
if frame.next < frame.deps.len() {
let dep = frame.deps[frame.next].clone();
frame.next += 1;
Some(dep)
} else {
None
}
};
match descend {
Some(dep) => {
if done.contains(&dep) {
continue; }
if on_stack.contains(&dep) {
return Err(DepError::Cycle(dep));
}
let required_by = stack.last().expect("a top frame exists").name.clone();
let deps = requires_of(&dep).ok_or(DepError::Missing {
task: dep.clone(),
required_by,
})?;
on_stack.insert(dep.clone());
stack.push(Frame {
name: dep,
deps,
next: 0,
});
}
None => {
let frame = stack.pop().expect("a top frame exists");
on_stack.remove(&frame.name);
done.insert(frame.name.clone());
order.push(frame.name);
}
}
}
Ok(order)
}
pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd"];
impl Job {
pub(crate) fn inherits_cwd(&self) -> bool {
self.opts.iter().any(|o| o == "inherit-cwd")
}
pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
let mut found: Vec<&str> = Vec::new();
let mut rest = self.script.as_str();
while let Some(open) = rest.find("{{") {
let after = &rest[open + 2..];
let Some(close) = after.find("}}") else { break };
let tok = after[..close].trim();
if declared.contains(tok) && !found.contains(&tok) {
found.push(tok);
}
rest = &after[close + 2..];
}
found
}
}
impl TaskFile {
pub fn jobs(&self) -> &[Job] {
&self.jobs
}
pub fn job(&self, name: &str) -> Option<&Job> {
self.jobs.iter().find(|j| j.name == name)
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub(crate) fn invocation(
&self,
job: &Job,
args: &BTreeMap<String, String>,
cwd: &Path,
job_file_dir: Option<&Path>,
) -> Result<Invocation, MissingArg> {
let mut effective = args.clone();
for a in &job.args {
if !effective.contains_key(&a.name) {
if a.variadic {
effective.insert(a.name.clone(), String::new());
} else if let Some(d) = &a.default {
effective.insert(a.name.clone(), d.clone());
} else {
return Err(MissingArg(a.name.clone()));
}
}
}
let script = substitute(&job.script, &effective);
let (program, flag) = interpreter(&job.lang);
let mut env = self.env.clone();
env.extend(job.env.iter().cloned());
env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
let run_cwd = match job_file_dir {
_ if job.inherits_cwd() => cwd.to_path_buf(),
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => cwd.to_path_buf(),
};
Ok(Invocation {
program: program.to_string(),
args: vec![flag.to_string(), script],
env,
cwd: run_cwd,
})
}
pub(crate) fn bind(
job: &Job,
positional: &[String],
) -> Result<BTreeMap<String, String>, MissingArg> {
let mut map = BTreeMap::new();
let mut i = 0;
for a in &job.args {
if a.variadic {
map.insert(
a.name.clone(),
positional[i.min(positional.len())..].join(" "),
);
i = positional.len();
} else if i < positional.len() {
map.insert(a.name.clone(), positional[i].clone());
i += 1;
} else if let Some(d) = &a.default {
map.insert(a.name.clone(), d.clone());
} else {
return Err(MissingArg(a.name.clone()));
}
}
Ok(map)
}
}
pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for (_, tf) in files {
for job in &tf.jobs {
if seen.insert(job.name.clone()) && job.agent_allow {
out.push(job);
}
}
}
out
}
fn trusted_lookup<'a>(
files: &'a [(PathBuf, TaskFile)],
name: &str,
) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
files
.iter()
.find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
}
fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<String>, RunError> {
if trusted_lookup(files, target).is_none() {
return Err(RunError::NotFound(target.to_string()));
}
dependency_order(target, |n| {
trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
})
.map_err(RunError::Dependency)
}
fn plan_invocations<'a>(
order: &[String],
target: &str,
args: &[String],
cwd: &Path,
lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
) -> Result<Vec<Invocation>, RunError> {
let mut plan = Vec::with_capacity(order.len());
for step in order {
let (tf, job, dir) = lookup(step).expect("a resolved name still resolves");
let step_args: &[String] = if step == target { args } else { &[] };
let values = TaskFile::bind(job, step_args).map_err(RunError::MissingArg)?;
let inv = tf
.invocation(job, &values, cwd, dir)
.map_err(RunError::MissingArg)?;
plan.push(inv);
}
Ok(plan)
}
fn run_plan_captured(plan: &[Invocation]) -> Result<std::process::Output, RunError> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut status = None;
for inv in plan {
let out = inv.run_captured().map_err(RunError::Io)?;
stdout.extend_from_slice(&out.stdout);
stderr.extend_from_slice(&out.stderr);
let failed = !out.status.success();
status = Some(out.status);
if failed {
break;
}
}
Ok(std::process::Output {
status: status.expect("the plan always contains the target"),
stdout,
stderr,
})
}
pub fn run(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::ExitStatus, RunError> {
let order = trusted_order(files, name)?;
let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
let mut last = None;
for inv in &plan {
let status = inv.run_inherit().map_err(RunError::Io)?;
if !status.success() {
return Ok(status);
}
last = Some(status);
}
Ok(last.expect("the plan always contains the target"))
}
pub fn run_captured(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::Output, RunError> {
let order = trusted_order(files, name)?;
let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
run_plan_captured(&plan)
}
pub fn run_agent(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::Output, RunError> {
let mut target: Option<(&Path, &TaskFile, &Job)> = None;
for (p, tf) in files {
if let Some(job) = tf.job(name) {
if job.agent_allow {
target = Some((p.as_path(), tf, job));
}
break; }
}
let Some((target_path, target_tf, target_job)) = target else {
return Err(RunError::NotAllowed(name.to_string()));
};
let templated = target_job.script_arg_templates();
if !templated.is_empty() {
return Err(RunError::Injects {
task: name.to_string(),
args: templated.iter().map(|s| s.to_string()).collect(),
});
}
let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
.map_err(RunError::Dependency)?;
let dir = target_path.parent();
let plan = plan_invocations(&order, name, args, cwd, |n| {
target_tf.job(n).map(|j| (target_tf, j, dir))
})?;
run_plan_captured(&plan)
}
impl Invocation {
fn command(&self) -> std::process::Command {
let mut cmd = std::process::Command::new(&self.program);
cmd.args(&self.args)
.envs(self.env.iter().map(|(k, v)| (k, v)))
.current_dir(&self.cwd);
cmd
}
fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
self.command().status()
}
fn run_captured(&self) -> std::io::Result<std::process::Output> {
self.command().output()
}
}
fn interpreter(lang: &str) -> (&'static str, &'static str) {
match lang.trim().to_ascii_lowercase().as_str() {
"" | "sh" | "shell" => ("sh", "-c"),
"bash" => ("bash", "-c"),
"zsh" => ("zsh", "-c"),
"fish" => ("fish", "-c"),
"python" | "py" | "python3" => ("python3", "-c"),
"ruby" => ("ruby", "-e"),
"node" | "js" | "javascript" => ("node", "-e"),
_ => ("sh", "-c"),
}
}
fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(src.len());
let mut rest = src;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
if let Some(close) = after.find("}}") {
let name = after[..close].trim();
match args.get(name) {
Some(v) => out.push_str(v),
None => {
out.push_str("{{");
out.push_str(&after[..close]);
out.push_str("}}");
}
}
rest = &after[close + 2..];
} else {
out.push_str("{{");
rest = after;
}
}
out.push_str(rest);
out
}
pub fn parse(src: &str) -> TaskFile {
let mut file = TaskFile::default();
let mut cur: Option<Job> = None;
let mut in_fence = false;
let mut fence_marker = "";
let mut have_script = false; let mut script = String::new();
for raw in src.split('\n') {
let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
if is_closing_fence(line, fence_marker) {
in_fence = false;
if let Some(t) = cur.as_mut()
&& !have_script
{
t.script = std::mem::take(&mut script);
have_script = true;
}
script.clear();
} else if cur.is_some() && !have_script {
script.push_str(line);
script.push('\n');
}
continue;
}
if let Some(marker) = opening_fence(line) {
in_fence = true;
fence_marker = marker;
if let Some(t) = cur.as_mut()
&& !have_script
{
t.lang = info_string(line, marker);
}
script.clear();
continue;
}
if let Some(name) = heading(line) {
finalize(cur.take(), &mut file);
cur = Some(Job {
name,
..Job::default()
});
have_script = false;
continue;
}
apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
}
if in_fence {
if let Some(t) = cur.as_mut()
&& !have_script
{
t.script = std::mem::take(&mut script);
}
let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
file.warnings
.push(format!("unterminated code fence in task {name:?}"));
}
finalize(cur.take(), &mut file);
file
}
fn finalize(job: Option<Job>, file: &mut TaskFile) {
let Some(mut t) = job else {
return;
};
if t.script.is_empty() {
file.env.append(&mut t.env); return;
}
t.description = t.description.trim().to_string();
if file.jobs.iter().any(|x| x.name == t.name) {
file.warnings.push(format!(
"duplicate task {:?}; the first defined wins",
t.name
));
}
if !is_known_lang(&t.lang) {
file.warnings.push(format!(
"task {:?}: fenced language {:?} is not a known interpreter; running as sh",
t.name, t.lang
));
}
file.jobs.push(t);
}
fn is_known_lang(lang: &str) -> bool {
matches!(
lang.trim().to_ascii_lowercase().as_str(),
"" | "sh"
| "shell"
| "bash"
| "zsh"
| "fish"
| "python"
| "py"
| "python3"
| "ruby"
| "node"
| "js"
| "javascript"
)
}
fn opening_fence(line: &str) -> Option<&'static str> {
let t = line.trim_start();
if t.starts_with("```") {
Some("```")
} else if t.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn is_closing_fence(line: &str, marker: &str) -> bool {
let ch = marker.as_bytes()[0];
let t = line.trim();
t.len() >= 3 && t.bytes().all(|b| b == ch)
}
pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
let mut found = Vec::new();
for dir in start.ancestors() {
for name in ["tasks.md", "maskfile.md", "README.md"] {
let path = dir.join(name);
if let Ok(src) = std::fs::read_to_string(&path) {
let tf = parse(&src);
if !tf.jobs.is_empty() {
found.push((path, tf));
break; }
}
}
}
found
}
fn info_string(line: &str, marker: &str) -> String {
line.trim_start()
.strip_prefix(marker)
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
fn heading(line: &str) -> Option<String> {
let t = line.trim_start();
if !t.starts_with('#') {
return None;
}
let after = t.trim_start_matches('#');
if after == t || !after.starts_with(' ') {
return None;
}
Some(after.trim().to_string())
}
fn apply_line(
line: &str,
job: Option<&mut Job>,
file_env: &mut Vec<(String, String)>,
warnings: &mut Vec<String>,
) {
if let Some((key, value)) = split_key(line) {
let value = value.trim();
match key.as_str() {
"env" | "environment" => {
let pairs = parse_env(value);
match job {
Some(t) => t.env.extend(pairs),
None => file_env.extend(pairs), }
return;
}
"opts" | "options" => {
if let Some(t) = job {
t.opts = value.split_whitespace().map(str::to_string).collect();
for flag in &t.opts {
if !KNOWN_OPTS.contains(&flag.as_str()) {
warnings.push(format!(
"unknown option {flag:?} in `Opts:` (known: {})",
KNOWN_OPTS.join(", ")
));
}
}
}
return;
}
"args" | "arguments" => {
if let Some(t) = job {
t.args = parse_args(value);
}
return;
}
"requires" | "req" => {
if let Some(t) = job {
t.requires = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
return;
}
"agent" => {
if let Some(t) = job {
t.agent_allow = value.eq_ignore_ascii_case("allow");
}
return;
}
_ => {}
}
}
if let Some(t) = job
&& !line.trim().is_empty()
{
t.description.push_str(line.trim());
t.description.push('\n');
}
}
fn split_key(line: &str) -> Option<(String, &str)> {
let colon = line.find(':')?;
let key = line[..colon].trim();
if key.is_empty() || key.contains(char::is_whitespace) {
return None;
}
Some((key.to_ascii_lowercase(), &line[colon + 1..]))
}
fn parse_env(value: &str) -> Vec<(String, String)> {
value
.split(',')
.filter_map(|p| {
let (k, v) = p.split_once('=')?;
let k = k.trim();
if k.is_empty() {
return None;
}
Some((k.to_string(), v.trim().to_string()))
})
.collect()
}
fn parse_args(value: &str) -> Vec<Arg> {
tokenize_args(value)
.into_iter()
.filter_map(|tok| {
let (name, default) = match tok.split_once('=') {
Some((n, d)) => (n, Some(unquote(d).to_string())),
None => (tok.as_str(), None),
};
let (name, variadic) = match name.strip_prefix('*') {
Some(rest) => (rest, true),
None => (name, false),
};
let name = name.trim();
if name.is_empty() {
return None;
}
Some(Arg {
name: name.to_string(),
variadic,
default,
})
})
.collect()
}
fn tokenize_args(value: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in value.chars() {
match quote {
Some(q) => {
cur.push(c);
if c == q {
quote = None;
}
}
None if c == '\'' || c == '"' => {
cur.push(c);
quote = Some(c);
}
None if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
None => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn unquote(s: &str) -> &str {
let s = s.trim();
let b = s.as_bytes();
if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
&s[1..s.len() - 1]
} else {
s
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
pairs
.iter()
.map(|(path, src)| (PathBuf::from(path), parse(src)))
.collect()
}
#[test]
fn parses_named_jobs_with_interpreter() {
let tf =
parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
assert_eq!(tf.jobs.len(), 2);
assert_eq!(tf.jobs[0].name, "build");
assert_eq!(tf.jobs[0].lang, "sh");
assert_eq!(tf.jobs[0].script.trim(), "cargo build");
assert_eq!(tf.jobs[1].lang, "zsh");
}
#[test]
fn metadata_keys_are_case_insensitive() {
let tf = parse(
"## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
);
let t = &tf.jobs[0];
assert_eq!(t.opts, vec!["inherit-cwd"]);
assert!(t.inherits_cwd());
assert_eq!(
t.env,
vec![
("REGION".into(), "us".into()),
("TIER".into(), "prod".into())
]
);
assert_eq!(
t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["target"]
);
assert_eq!(t.requires, vec!["build", "test"]);
assert!(t.agent_allow);
}
#[test]
fn agent_gate_is_off_by_default() {
let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
assert!(!tf.jobs[0].agent_allow);
}
#[test]
fn top_level_env_is_hoisted() {
let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
}
#[test]
fn fence_content_is_not_parsed_as_structure() {
let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
assert_eq!(tf.jobs.len(), 1);
assert!(tf.jobs[0].script.contains("## not a task"));
assert!(tf.jobs[0].env.is_empty());
}
#[test]
fn substitutes_args_and_leaves_unknown_tokens() {
let out = substitute(
"hello {{ name }} and {{ other }}",
&args(&[("name", "world")]),
);
assert_eq!(out, "hello world and {{ other }}");
}
#[test]
fn invocation_substitutes_sets_env_and_picks_interpreter() {
let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
let j = tf.job("greet").unwrap();
let inv = tf
.invocation(
j,
&args(&[("name", "sam")]),
Path::new("/here"),
Some(Path::new("/file")),
)
.unwrap();
assert_eq!(inv.program, "zsh");
assert_eq!(inv.args[0], "-c");
assert!(inv.args[1].contains("hi sam"));
assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
assert_eq!(inv.cwd, Path::new("/file"));
}
#[test]
fn a_missing_required_arg_is_an_error() {
let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
let j = tf.job("t").unwrap();
assert_eq!(
tf.invocation(j, &args(&[]), Path::new("/here"), None),
Err(MissingArg("file".into()))
);
}
#[test]
fn optional_and_variadic_args_fill_from_defaults() {
let tf = parse(
"## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
);
let j = tf.job("t").unwrap();
assert!(!j.args[0].variadic && j.args[0].default.is_none());
assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
assert!(j.args[2].variadic);
let inv = tf
.invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
.unwrap();
assert!(inv.args[1].contains("echo x fallback "));
let bound =
TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
assert_eq!(bound.get("b").map(String::as_str), Some("y"));
assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
}
#[test]
fn default_cwd_is_the_task_file_dir() {
let tf = parse("## t\n\n```sh\ntrue\n```\n");
let j = tf.job("t").unwrap();
let inv = tf
.invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
.unwrap();
assert_eq!(inv.cwd, Path::new("/proj"));
let inv = tf
.invocation(j, &args(&[]), Path::new("/here"), None)
.unwrap();
assert_eq!(inv.cwd, Path::new("/here"));
let inv = tf
.invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
.unwrap();
assert_eq!(inv.cwd, Path::new("/here"));
}
#[test]
fn inherit_cwd_runs_in_the_invocation_dir() {
let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
let j = tf.job("t").unwrap();
assert!(j.inherits_cwd());
let inv = tf
.invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
.unwrap();
assert_eq!(inv.cwd, Path::new("/here"));
}
#[test]
fn an_unknown_opt_warns_but_is_ignored() {
let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
assert!(tf.jobs[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
}
fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
move |name| {
map.iter()
.find(|(n, _)| *n == name)
.map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
}
}
#[test]
fn dependency_order_is_deps_first_target_last() {
let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
}
#[test]
fn dependency_order_dedupes_a_diamond() {
let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
let order = dependency_order("a", g).unwrap();
assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
assert!(pos("d") < pos("b") && pos("d") < pos("c"));
assert_eq!(order.last().unwrap(), "a");
}
#[test]
fn dependency_order_detects_a_cycle() {
let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
}
#[test]
fn dependency_order_flags_a_missing_dependency() {
let g = deps_of(&[("a", &["ghost"])]);
assert_eq!(
dependency_order("a", g),
Err(DepError::Missing {
task: "ghost".into(),
required_by: "a".into(),
})
);
}
#[test]
fn dependency_order_survives_a_pathologically_deep_chain() {
const N: usize = 200_000;
let order = dependency_order("t0", |n| {
let i: usize = n.strip_prefix('t')?.parse().ok()?;
Some(if i + 1 < N {
vec![format!("t{}", i + 1)]
} else {
vec![]
})
})
.unwrap();
assert_eq!(order.len(), N);
assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); assert_eq!(order.last().unwrap(), "t0"); }
#[test]
fn script_arg_templates_flags_only_declared_args_in_the_script() {
let tf =
parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
let j = tf.job("t").unwrap();
assert_eq!(j.script_arg_templates(), vec!["name"]);
let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
}
#[test]
fn crlf_scripts_are_normalized() {
let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
assert!(!tf.jobs[0].script.contains('\r'));
}
#[test]
fn an_unterminated_fence_warns_but_keeps_the_job() {
let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.jobs.len(), 1);
assert_eq!(tf.jobs[0].script.trim(), "echo hi");
assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
}
#[test]
fn a_stray_fence_open_does_not_close_an_unterminated_block() {
let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
assert!(tf.jobs[0].script.contains("one"));
assert!(tf.jobs[0].script.contains("```sh\ntwo"));
}
#[test]
fn indented_metadata_is_recognized() {
let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
}
#[test]
fn duplicate_and_unknown_lang_warn() {
let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
assert_eq!(tf.jobs.len(), 2);
assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
assert!(tf.warnings.iter().any(|w| w.contains("json")));
}
#[test]
fn agent_jobs_filters_to_the_gated_ones() {
let f = files(&[(
"tasks.md",
"## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
)]);
let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["open"]);
}
#[test]
fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
let f = files(&[
("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
),
]);
assert!(agent_jobs(&f).is_empty());
}
#[test]
fn find_task_files_layers_child_over_parent() {
let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
let child = base.join("child");
std::fs::create_dir_all(&child).unwrap();
std::fs::write(
base.join("tasks.md"),
"## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
)
.unwrap();
std::fs::write(
child.join("tasks.md"),
"## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
)
.unwrap();
let files = find_task_files(&child);
assert_eq!(files.len(), 2, "child and parent files found");
assert!(files[0].0.starts_with(&child));
assert_eq!(
files[0].1.job("shared").unwrap().script.trim(),
"echo child"
);
assert!(files[1].1.job("base").is_some());
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn run_captured_returns_stdout() {
let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
}
#[test]
fn run_captured_runs_requires_deps_first() {
let f = files(&[(
"tasks.md",
"## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
)]);
let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
let text = String::from_utf8_lossy(&out.stdout);
let bpos = text.find('B').expect("B in output");
let apos = text.find('A').expect("A in output");
assert!(bpos < apos, "deps must run first: {text}");
}
#[test]
fn run_reports_an_unknown_target_as_not_found() {
let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
match run_captured(&f, "ghost", &[], Path::new(".")) {
Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
let f = files(&[
("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
),
]);
let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("real-build"), "got: {text}");
assert!(text.contains("real-deploy"), "got: {text}");
assert!(!text.contains("PWNED"), "nearer build ran: {text}");
assert!(out.status.success());
}
#[test]
fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
let f = files(&[(
"tasks.md",
"## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
)]);
match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
Err(RunError::Injects { task, args }) => {
assert_eq!(task, "greet");
assert_eq!(args, vec!["name".to_string()]);
}
other => panic!("expected Injects, got {other:?}"),
}
}
#[test]
fn run_agent_refuses_a_non_allowed_target() {
let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
match run_agent(&f, "secret", &[], Path::new(".")) {
Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
other => panic!("expected NotAllowed, got {other:?}"),
}
}
#[test]
fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
let f = files(&[
("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
),
]);
match run_agent(&f, "deploy", &[], Path::new(".")) {
Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
other => panic!("expected NotAllowed, got {other:?}"),
}
}
}