Skip to main content

mdtask_core/
lib.rs

1//! `mdtask-core` parses a markdown task file into a typed job tree and runs jobs
2//! from it. It is embeddable, execution-capable, and dependency-free.
3//!
4//! A task file is ordinary markdown (a `tasks.md`, a `maskfile.md`, or a project
5//! `README.md`): a heading is a job, the first fenced code block under it is the
6//! script, and `Key: value` lines in the body carry metadata. The format is its
7//! own grammar, a graceful superset that borrows xc's metadata vocabulary and
8//! mask's runtime shape (per-fence interpreter, positional args). It reads cleanly
9//! in those tools where the features overlap, but claims no compatibility.
10//!
11//! ```
12//! let tf = mdtask_core::parse("\
13//! ## greet\n\
14//! \n\
15//! Args: name\n\
16//! \n\
17//! ```sh\n\
18//! echo \"hello {{ name }}\"\n\
19//! ```\n");
20//! let job = tf.job("greet").unwrap();
21//! assert_eq!(job.args[0].name, "name");
22//! ```
23//!
24//! Parsing is pure. A consumer sees only jobs and their metadata: interpreter
25//! selection, argv building, working-directory resolution, and spawning are all
26//! internal. Three entry points run a job and its `Requires:` chain: [`run`]
27//! inherits stdio (streaming, for a CLI), [`run_captured`] captures the aggregated
28//! output (for an embedder), and [`run_agent`] adds the agent allow gate and the
29//! injection guard (for an MCP or agent surface). The parser is line-based (no
30//! CommonMark dependency), so a `#` or `Key:` inside a fenced block is never
31//! mistaken for structure.
32
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::{Path, PathBuf};
35
36/// A parsed task file: the jobs, any file-level environment hoisted to all of
37/// them (an `Env:` under a section heading applies to **every** job regardless of
38/// where in the document it appears; hoisting is not positional), and any parse
39/// warnings (an unterminated fence, a duplicate job, an unknown fence language).
40/// Parsing is infallible. A malformed file still yields what it can, so an
41/// embedder should surface `warnings()` rather than trust silence. The internal
42/// fields carry execution mechanics; a consumer reaches jobs through [`jobs`] and
43/// [`job`], and runs them through [`run`], [`run_captured`], or [`run_agent`].
44///
45/// [`jobs`]: TaskFile::jobs
46/// [`job`]: TaskFile::job
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct TaskFile {
49    pub(crate) env: Vec<(String, String)>,
50    pub(crate) jobs: Vec<Job>,
51    pub(crate) warnings: Vec<String>,
52}
53
54/// One job: a named script with its metadata. The script, its interpreter
55/// language, its `Opts:` flags, and its extra environment are internal mechanics;
56/// a consumer deals in the name, description, declared args, dependencies, and the
57/// agent gate, and runs the job through [`run`], [`run_captured`], or
58/// [`run_agent`].
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Job {
61    /// The heading text (the job name).
62    pub name: String,
63    /// Prose in the job body that is not a recognized `Key: value` line.
64    pub description: String,
65    /// `Args:` declares positional arguments in just's syntax. A bare `name` is
66    /// required, `name='default'` is optional, and a trailing `*name` is variadic
67    /// (it collects the rest, space-joined). Each one is substituted as
68    /// `{{ name }}` in the script and also exported as `$name`. Note that
69    /// **`{{ name }}` is raw text substitution**, spliced in before the interpreter
70    /// parses the script, so `{{ name }}` is NOT injection-safe for untrusted values
71    /// in any language. The safe form is to read the value from the environment,
72    /// never to template it: `"$name"` in a shell, `os.environ["name"]` in Python,
73    /// `process.env.name` in Node, and so on. Reserve `{{ }}` for developer-authored
74    /// templates. An agent-run job that raw-templates an arg is refused by
75    /// [`run_agent`].
76    pub args: Vec<Arg>,
77    /// `Requires:` names the jobs this one depends on. The `run*` entry points
78    /// resolve the transitive order (deps first, cycle and typo detected) and run
79    /// each in turn, stopping on the first non-success step.
80    pub requires: Vec<String>,
81    /// `Agent: allow` opts a job in to being listed and run by an MCP or agent
82    /// surface. The flag alone enforces nothing: [`run_agent`] is the gate that
83    /// checks it (and [`agent_jobs`] the listing that filters on it), so a plain
84    /// [`run`] or [`run_captured`] ignores it. It stays public as advisory data an
85    /// embedder can read.
86    pub agent_allow: bool,
87    /// The fenced block's info-string language (`sh`, `zsh`, `python`, ...); empty
88    /// means an unlabeled fence (treated as `sh`).
89    pub(crate) lang: String,
90    /// The script (the fenced block's contents), verbatim.
91    pub(crate) script: String,
92    /// `Opts:` carries per-job boolean flags, space-separated. The only flag today
93    /// is `inherit-cwd`: run the job in the directory mdtask was invoked from,
94    /// rather than the default (the directory of the task file that defines it).
95    pub(crate) opts: Vec<String>,
96    /// `Env:` adds extra environment for this job.
97    pub(crate) env: Vec<(String, String)>,
98}
99
100/// One declared positional argument.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Arg {
103    pub name: String,
104    /// `*name`: collects all remaining positionals, space-joined.
105    pub variadic: bool,
106    /// `name='default'`: optional, with this value when not supplied.
107    pub default: Option<String>,
108}
109
110/// A runnable command built from a job: what to exec, with what environment, in
111/// which directory. Internal mechanics: the `run*` functions build it and spawn
112/// it, and no consumer ever sees the program, argv, or interpreter.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub(crate) struct Invocation {
115    pub program: String,
116    pub args: Vec<String>,
117    pub env: Vec<(String, String)>,
118    pub cwd: PathBuf,
119}
120
121/// A declared argument had no value supplied when binding a job's args.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct MissingArg(pub String);
124
125impl std::fmt::Display for MissingArg {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(f, "missing value for argument `{}`", self.0)
128    }
129}
130impl std::error::Error for MissingArg {}
131
132/// A `Requires:` dependency chain could not be resolved.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum DepError {
135    /// A `Requires:` named a job that does not exist.
136    Missing { task: String, required_by: String },
137    /// A dependency cycle, reported at the job where the back edge closes.
138    Cycle(String),
139}
140
141impl std::fmt::Display for DepError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            DepError::Missing { task, required_by } => {
145                write!(f, "task {required_by:?} requires unknown task {task:?}")
146            }
147            DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
148        }
149    }
150}
151impl std::error::Error for DepError {}
152
153/// Why a `run*` call could not complete. It reports the failure to resolve or
154/// dispatch a job; a job that runs to a non-zero exit is not an error here (the
155/// exit status rides back in the `Ok`). Only `Debug` is derived, because `Io`
156/// wraps a [`std::io::Error`], which is neither `Clone` nor `PartialEq`.
157#[derive(Debug)]
158pub enum RunError {
159    /// No job by that name across the resolved files (from [`run`]/[`run_captured`]).
160    NotFound(String),
161    /// The nearest definition of the named job is not `Agent: allow`, so an agent
162    /// surface may not run it (from [`run_agent`] only). A nearer non-allowed
163    /// definition shadowing a farther allowed one lands here too: fail closed.
164    NotAllowed(String),
165    /// The agent target raw-templates a declared arg into its script via
166    /// `{{ arg }}` (from [`run_agent`] only). `args` lists the offending names.
167    /// The job must read the value from the environment instead before an agent
168    /// may run it.
169    Injects { task: String, args: Vec<String> },
170    /// A required positional argument had no value.
171    MissingArg(MissingArg),
172    /// The `Requires:` chain could not be resolved (a typo or a cycle).
173    Dependency(DepError),
174    /// Spawning a step failed (the interpreter is missing, the directory is gone).
175    Io(std::io::Error),
176}
177
178impl std::fmt::Display for RunError {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            RunError::NotFound(name) => write!(f, "no task named {name:?}"),
182            RunError::NotAllowed(name) => write!(
183                f,
184                "task {name:?} is not available to agents (it lacks `Agent: allow`)"
185            ),
186            RunError::Injects { task, args } => write!(
187                f,
188                "task {task:?} interpolates argument(s) [{}] into its script via {{{{ }}}} \
189                 (raw substitution, an injection risk with agent-supplied values); it must \
190                 read them from the environment instead (\"$arg\", os.environ[\"arg\"], ...) \
191                 before an agent can run it. Refused.",
192                args.join(", ")
193            ),
194            RunError::MissingArg(e) => e.fmt(f),
195            RunError::Dependency(e) => e.fmt(f),
196            RunError::Io(e) => e.fmt(f),
197        }
198    }
199}
200impl std::error::Error for RunError {
201    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
202        match self {
203            RunError::MissingArg(e) => Some(e),
204            RunError::Dependency(e) => Some(e),
205            RunError::Io(e) => Some(e),
206            _ => None,
207        }
208    }
209}
210
211/// Resolve the run order for `target` and its transitive `Requires:`: each
212/// dependency comes before the job that needs it, `target` comes last, and every
213/// job appears at most once (a diamond runs its shared dependency once). The
214/// caller supplies `requires_of`, which returns a job's declared dependency names,
215/// or `None` if the name is not a known job (so a typo in `Requires:` is a hard
216/// error, not a silent skip). Pure: no filesystem or process access.
217///
218/// The traversal is iterative (an explicit work stack, not native recursion), so a
219/// pathologically deep chain cannot overflow the call stack and abort the process.
220pub(crate) fn dependency_order(
221    target: &str,
222    requires_of: impl Fn(&str) -> Option<Vec<String>>,
223) -> Result<Vec<String>, DepError> {
224    // Each frame is a job whose dependencies we are still walking (`next` is the
225    // index of the next dependency to descend into). A post-order DFS: a frame
226    // moves to `order` only once all its dependencies are done.
227    struct Frame {
228        name: String,
229        deps: Vec<String>,
230        next: usize,
231    }
232
233    let mut order = Vec::new();
234    let mut done = BTreeSet::new();
235    let mut on_stack = BTreeSet::new();
236    let mut stack: Vec<Frame> = Vec::new();
237
238    let deps = requires_of(target).ok_or_else(|| DepError::Missing {
239        task: target.to_string(),
240        required_by: target.to_string(),
241    })?;
242    on_stack.insert(target.to_string());
243    stack.push(Frame {
244        name: target.to_string(),
245        deps,
246        next: 0,
247    });
248
249    loop {
250        // Decide the next move using a short-lived borrow of the top frame, so the
251        // stack is free to push/pop afterwards.
252        let descend = {
253            let Some(frame) = stack.last_mut() else { break };
254            if frame.next < frame.deps.len() {
255                let dep = frame.deps[frame.next].clone();
256                frame.next += 1;
257                Some(dep)
258            } else {
259                None
260            }
261        };
262        match descend {
263            Some(dep) => {
264                if done.contains(&dep) {
265                    continue; // already resolved via another path (a diamond)
266                }
267                if on_stack.contains(&dep) {
268                    return Err(DepError::Cycle(dep));
269                }
270                let required_by = stack.last().expect("a top frame exists").name.clone();
271                let deps = requires_of(&dep).ok_or(DepError::Missing {
272                    task: dep.clone(),
273                    required_by,
274                })?;
275                on_stack.insert(dep.clone());
276                stack.push(Frame {
277                    name: dep,
278                    deps,
279                    next: 0,
280                });
281            }
282            None => {
283                let frame = stack.pop().expect("a top frame exists");
284                on_stack.remove(&frame.name);
285                done.insert(frame.name.clone());
286                order.push(frame.name);
287            }
288        }
289    }
290    Ok(order)
291}
292
293/// The `Opts:` flags mdtask recognizes. An `Opts:` value outside this set is
294/// recorded as a warning and otherwise ignored, so a file written for a newer
295/// mdtask does not hard-fail on an older one.
296pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd"];
297
298impl Job {
299    /// Whether this job opted into `Opts: inherit-cwd`: run it in the invocation
300    /// directory rather than the default (the task file's own directory).
301    pub(crate) fn inherits_cwd(&self) -> bool {
302        self.opts.iter().any(|o| o == "inherit-cwd")
303    }
304
305    /// The declared argument names this job interpolates into its **script** via
306    /// `{{ arg }}` (raw text substitution, spliced in before the interpreter parses
307    /// the script). Because it is not quoted, each of these is an injection point
308    /// for an untrusted argument value, in any language, so [`run_agent`] refuses a
309    /// job that has any. Empty for a job that reads its args from the environment,
310    /// the safe form.
311    pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
312        let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
313        let mut found: Vec<&str> = Vec::new();
314        let mut rest = self.script.as_str();
315        while let Some(open) = rest.find("{{") {
316            let after = &rest[open + 2..];
317            let Some(close) = after.find("}}") else { break };
318            let tok = after[..close].trim();
319            if declared.contains(tok) && !found.contains(&tok) {
320                found.push(tok);
321            }
322            rest = &after[close + 2..];
323        }
324        found
325    }
326}
327
328impl TaskFile {
329    /// The jobs in this file, in document order.
330    pub fn jobs(&self) -> &[Job] {
331        &self.jobs
332    }
333
334    /// Find a job by name. The match is exact and case-sensitive, against the
335    /// heading text as written. The first definition wins if a name is duplicated
336    /// (a warning is recorded).
337    pub fn job(&self, name: &str) -> Option<&Job> {
338        self.jobs.iter().find(|j| j.name == name)
339    }
340
341    /// Any parse warnings (an unterminated fence, a duplicate job, an unknown fence
342    /// language). Parsing is infallible, so surface these rather than trust silence.
343    pub fn warnings(&self) -> &[String] {
344        &self.warnings
345    }
346
347    /// Build the invocation for `job`, given `args` mapping each name to a value.
348    /// It substitutes `{{ arg }}` in the script, exports the args and env, and
349    /// resolves the working directory: by default the job runs in `job_file_dir`
350    /// (the directory of the file that defines it; `None` or empty falls back to
351    /// `cwd`), while `Opts: inherit-cwd` runs it in `cwd`. Missing optional and
352    /// variadic args are filled from their defaults; only a missing required arg is
353    /// an error.
354    pub(crate) fn invocation(
355        &self,
356        job: &Job,
357        args: &BTreeMap<String, String>,
358        cwd: &Path,
359        job_file_dir: Option<&Path>,
360    ) -> Result<Invocation, MissingArg> {
361        // Fill defaults for any declared arg the caller did not supply.
362        let mut effective = args.clone();
363        for a in &job.args {
364            if !effective.contains_key(&a.name) {
365                if a.variadic {
366                    effective.insert(a.name.clone(), String::new());
367                } else if let Some(d) = &a.default {
368                    effective.insert(a.name.clone(), d.clone());
369                } else {
370                    return Err(MissingArg(a.name.clone()));
371                }
372            }
373        }
374
375        let script = substitute(&job.script, &effective);
376        let (program, flag) = interpreter(&job.lang);
377
378        // Env precedence: hoisted, then job, then args. Args win, being the most
379        // specific, so `$name` resolves to the passed value.
380        let mut env = self.env.clone();
381        env.extend(job.env.iter().cloned());
382        env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
383
384        // The job's own directory is the default anchor; `inherit-cwd` opts into
385        // the invocation directory. An absent or empty job_file_dir (a bare
386        // filename with no directory part) falls back to cwd, since running in an
387        // empty path would fail.
388        let run_cwd = match job_file_dir {
389            _ if job.inherits_cwd() => cwd.to_path_buf(),
390            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
391            _ => cwd.to_path_buf(),
392        };
393
394        Ok(Invocation {
395            program: program.to_string(),
396            args: vec![flag.to_string(), script],
397            env,
398            cwd: run_cwd,
399        })
400    }
401
402    /// Bind positional argument values to a job's declared `Args:`, applying
403    /// defaults and collecting a trailing `*variadic` from the rest. This feeds
404    /// [`TaskFile::invocation`] and errors on a missing required arg.
405    pub(crate) fn bind(
406        job: &Job,
407        positional: &[String],
408    ) -> Result<BTreeMap<String, String>, MissingArg> {
409        let mut map = BTreeMap::new();
410        let mut i = 0;
411        for a in &job.args {
412            if a.variadic {
413                map.insert(
414                    a.name.clone(),
415                    positional[i.min(positional.len())..].join(" "),
416                );
417                i = positional.len();
418            } else if i < positional.len() {
419                map.insert(a.name.clone(), positional[i].clone());
420                i += 1;
421            } else if let Some(d) = &a.default {
422                map.insert(a.name.clone(), d.clone());
423            } else {
424                return Err(MissingArg(a.name.clone()));
425            }
426        }
427        Ok(map)
428    }
429}
430
431/// The jobs a set of layered files exposes to an agent or MCP surface: one per
432/// name using the **nearest** definition (so a nearer non-allowed job shadows a
433/// farther allowed one, matching run semantics), keeping only those whose nearest
434/// definition carries `Agent: allow`. This is the enforcement point for listing;
435/// [`run_agent`] is the enforcement point for running. A surface exposing jobs to
436/// an agent should list only these.
437pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
438    let mut seen = BTreeSet::new();
439    let mut out = Vec::new();
440    for (_, tf) in files {
441        for job in &tf.jobs {
442            if seen.insert(job.name.clone()) && job.agent_allow {
443                out.push(job);
444            }
445        }
446    }
447    out
448}
449
450/// The nearest definition of `name` across the layered files, plus the file that
451/// owns it and that file's directory (`None` when the path has no directory part).
452/// A nearer definition wins (the fallback layering), so this resolves both a
453/// target and each `Requires:` dependency the same way the CLI does.
454fn trusted_lookup<'a>(
455    files: &'a [(PathBuf, TaskFile)],
456    name: &str,
457) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
458    files
459        .iter()
460        .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
461}
462
463/// Resolve `target` and its `Requires:` chain across the layered files (deps
464/// first, target last, each once). A name that resolves nowhere is `NotFound`.
465fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<String>, RunError> {
466    if trusted_lookup(files, target).is_none() {
467        return Err(RunError::NotFound(target.to_string()));
468    }
469    dependency_order(target, |n| {
470        trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
471    })
472    .map_err(RunError::Dependency)
473}
474
475/// Build the ordered, ready-to-spawn invocations for `order`. `lookup` resolves
476/// each step to its file, job, and directory; only `target` receives `args`,
477/// while every dependency runs argless (its own defaults fill in).
478fn plan_invocations<'a>(
479    order: &[String],
480    target: &str,
481    args: &[String],
482    cwd: &Path,
483    lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
484) -> Result<Vec<Invocation>, RunError> {
485    let mut plan = Vec::with_capacity(order.len());
486    for step in order {
487        let (tf, job, dir) = lookup(step).expect("a resolved name still resolves");
488        let step_args: &[String] = if step == target { args } else { &[] };
489        let values = TaskFile::bind(job, step_args).map_err(RunError::MissingArg)?;
490        let inv = tf
491            .invocation(job, &values, cwd, dir)
492            .map_err(RunError::MissingArg)?;
493        plan.push(inv);
494    }
495    Ok(plan)
496}
497
498/// Run a planned chain with captured output, aggregating stdout and stderr across
499/// steps and stopping on the first non-success step. The returned status is that
500/// step's (or the last step's on full success). The plan always holds the target,
501/// so it is never empty.
502fn run_plan_captured(plan: &[Invocation]) -> Result<std::process::Output, RunError> {
503    let mut stdout = Vec::new();
504    let mut stderr = Vec::new();
505    let mut status = None;
506    for inv in plan {
507        let out = inv.run_captured().map_err(RunError::Io)?;
508        stdout.extend_from_slice(&out.stdout);
509        stderr.extend_from_slice(&out.stderr);
510        let failed = !out.status.success();
511        status = Some(out.status);
512        if failed {
513            break;
514        }
515    }
516    Ok(std::process::Output {
517        status: status.expect("the plan always contains the target"),
518        stdout,
519        stderr,
520    })
521}
522
523/// Run `name` and its `Requires:` chain across the layered `files`, inheriting the
524/// parent's stdio so output streams straight through (the CLI path: a job is an
525/// interactive command, not a captured subprocess). Dependencies run first, each
526/// once, and each with its own defaults; only `name` receives `args`. Returns the
527/// first failing step's exit status, or the last step's on full success. This is
528/// **trusted**: it applies no agent gate, so a caller must not hand it a name from
529/// an untrusted source.
530pub fn run(
531    files: &[(PathBuf, TaskFile)],
532    name: &str,
533    args: &[String],
534    cwd: &Path,
535) -> Result<std::process::ExitStatus, RunError> {
536    let order = trusted_order(files, name)?;
537    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
538    let mut last = None;
539    for inv in &plan {
540        let status = inv.run_inherit().map_err(RunError::Io)?;
541        if !status.success() {
542            return Ok(status);
543        }
544        last = Some(status);
545    }
546    Ok(last.expect("the plan always contains the target"))
547}
548
549/// Like [`run`], but captured: run `name` and its `Requires:` chain across the
550/// layered `files` with output aggregated across steps into a single
551/// [`std::process::Output`] (its status is the failing step's, or the last on
552/// success). For an embedder (a TUI, an editor) that wants the text rather than a
553/// stream. Also **trusted**: no agent gate.
554pub fn run_captured(
555    files: &[(PathBuf, TaskFile)],
556    name: &str,
557    args: &[String],
558    cwd: &Path,
559) -> Result<std::process::Output, RunError> {
560    let order = trusted_order(files, name)?;
561    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
562    run_plan_captured(&plan)
563}
564
565/// The agent gate: run `name` for an MCP or agent surface, captured, failing
566/// closed. Enforced, in order:
567///
568/// - The **nearest** definition of `name` across the layered files must carry
569///   `Agent: allow`, else [`RunError::NotAllowed`]. A nearer non-allowed
570///   definition shadows a farther allowed one (still `NotAllowed`), and a name
571///   that resolves nowhere is `NotAllowed` too: the agent never learns whether a
572///   hidden job exists.
573/// - The target must not raw-template a declared arg into its script via
574///   `{{ arg }}` (the agent controls the value), else [`RunError::Injects`]. The
575///   author must read the value from the environment instead.
576/// - The `Requires:` chain is resolved **within the target's own file**, not by
577///   the cross-file nearest-wins scan [`run`] uses. The author who wrote
578///   `Agent: allow` vouched for their file's jobs; a nearer, untrusted task file in
579///   the invocation directory must not be able to shadow a dependency and run
580///   attacker-controlled code through an allowed entry point. A dependency is never
581///   independently callable and never listed.
582///
583/// Only the target receives `args`; dependencies run argless with author-controlled
584/// defaults, so the target is the sole injection surface.
585pub fn run_agent(
586    files: &[(PathBuf, TaskFile)],
587    name: &str,
588    args: &[String],
589    cwd: &Path,
590) -> Result<std::process::Output, RunError> {
591    // The nearest definition wins. If it is not allowed (or the name resolves
592    // nowhere), refuse: fail closed.
593    let mut target: Option<(&Path, &TaskFile, &Job)> = None;
594    for (p, tf) in files {
595        if let Some(job) = tf.job(name) {
596            if job.agent_allow {
597                target = Some((p.as_path(), tf, job));
598            }
599            break; // the nearest definition decides, allowed or not
600        }
601    }
602    let Some((target_path, target_tf, target_job)) = target else {
603        return Err(RunError::NotAllowed(name.to_string()));
604    };
605
606    // Refuse a target that raw-templates an untrusted arg into its script.
607    let templated = target_job.script_arg_templates();
608    if !templated.is_empty() {
609        return Err(RunError::Injects {
610            task: name.to_string(),
611            args: templated.iter().map(|s| s.to_string()).collect(),
612        });
613    }
614
615    // Resolve the Requires: chain WITHIN the target's own file (the security
616    // boundary), not the cross-file scan.
617    let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
618        .map_err(RunError::Dependency)?;
619    let dir = target_path.parent();
620    let plan = plan_invocations(&order, name, args, cwd, |n| {
621        target_tf.job(n).map(|j| (target_tf, j, dir))
622    })?;
623    run_plan_captured(&plan)
624}
625
626impl Invocation {
627    /// The `std::process::Command` for this invocation (program, argv, env, cwd).
628    fn command(&self) -> std::process::Command {
629        let mut cmd = std::process::Command::new(&self.program);
630        cmd.args(&self.args)
631            .envs(self.env.iter().map(|(k, v)| (k, v)))
632            .current_dir(&self.cwd);
633        cmd
634    }
635
636    /// Run inheriting the parent's stdio so output streams straight through.
637    fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
638        self.command().status()
639    }
640
641    /// Run capturing stdout and stderr.
642    fn run_captured(&self) -> std::io::Result<std::process::Output> {
643        self.command().output()
644    }
645}
646
647/// Map a fence language to `(program, code-flag)`. Unlabeled or unknown falls
648/// back to `sh -c`, so a plain ` ``` ` block runs as a shell script.
649fn interpreter(lang: &str) -> (&'static str, &'static str) {
650    match lang.trim().to_ascii_lowercase().as_str() {
651        "" | "sh" | "shell" => ("sh", "-c"),
652        "bash" => ("bash", "-c"),
653        "zsh" => ("zsh", "-c"),
654        "fish" => ("fish", "-c"),
655        "python" | "py" | "python3" => ("python3", "-c"),
656        "ruby" => ("ruby", "-e"),
657        "node" | "js" | "javascript" => ("node", "-e"),
658        _ => ("sh", "-c"),
659    }
660}
661
662/// Replace `{{ name }}` tokens (any inner whitespace) with `args[name]`. A token
663/// whose name is not in `args` is left as written, so a literal `{{x}}` that is
664/// not an argument survives.
665fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
666    let mut out = String::with_capacity(src.len());
667    let mut rest = src;
668    while let Some(open) = rest.find("{{") {
669        out.push_str(&rest[..open]);
670        let after = &rest[open + 2..];
671        if let Some(close) = after.find("}}") {
672            let name = after[..close].trim();
673            match args.get(name) {
674                Some(v) => out.push_str(v),
675                None => {
676                    // Not an argument: keep the token verbatim.
677                    out.push_str("{{");
678                    out.push_str(&after[..close]);
679                    out.push_str("}}");
680                }
681            }
682            rest = &after[close + 2..];
683        } else {
684            out.push_str("{{");
685            rest = after;
686        }
687    }
688    out.push_str(rest);
689    out
690}
691
692/// Parse a markdown task file. It is line-based (no CommonMark dependency): a
693/// heading starts a job, the first fenced block under it is the script, and
694/// `Key: value` lines set metadata. Parsing is infallible; problems are reported
695/// in [`TaskFile::warnings`] rather than dropped to silence. CRLF endings are
696/// normalized.
697pub fn parse(src: &str) -> TaskFile {
698    let mut file = TaskFile::default();
699    let mut cur: Option<Job> = None;
700    let mut in_fence = false;
701    let mut fence_marker = "";
702    let mut have_script = false; // first fence per job only
703    let mut script = String::new();
704
705    for raw in src.split('\n') {
706        let line = raw.strip_suffix('\r').unwrap_or(raw); // normalize CRLF
707        if in_fence {
708            // A fence is closed only by a BARE marker line (CommonMark): ` ``` `
709            // with an info string opens, it does not close, so a stray fence-open
710            // cannot accidentally terminate an unterminated block early.
711            if is_closing_fence(line, fence_marker) {
712                in_fence = false;
713                if let Some(t) = cur.as_mut()
714                    && !have_script
715                {
716                    t.script = std::mem::take(&mut script);
717                    have_script = true;
718                }
719                script.clear();
720            } else if cur.is_some() && !have_script {
721                script.push_str(line);
722                script.push('\n');
723            }
724            continue;
725        }
726        if let Some(marker) = opening_fence(line) {
727            in_fence = true;
728            fence_marker = marker;
729            if let Some(t) = cur.as_mut()
730                && !have_script
731            {
732                t.lang = info_string(line, marker);
733            }
734            script.clear();
735            continue;
736        }
737        if let Some(name) = heading(line) {
738            finalize(cur.take(), &mut file);
739            cur = Some(Job {
740                name,
741                ..Job::default()
742            });
743            have_script = false;
744            continue;
745        }
746        apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
747    }
748    // An unterminated fence at EOF: still capture the script so the job is not
749    // lost, but warn, since a forgotten closing fence is a common authoring slip.
750    if in_fence {
751        if let Some(t) = cur.as_mut()
752            && !have_script
753        {
754            t.script = std::mem::take(&mut script);
755        }
756        let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
757        file.warnings
758            .push(format!("unterminated code fence in task {name:?}"));
759    }
760    finalize(cur.take(), &mut file);
761    file
762}
763
764/// Finalize a heading into the file. A heading with a script is a job; one without
765/// (a `# Tasks` section) is not, but its `Env:` hoists to all jobs. Records
766/// warnings for a duplicate name or an unknown fence language.
767fn finalize(job: Option<Job>, file: &mut TaskFile) {
768    let Some(mut t) = job else {
769        return;
770    };
771    if t.script.is_empty() {
772        file.env.append(&mut t.env); // section heading, so hoist its env
773        return;
774    }
775    t.description = t.description.trim().to_string();
776    if file.jobs.iter().any(|x| x.name == t.name) {
777        file.warnings.push(format!(
778            "duplicate task {:?}; the first defined wins",
779            t.name
780        ));
781    }
782    if !is_known_lang(&t.lang) {
783        file.warnings.push(format!(
784            "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
785            t.name, t.lang
786        ));
787    }
788    file.jobs.push(t);
789}
790
791/// Whether a fence language maps to an interpreter (unlabeled counts as `sh`).
792fn is_known_lang(lang: &str) -> bool {
793    matches!(
794        lang.trim().to_ascii_lowercase().as_str(),
795        "" | "sh"
796            | "shell"
797            | "bash"
798            | "zsh"
799            | "fish"
800            | "python"
801            | "py"
802            | "python3"
803            | "ruby"
804            | "node"
805            | "js"
806            | "javascript"
807    )
808}
809
810/// The opening fence marker if `line` starts one, else `None`.
811fn opening_fence(line: &str) -> Option<&'static str> {
812    let t = line.trim_start();
813    if t.starts_with("```") {
814        Some("```")
815    } else if t.starts_with("~~~") {
816        Some("~~~")
817    } else {
818        None
819    }
820}
821
822/// Whether `line` is a bare closing fence for `marker`: only the fence char, no
823/// info string, per CommonMark's closing rule.
824fn is_closing_fence(line: &str, marker: &str) -> bool {
825    let ch = marker.as_bytes()[0];
826    let t = line.trim();
827    t.len() >= 3 && t.bytes().all(|b| b == ch)
828}
829
830/// Search for task files from `start` up to the filesystem root, **nearest
831/// first**. In each ancestor directory the first of `tasks.md`, `maskfile.md`,
832/// `README.md` that parses to at least one job is taken. The CLI layers these
833/// child-first, so a nearer file shadows a farther one by job name (like just's
834/// `set fallback`, letting a project inherit a baseline of jobs from a parent).
835/// Embedders with their own project root can ignore this and call [`parse`].
836pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
837    let mut found = Vec::new();
838    for dir in start.ancestors() {
839        for name in ["tasks.md", "maskfile.md", "README.md"] {
840            let path = dir.join(name);
841            if let Ok(src) = std::fs::read_to_string(&path) {
842                let tf = parse(&src);
843                if !tf.jobs.is_empty() {
844                    found.push((path, tf));
845                    break; // one file per directory
846                }
847            }
848        }
849    }
850    found
851}
852
853/// The info-string language after the opening fence marker.
854fn info_string(line: &str, marker: &str) -> String {
855    line.trim_start()
856        .strip_prefix(marker)
857        .unwrap_or("")
858        .split_whitespace()
859        .next()
860        .unwrap_or("")
861        .to_string()
862}
863
864/// The heading text if `line` is an ATX heading (`#`..`######`), else `None`.
865fn heading(line: &str) -> Option<String> {
866    let t = line.trim_start();
867    if !t.starts_with('#') {
868        return None;
869    }
870    let after = t.trim_start_matches('#');
871    // Must have a space after the `#` run (a real ATX heading), and not be all #.
872    if after == t || !after.starts_with(' ') {
873        return None;
874    }
875    Some(after.trim().to_string())
876}
877
878/// Apply a body line: a recognized `Key: value` sets metadata (case-insensitive
879/// key, xc vocabulary); anything else is description. `Env:` before the first job
880/// accumulates into the hoisted `file_env`.
881fn apply_line(
882    line: &str,
883    job: Option<&mut Job>,
884    file_env: &mut Vec<(String, String)>,
885    warnings: &mut Vec<String>,
886) {
887    if let Some((key, value)) = split_key(line) {
888        let value = value.trim();
889        match key.as_str() {
890            "env" | "environment" => {
891                let pairs = parse_env(value);
892                match job {
893                    Some(t) => t.env.extend(pairs),
894                    None => file_env.extend(pairs), // hoisted
895                }
896                return;
897            }
898            "opts" | "options" => {
899                if let Some(t) = job {
900                    t.opts = value.split_whitespace().map(str::to_string).collect();
901                    for flag in &t.opts {
902                        if !KNOWN_OPTS.contains(&flag.as_str()) {
903                            warnings.push(format!(
904                                "unknown option {flag:?} in `Opts:` (known: {})",
905                                KNOWN_OPTS.join(", ")
906                            ));
907                        }
908                    }
909                }
910                return;
911            }
912            "args" | "arguments" => {
913                if let Some(t) = job {
914                    t.args = parse_args(value);
915                }
916                return;
917            }
918            "requires" | "req" => {
919                if let Some(t) = job {
920                    t.requires = value
921                        .split(',')
922                        .map(|s| s.trim().to_string())
923                        .filter(|s| !s.is_empty())
924                        .collect();
925                }
926                return;
927            }
928            "agent" => {
929                if let Some(t) = job {
930                    t.agent_allow = value.eq_ignore_ascii_case("allow");
931                }
932                return;
933            }
934            _ => {}
935        }
936    }
937    // Description (only within a job; drop stray prose outside one).
938    if let Some(t) = job
939        && !line.trim().is_empty()
940    {
941        t.description.push_str(line.trim());
942        t.description.push('\n');
943    }
944}
945
946/// Split `Key: value`, returning the lowercased key if the line looks like one
947/// (a single-word key before the first colon). Leading indentation is allowed, so
948/// an `Env:` indented under a list still counts. This is safe because only *known*
949/// keys act (see `apply_line`), so ordinary prose with a colon stays description.
950fn split_key(line: &str) -> Option<(String, &str)> {
951    let colon = line.find(':')?;
952    let key = line[..colon].trim();
953    if key.is_empty() || key.contains(char::is_whitespace) {
954        return None;
955    }
956    Some((key.to_ascii_lowercase(), &line[colon + 1..]))
957}
958
959/// Parse an `Env:` value: comma-separated `KEY=VALUE` pairs.
960fn parse_env(value: &str) -> Vec<(String, String)> {
961    value
962        .split(',')
963        .filter_map(|p| {
964            let (k, v) = p.split_once('=')?;
965            let k = k.trim();
966            if k.is_empty() {
967                return None;
968            }
969            Some((k.to_string(), v.trim().to_string()))
970        })
971        .collect()
972}
973
974/// Parse an `Args:` value into declared [`Arg`]s (just's syntax): `name` is
975/// required, `*name` collects the rest (variadic), `name='default'` (or
976/// `name="default"`) is optional. Tokens are whitespace-separated, but a quoted
977/// default may itself contain spaces (`msg='hello world'`).
978fn parse_args(value: &str) -> Vec<Arg> {
979    tokenize_args(value)
980        .into_iter()
981        .filter_map(|tok| {
982            let (name, default) = match tok.split_once('=') {
983                Some((n, d)) => (n, Some(unquote(d).to_string())),
984                None => (tok.as_str(), None),
985            };
986            let (name, variadic) = match name.strip_prefix('*') {
987                Some(rest) => (rest, true),
988                None => (name, false),
989            };
990            let name = name.trim();
991            if name.is_empty() {
992                return None;
993            }
994            Some(Arg {
995                name: name.to_string(),
996                variadic,
997                default,
998            })
999        })
1000        .collect()
1001}
1002
1003/// Split an `Args:` value on whitespace, but keep a single- or double-quoted run
1004/// (a default value) together so `msg='a b'` is one token.
1005fn tokenize_args(value: &str) -> Vec<String> {
1006    let mut out = Vec::new();
1007    let mut cur = String::new();
1008    let mut quote: Option<char> = None;
1009    for c in value.chars() {
1010        match quote {
1011            Some(q) => {
1012                cur.push(c);
1013                if c == q {
1014                    quote = None;
1015                }
1016            }
1017            None if c == '\'' || c == '"' => {
1018                cur.push(c);
1019                quote = Some(c);
1020            }
1021            None if c.is_whitespace() => {
1022                if !cur.is_empty() {
1023                    out.push(std::mem::take(&mut cur));
1024                }
1025            }
1026            None => cur.push(c),
1027        }
1028    }
1029    if !cur.is_empty() {
1030        out.push(cur);
1031    }
1032    out
1033}
1034
1035/// Strip one matching pair of surrounding single or double quotes, if present.
1036fn unquote(s: &str) -> &str {
1037    let s = s.trim();
1038    let b = s.as_bytes();
1039    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
1040        &s[1..s.len() - 1]
1041    } else {
1042        s
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1051        pairs
1052            .iter()
1053            .map(|(k, v)| (k.to_string(), v.to_string()))
1054            .collect()
1055    }
1056
1057    fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
1058        pairs
1059            .iter()
1060            .map(|(path, src)| (PathBuf::from(path), parse(src)))
1061            .collect()
1062    }
1063
1064    #[test]
1065    fn parses_named_jobs_with_interpreter() {
1066        let tf =
1067            parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
1068        assert_eq!(tf.jobs.len(), 2);
1069        assert_eq!(tf.jobs[0].name, "build");
1070        assert_eq!(tf.jobs[0].lang, "sh");
1071        assert_eq!(tf.jobs[0].script.trim(), "cargo build");
1072        assert_eq!(tf.jobs[1].lang, "zsh");
1073    }
1074
1075    #[test]
1076    fn metadata_keys_are_case_insensitive() {
1077        let tf = parse(
1078            "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
1079        );
1080        let t = &tf.jobs[0];
1081        assert_eq!(t.opts, vec!["inherit-cwd"]);
1082        assert!(t.inherits_cwd());
1083        assert_eq!(
1084            t.env,
1085            vec![
1086                ("REGION".into(), "us".into()),
1087                ("TIER".into(), "prod".into())
1088            ]
1089        );
1090        assert_eq!(
1091            t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
1092            ["target"]
1093        );
1094        assert_eq!(t.requires, vec!["build", "test"]);
1095        assert!(t.agent_allow);
1096    }
1097
1098    #[test]
1099    fn agent_gate_is_off_by_default() {
1100        let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
1101        assert!(!tf.jobs[0].agent_allow);
1102    }
1103
1104    #[test]
1105    fn top_level_env_is_hoisted() {
1106        let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
1107        assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
1108    }
1109
1110    #[test]
1111    fn fence_content_is_not_parsed_as_structure() {
1112        // A `## heading` and a `Key:` line inside a fence stay in the script.
1113        let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
1114        assert_eq!(tf.jobs.len(), 1);
1115        assert!(tf.jobs[0].script.contains("## not a task"));
1116        assert!(tf.jobs[0].env.is_empty());
1117    }
1118
1119    #[test]
1120    fn substitutes_args_and_leaves_unknown_tokens() {
1121        let out = substitute(
1122            "hello {{ name }} and {{ other }}",
1123            &args(&[("name", "world")]),
1124        );
1125        assert_eq!(out, "hello world and {{ other }}");
1126    }
1127
1128    #[test]
1129    fn invocation_substitutes_sets_env_and_picks_interpreter() {
1130        let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
1131        let j = tf.job("greet").unwrap();
1132        let inv = tf
1133            .invocation(
1134                j,
1135                &args(&[("name", "sam")]),
1136                Path::new("/here"),
1137                Some(Path::new("/file")),
1138            )
1139            .unwrap();
1140        assert_eq!(inv.program, "zsh");
1141        assert_eq!(inv.args[0], "-c");
1142        assert!(inv.args[1].contains("hi sam"));
1143        assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
1144        // By default it runs in the task file's directory, not where invoked.
1145        assert_eq!(inv.cwd, Path::new("/file"));
1146    }
1147
1148    #[test]
1149    fn a_missing_required_arg_is_an_error() {
1150        let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
1151        let j = tf.job("t").unwrap();
1152        assert_eq!(
1153            tf.invocation(j, &args(&[]), Path::new("/here"), None),
1154            Err(MissingArg("file".into()))
1155        );
1156    }
1157
1158    #[test]
1159    fn optional_and_variadic_args_fill_from_defaults() {
1160        let tf = parse(
1161            "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
1162        );
1163        let j = tf.job("t").unwrap();
1164        assert!(!j.args[0].variadic && j.args[0].default.is_none());
1165        assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
1166        assert!(j.args[2].variadic);
1167        // Only `a` supplied: `b` uses its default, `rest` is empty.
1168        let inv = tf
1169            .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
1170            .unwrap();
1171        assert!(inv.args[1].contains("echo x fallback "));
1172        // bind() collects a trailing variadic from the leftover positionals.
1173        let bound =
1174            TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
1175        assert_eq!(bound.get("b").map(String::as_str), Some("y"));
1176        assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
1177    }
1178
1179    #[test]
1180    fn default_cwd_is_the_task_file_dir() {
1181        let tf = parse("## t\n\n```sh\ntrue\n```\n");
1182        let j = tf.job("t").unwrap();
1183        // Default: the file's directory, not where invoked.
1184        let inv = tf
1185            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1186            .unwrap();
1187        assert_eq!(inv.cwd, Path::new("/proj"));
1188        // With no job_file_dir known (headless), it falls back to cwd.
1189        let inv = tf
1190            .invocation(j, &args(&[]), Path::new("/here"), None)
1191            .unwrap();
1192        assert_eq!(inv.cwd, Path::new("/here"));
1193        // An empty job_file_dir (a bare filename's parent) also falls back to cwd,
1194        // since running in an empty path would fail.
1195        let inv = tf
1196            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
1197            .unwrap();
1198        assert_eq!(inv.cwd, Path::new("/here"));
1199    }
1200
1201    #[test]
1202    fn inherit_cwd_runs_in_the_invocation_dir() {
1203        let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
1204        let j = tf.job("t").unwrap();
1205        assert!(j.inherits_cwd());
1206        let inv = tf
1207            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1208            .unwrap();
1209        assert_eq!(inv.cwd, Path::new("/here"));
1210    }
1211
1212    #[test]
1213    fn an_unknown_opt_warns_but_is_ignored() {
1214        let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
1215        assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
1216        assert!(tf.jobs[0].inherits_cwd()); // the known flag still applies
1217        assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
1218    }
1219
1220    // A `requires_of` for tests: a map from job name to its dependency names.
1221    fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
1222        move |name| {
1223            map.iter()
1224                .find(|(n, _)| *n == name)
1225                .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
1226        }
1227    }
1228
1229    #[test]
1230    fn dependency_order_is_deps_first_target_last() {
1231        // a -> b -> c, plus a -> c: c runs once, before b, and a is last.
1232        let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
1233        assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
1234    }
1235
1236    #[test]
1237    fn dependency_order_dedupes_a_diamond() {
1238        let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
1239        let order = dependency_order("a", g).unwrap();
1240        assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
1241        // d before b and c; a last.
1242        let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
1243        assert!(pos("d") < pos("b") && pos("d") < pos("c"));
1244        assert_eq!(order.last().unwrap(), "a");
1245    }
1246
1247    #[test]
1248    fn dependency_order_detects_a_cycle() {
1249        let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
1250        assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
1251    }
1252
1253    #[test]
1254    fn dependency_order_flags_a_missing_dependency() {
1255        let g = deps_of(&[("a", &["ghost"])]);
1256        assert_eq!(
1257            dependency_order("a", g),
1258            Err(DepError::Missing {
1259                task: "ghost".into(),
1260                required_by: "a".into(),
1261            })
1262        );
1263    }
1264
1265    #[test]
1266    fn dependency_order_survives_a_pathologically_deep_chain() {
1267        // t0 -> t1 -> ... -> tN. Native recursion overflowed the stack here; the
1268        // iterative walk must return a full, correctly ordered chain instead.
1269        const N: usize = 200_000;
1270        let order = dependency_order("t0", |n| {
1271            let i: usize = n.strip_prefix('t')?.parse().ok()?;
1272            Some(if i + 1 < N {
1273                vec![format!("t{}", i + 1)]
1274            } else {
1275                vec![]
1276            })
1277        })
1278        .unwrap();
1279        assert_eq!(order.len(), N);
1280        assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); // deepest runs first
1281        assert_eq!(order.last().unwrap(), "t0"); // target runs last
1282    }
1283
1284    #[test]
1285    fn script_arg_templates_flags_only_declared_args_in_the_script() {
1286        // `name` is interpolated raw via {{ name }} (injectable); `safe` uses $safe.
1287        let tf =
1288            parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1289        let j = tf.job("t").unwrap();
1290        assert_eq!(j.script_arg_templates(), vec!["name"]);
1291
1292        // A job that only uses $arg has no raw template interpolation.
1293        let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1294        assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
1295    }
1296
1297    #[test]
1298    fn crlf_scripts_are_normalized() {
1299        let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1300        assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
1301        assert!(!tf.jobs[0].script.contains('\r'));
1302    }
1303
1304    #[test]
1305    fn an_unterminated_fence_warns_but_keeps_the_job() {
1306        let tf = parse("## a\n\n```sh\necho hi\n"); // no closing fence
1307        assert_eq!(tf.jobs.len(), 1);
1308        assert_eq!(tf.jobs[0].script.trim(), "echo hi");
1309        assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1310    }
1311
1312    #[test]
1313    fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1314        // ```sh has an info string, so it opens rather than closes; only a bare
1315        // ``` closes. (The trailing block here is what closes it.)
1316        let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1317        assert!(tf.jobs[0].script.contains("one"));
1318        assert!(tf.jobs[0].script.contains("```sh\ntwo"));
1319    }
1320
1321    #[test]
1322    fn indented_metadata_is_recognized() {
1323        let tf = parse("## a\n\n- steps:\n  Env: KEY=val\n\n```sh\ntrue\n```\n");
1324        assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
1325    }
1326
1327    #[test]
1328    fn duplicate_and_unknown_lang_warn() {
1329        let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1330        assert_eq!(tf.jobs.len(), 2);
1331        assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1332        assert!(tf.warnings.iter().any(|w| w.contains("json")));
1333    }
1334
1335    #[test]
1336    fn agent_jobs_filters_to_the_gated_ones() {
1337        let f = files(&[(
1338            "tasks.md",
1339            "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
1340        )]);
1341        let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
1342        assert_eq!(names, ["open"]);
1343    }
1344
1345    #[test]
1346    fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
1347        // The child redefines `deploy` WITHOUT the gate; the nearest definition
1348        // wins and it is not allowed, so `deploy` is not exposed (fail closed).
1349        let f = files(&[
1350            ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
1351            (
1352                "tasks.md",
1353                "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
1354            ),
1355        ]);
1356        assert!(agent_jobs(&f).is_empty());
1357    }
1358
1359    #[test]
1360    fn find_task_files_layers_child_over_parent() {
1361        // parent/tasks.md defines `base` + `shared`; parent/child/tasks.md
1362        // redefines `shared` + adds `only`. Nearest-first, so child wins.
1363        let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1364        let child = base.join("child");
1365        std::fs::create_dir_all(&child).unwrap();
1366        std::fs::write(
1367            base.join("tasks.md"),
1368            "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1369        )
1370        .unwrap();
1371        std::fs::write(
1372            child.join("tasks.md"),
1373            "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1374        )
1375        .unwrap();
1376
1377        let files = find_task_files(&child);
1378        assert_eq!(files.len(), 2, "child and parent files found");
1379        // Nearest first: child then parent.
1380        assert!(files[0].0.starts_with(&child));
1381        assert_eq!(
1382            files[0].1.job("shared").unwrap().script.trim(),
1383            "echo child"
1384        );
1385        // The parent still supplies `base` as an inherited baseline.
1386        assert!(files[1].1.job("base").is_some());
1387        std::fs::remove_dir_all(&base).ok();
1388    }
1389
1390    #[test]
1391    fn run_captured_returns_stdout() {
1392        let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
1393        let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
1394        assert!(out.status.success());
1395        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
1396    }
1397
1398    #[test]
1399    fn run_captured_runs_requires_deps_first() {
1400        let f = files(&[(
1401            "tasks.md",
1402            "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
1403        )]);
1404        let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
1405        let text = String::from_utf8_lossy(&out.stdout);
1406        // b runs before a (deps first).
1407        let bpos = text.find('B').expect("B in output");
1408        let apos = text.find('A').expect("A in output");
1409        assert!(bpos < apos, "deps must run first: {text}");
1410    }
1411
1412    #[test]
1413    fn run_reports_an_unknown_target_as_not_found() {
1414        let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
1415        match run_captured(&f, "ghost", &[], Path::new(".")) {
1416            Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
1417            other => panic!("expected NotFound, got {other:?}"),
1418        }
1419    }
1420
1421    // The two RCE regressions, exercised through the public agent gate.
1422
1423    #[test]
1424    fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
1425        // A nearer, untrusted `build` must NOT run when the allowed ancestor
1426        // `deploy` (which requires build) is invoked by name. The chain resolves
1427        // within deploy's own file, so the ancestor's real build runs, not PWNED.
1428        let f = files(&[
1429            ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
1430            (
1431                "tasks.md",
1432                "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
1433            ),
1434        ]);
1435        let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
1436        let text = String::from_utf8_lossy(&out.stdout);
1437        assert!(text.contains("real-build"), "got: {text}");
1438        assert!(text.contains("real-deploy"), "got: {text}");
1439        assert!(!text.contains("PWNED"), "nearer build ran: {text}");
1440        assert!(out.status.success());
1441    }
1442
1443    #[test]
1444    fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
1445        // greet interpolates {{ name }} raw into its script; an agent-supplied
1446        // value would be shell-injectable, so run_agent must refuse before running.
1447        let f = files(&[(
1448            "tasks.md",
1449            "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
1450        )]);
1451        match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
1452            Err(RunError::Injects { task, args }) => {
1453                assert_eq!(task, "greet");
1454                assert_eq!(args, vec!["name".to_string()]);
1455            }
1456            other => panic!("expected Injects, got {other:?}"),
1457        }
1458    }
1459
1460    #[test]
1461    fn run_agent_refuses_a_non_allowed_target() {
1462        let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
1463        match run_agent(&f, "secret", &[], Path::new(".")) {
1464            Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
1465            other => panic!("expected NotAllowed, got {other:?}"),
1466        }
1467    }
1468
1469    #[test]
1470    fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
1471        // The nearest `deploy` lacks the gate; it shadows the farther allowed one.
1472        let f = files(&[
1473            ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
1474            (
1475                "tasks.md",
1476                "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
1477            ),
1478        ]);
1479        match run_agent(&f, "deploy", &[], Path::new(".")) {
1480            Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
1481            other => panic!("expected NotAllowed, got {other:?}"),
1482        }
1483    }
1484}