use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::run::{interpreter, substitute};
#[allow(unused_imports)]
use crate::{
discover::find_task_files,
run::{agent_jobs, run, run_agent, run_captured},
};
#[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>,
pub(crate) opts: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Job {
pub name: String,
pub description: String,
pub args: Vec<Arg>,
pub requires: Vec<Requirement>,
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 Requirement {
pub name: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Arg {
pub name: String,
pub variadic: bool,
pub default: Option<String>,
}
impl Arg {
pub fn is_valid_name(&self) -> bool {
let mut chars = self.name.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Invocation {
pub task: String,
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)]
#[non_exhaustive]
pub enum RunError {
NotFound(String),
NotAllowed(String),
Injects { task: String, args: Vec<String> },
MissingArg(MissingArg),
InvalidArgName { task: String, args: Vec<String> },
Dependency(DepError),
Cancelled,
Io {
task: String,
program: String,
cwd: PathBuf,
source: 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::Cancelled => write!(f, "cancelled"),
RunError::InvalidArgName { task, args } => write!(
f,
"task {task:?} declares argument(s) [{}] whose name(s) cannot be a shell \
variable, so the script could never read them. `Args:` is whitespace-separated \
(just's syntax), so a comma becomes part of the name: write `Args: a b`, not \
`Args: a, b`. Refused.",
args.join(", ")
),
RunError::MissingArg(e) => e.fmt(f),
RunError::Dependency(e) => e.fmt(f),
RunError::Io {
task,
program,
cwd,
source,
} => {
write!(f, "task {task:?}: could not run {program:?}")?;
if source.kind() == std::io::ErrorKind::NotFound {
return if cwd.is_dir() {
write!(f, ": not installed, or not on PATH")
} else {
write!(f, " in {}: that directory does not exist", cwd.display())
};
}
write!(f, " in {}: {source}", cwd.display())
}
}
}
}
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 { source, .. } => Some(source),
_ => None,
}
}
}
pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd", "no-strict"];
pub(crate) const KNOWN_FILE_OPTS: &[&str] = &["include-parent"];
impl Job {
pub fn script(&self) -> &str {
&self.script
}
pub fn lang(&self) -> &str {
&self.lang
}
pub fn opts(&self) -> &[String] {
&self.opts
}
pub fn env(&self) -> &[(String, String)] {
&self.env
}
pub(crate) fn inherits_cwd(&self) -> bool {
self.opts.iter().any(|o| o == "inherit-cwd")
}
pub(crate) fn is_strict(&self) -> bool {
!self.opts.iter().any(|o| o == "no-strict")
}
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 includes_parent(&self) -> bool {
self.opts.iter().any(|o| o == "include-parent")
}
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 lang = interpreter(&job.lang);
let (program, flag) = (lang.program, lang.flag);
let script = match lang.prelude {
Some(prelude) if job.is_strict() => format!("{prelude}\n{script}"),
_ => script,
};
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 {
task: job.name.clone(),
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn io_error(kind: std::io::ErrorKind, cwd: &str) -> RunError {
RunError::Io {
task: "deploy".into(),
program: "ruby".into(),
cwd: PathBuf::from(cwd),
source: std::io::Error::new(kind, "boom"),
}
}
#[test]
fn a_spawn_failure_says_which_task_and_which_program() {
let msg = io_error(std::io::ErrorKind::NotFound, ".").to_string();
assert!(msg.contains("deploy"), "{msg}");
assert!(msg.contains("ruby"), "{msg}");
}
#[test]
fn a_missing_interpreter_and_a_missing_directory_read_differently() {
let missing_program = io_error(std::io::ErrorKind::NotFound, ".").to_string();
assert!(missing_program.contains("not on PATH"), "{missing_program}");
let missing_dir =
io_error(std::io::ErrorKind::NotFound, "/no/such/place/at/all").to_string();
assert!(
missing_dir.contains("that directory does not exist"),
"{missing_dir}"
);
assert!(
missing_dir.contains("/no/such/place/at/all"),
"{missing_dir}"
);
}
#[test]
fn another_spawn_failure_still_reports_the_underlying_error() {
let msg = io_error(std::io::ErrorKind::PermissionDenied, ".").to_string();
assert!(msg.contains("deploy") && msg.contains("boom"), "{msg}");
}
use crate::parse::parse;
fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[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 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());
}
}