use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TaskFile {
pub env: Vec<(String, String)>,
pub tasks: Vec<Task>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Task {
pub name: String,
pub description: String,
pub lang: String,
pub script: String,
pub opts: Vec<String>,
pub env: Vec<(String, String)>,
pub args: Vec<Arg>,
pub requires: Vec<String>,
pub agent_allow: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Arg {
pub name: String,
pub variadic: bool,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub 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 {}
pub 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 = std::collections::BTreeSet::new();
let mut on_stack = std::collections::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 const KNOWN_OPTS: &[&str] = &["inherit-cwd"];
impl Task {
pub fn inherits_cwd(&self) -> bool {
self.opts.iter().any(|o| o == "inherit-cwd")
}
pub fn script_arg_templates(&self) -> Vec<&str> {
let declared: std::collections::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 task(&self, name: &str) -> Option<&Task> {
self.tasks.iter().find(|t| t.name == name)
}
pub fn agent_tasks(&self) -> impl Iterator<Item = &Task> {
self.tasks.iter().filter(|t| t.agent_allow)
}
pub fn invocation(
&self,
task: &Task,
args: &BTreeMap<String, String>,
cwd: &Path,
task_file_dir: Option<&Path>,
) -> Result<Invocation, MissingArg> {
let mut effective = args.clone();
for a in &task.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(&task.script, &effective);
let (program, flag) = interpreter(&task.lang);
let mut env = self.env.clone();
env.extend(task.env.iter().cloned());
env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
let run_cwd = match task_file_dir {
_ if task.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 fn bind(
task: &Task,
positional: &[String],
) -> Result<BTreeMap<String, String>, MissingArg> {
let mut map = BTreeMap::new();
let mut i = 0;
for a in &task.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)
}
}
impl Invocation {
pub fn run(&self) -> std::io::Result<std::process::ExitStatus> {
std::process::Command::new(&self.program)
.args(&self.args)
.envs(self.env.iter().map(|(k, v)| (k, v)))
.current_dir(&self.cwd)
.status()
}
}
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<Task> = 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(Task {
name,
..Task::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(task: Option<Task>, file: &mut TaskFile) {
let Some(mut t) = task else {
return;
};
if t.script.is_empty() {
file.env.append(&mut t.env); return;
}
t.description = t.description.trim().to_string();
if file.tasks.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.tasks.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.tasks.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,
task: Option<&mut Task>,
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 task {
Some(t) => t.env.extend(pairs),
None => file_env.extend(pairs), }
return;
}
"opts" | "options" => {
if let Some(t) = task {
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) = task {
t.args = parse_args(value);
}
return;
}
"requires" | "req" => {
if let Some(t) = task {
t.requires = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
return;
}
"agent" => {
if let Some(t) = task {
t.agent_allow = value.eq_ignore_ascii_case("allow");
}
return;
}
_ => {}
}
}
if let Some(t) = task
&& !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()
}
#[test]
fn parses_named_tasks_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.tasks.len(), 2);
assert_eq!(tf.tasks[0].name, "build");
assert_eq!(tf.tasks[0].lang, "sh");
assert_eq!(tf.tasks[0].script.trim(), "cargo build");
assert_eq!(tf.tasks[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.tasks[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.tasks[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.tasks.len(), 1);
assert!(tf.tasks[0].script.contains("## not a task"));
assert!(tf.tasks[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 t = tf.task("greet").unwrap();
let inv = tf
.invocation(
t,
&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 t = tf.task("t").unwrap();
assert_eq!(
tf.invocation(t, &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 t = tf.task("t").unwrap();
assert!(!t.args[0].variadic && t.args[0].default.is_none());
assert_eq!(t.args[1].default.as_deref(), Some("fallback"));
assert!(t.args[2].variadic);
let inv = tf
.invocation(t, &args(&[("a", "x")]), Path::new("/here"), None)
.unwrap();
assert!(inv.args[1].contains("echo x fallback "));
let bound =
TaskFile::bind(t, &["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 t = tf.task("t").unwrap();
let inv = tf
.invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
.unwrap();
assert_eq!(inv.cwd, Path::new("/proj"));
let inv = tf
.invocation(t, &args(&[]), Path::new("/here"), None)
.unwrap();
assert_eq!(inv.cwd, Path::new("/here"));
let inv = tf
.invocation(t, &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 t = tf.task("t").unwrap();
assert!(t.inherits_cwd());
let inv = tf
.invocation(t, &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.tasks[0].opts, vec!["inherit-cwd", "bogus"]);
assert!(tf.tasks[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 t = tf.task("t").unwrap();
assert_eq!(t.script_arg_templates(), vec!["name"]);
let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
assert!(safe.task("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.tasks[0].script, "echo foo\necho bar\n");
assert!(!tf.tasks[0].script.contains('\r'));
}
#[test]
fn an_unterminated_fence_warns_but_keeps_the_task() {
let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.tasks.len(), 1);
assert_eq!(tf.tasks[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.tasks[0].script.contains("one"));
assert!(tf.tasks[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.tasks[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.tasks.len(), 2);
assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
assert!(tf.warnings.iter().any(|w| w.contains("json")));
}
#[test]
fn agent_tasks_filters_to_the_gated_ones() {
let tf =
parse("## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n");
let names: Vec<_> = tf.agent_tasks().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["open"]);
}
#[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.task("shared").unwrap().script.trim(),
"echo child"
);
assert!(files[1].1.task("base").is_some());
std::fs::remove_dir_all(&base).ok();
}
}