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", "no-strict"];
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    /// Whether shell strictness applies. On unless `Opts: no-strict`.
306    ///
307    /// Strict is the default because the failure modes are asymmetric. A strict
308    /// default fails loudly when an author did not expect it, and they add
309    /// `no-strict`. A lenient default fails SILENTLY: a shell runs the whole
310    /// fenced block as one script, so an early failure is swallowed and the task
311    /// exits with the status of the last command. That turns a multi-step gate
312    /// into one that cannot fail, and it will report success while `cargo fmt`
313    /// is failing inside it.
314    ///
315    /// The other evidence is that authors were already writing the prelude by
316    /// hand: every multi-step task in mdtask's own dogfood repos opened with
317    /// `set -euo pipefail`. When everyone writes the same first line, it belongs
318    /// in the tool.
319    pub(crate) fn is_strict(&self) -> bool {
320        !self.opts.iter().any(|o| o == "no-strict")
321    }
322
323    /// The declared argument names this job interpolates into its **script** via
324    /// `{{ arg }}` (raw text substitution, spliced in before the interpreter parses
325    /// the script). Because it is not quoted, each of these is an injection point
326    /// for an untrusted argument value, in any language, so [`run_agent`] refuses a
327    /// job that has any. Empty for a job that reads its args from the environment,
328    /// the safe form.
329    pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
330        let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
331        let mut found: Vec<&str> = Vec::new();
332        let mut rest = self.script.as_str();
333        while let Some(open) = rest.find("{{") {
334            let after = &rest[open + 2..];
335            let Some(close) = after.find("}}") else { break };
336            let tok = after[..close].trim();
337            if declared.contains(tok) && !found.contains(&tok) {
338                found.push(tok);
339            }
340            rest = &after[close + 2..];
341        }
342        found
343    }
344}
345
346impl TaskFile {
347    /// The jobs in this file, in document order.
348    pub fn jobs(&self) -> &[Job] {
349        &self.jobs
350    }
351
352    /// Find a job by name. The match is exact and case-sensitive, against the
353    /// heading text as written. The first definition wins if a name is duplicated
354    /// (a warning is recorded).
355    pub fn job(&self, name: &str) -> Option<&Job> {
356        self.jobs.iter().find(|j| j.name == name)
357    }
358
359    /// Any parse warnings (an unterminated fence, a duplicate job, an unknown fence
360    /// language). Parsing is infallible, so surface these rather than trust silence.
361    pub fn warnings(&self) -> &[String] {
362        &self.warnings
363    }
364
365    /// Build the invocation for `job`, given `args` mapping each name to a value.
366    /// It substitutes `{{ arg }}` in the script, exports the args and env, and
367    /// resolves the working directory: by default the job runs in `job_file_dir`
368    /// (the directory of the file that defines it; `None` or empty falls back to
369    /// `cwd`), while `Opts: inherit-cwd` runs it in `cwd`. Missing optional and
370    /// variadic args are filled from their defaults; only a missing required arg is
371    /// an error.
372    pub(crate) fn invocation(
373        &self,
374        job: &Job,
375        args: &BTreeMap<String, String>,
376        cwd: &Path,
377        job_file_dir: Option<&Path>,
378    ) -> Result<Invocation, MissingArg> {
379        // Fill defaults for any declared arg the caller did not supply.
380        let mut effective = args.clone();
381        for a in &job.args {
382            if !effective.contains_key(&a.name) {
383                if a.variadic {
384                    effective.insert(a.name.clone(), String::new());
385                } else if let Some(d) = &a.default {
386                    effective.insert(a.name.clone(), d.clone());
387                } else {
388                    return Err(MissingArg(a.name.clone()));
389                }
390            }
391        }
392
393        let script = substitute(&job.script, &effective);
394        let (program, flag) = interpreter(&job.lang);
395        let script = match strict_prelude(&job.lang) {
396            Some(prelude) if job.is_strict() => format!("{prelude}\n{script}"),
397            _ => script,
398        };
399
400        // Env precedence: hoisted, then job, then args. Args win, being the most
401        // specific, so `$name` resolves to the passed value.
402        let mut env = self.env.clone();
403        env.extend(job.env.iter().cloned());
404        env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
405
406        // The job's own directory is the default anchor; `inherit-cwd` opts into
407        // the invocation directory. An absent or empty job_file_dir (a bare
408        // filename with no directory part) falls back to cwd, since running in an
409        // empty path would fail.
410        let run_cwd = match job_file_dir {
411            _ if job.inherits_cwd() => cwd.to_path_buf(),
412            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
413            _ => cwd.to_path_buf(),
414        };
415
416        Ok(Invocation {
417            program: program.to_string(),
418            args: vec![flag.to_string(), script],
419            env,
420            cwd: run_cwd,
421        })
422    }
423
424    /// Bind positional argument values to a job's declared `Args:`, applying
425    /// defaults and collecting a trailing `*variadic` from the rest. This feeds
426    /// [`TaskFile::invocation`] and errors on a missing required arg.
427    pub(crate) fn bind(
428        job: &Job,
429        positional: &[String],
430    ) -> Result<BTreeMap<String, String>, MissingArg> {
431        let mut map = BTreeMap::new();
432        let mut i = 0;
433        for a in &job.args {
434            if a.variadic {
435                map.insert(
436                    a.name.clone(),
437                    positional[i.min(positional.len())..].join(" "),
438                );
439                i = positional.len();
440            } else if i < positional.len() {
441                map.insert(a.name.clone(), positional[i].clone());
442                i += 1;
443            } else if let Some(d) = &a.default {
444                map.insert(a.name.clone(), d.clone());
445            } else {
446                return Err(MissingArg(a.name.clone()));
447            }
448        }
449        Ok(map)
450    }
451}
452
453/// The jobs a set of layered files exposes to an agent or MCP surface: one per
454/// name using the **nearest** definition (so a nearer non-allowed job shadows a
455/// farther allowed one, matching run semantics), keeping only those whose nearest
456/// definition carries `Agent: allow`. This is the enforcement point for listing;
457/// [`run_agent`] is the enforcement point for running. A surface exposing jobs to
458/// an agent should list only these.
459pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
460    let mut seen = BTreeSet::new();
461    let mut out = Vec::new();
462    for (_, tf) in files {
463        for job in &tf.jobs {
464            if seen.insert(job.name.clone()) && job.agent_allow {
465                out.push(job);
466            }
467        }
468    }
469    out
470}
471
472/// The nearest definition of `name` across the layered files, plus the file that
473/// owns it and that file's directory (`None` when the path has no directory part).
474/// A nearer definition wins (the fallback layering), so this resolves both a
475/// target and each `Requires:` dependency the same way the CLI does.
476fn trusted_lookup<'a>(
477    files: &'a [(PathBuf, TaskFile)],
478    name: &str,
479) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
480    files
481        .iter()
482        .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
483}
484
485/// Resolve `target` and its `Requires:` chain across the layered files (deps
486/// first, target last, each once). A name that resolves nowhere is `NotFound`.
487fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<String>, RunError> {
488    if trusted_lookup(files, target).is_none() {
489        return Err(RunError::NotFound(target.to_string()));
490    }
491    dependency_order(target, |n| {
492        trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
493    })
494    .map_err(RunError::Dependency)
495}
496
497/// Build the ordered, ready-to-spawn invocations for `order`. `lookup` resolves
498/// each step to its file, job, and directory; only `target` receives `args`,
499/// while every dependency runs argless (its own defaults fill in).
500fn plan_invocations<'a>(
501    order: &[String],
502    target: &str,
503    args: &[String],
504    cwd: &Path,
505    lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
506) -> Result<Vec<Invocation>, RunError> {
507    let mut plan = Vec::with_capacity(order.len());
508    for step in order {
509        let (tf, job, dir) = lookup(step).expect("a resolved name still resolves");
510        let step_args: &[String] = if step == target { args } else { &[] };
511        let values = TaskFile::bind(job, step_args).map_err(RunError::MissingArg)?;
512        let inv = tf
513            .invocation(job, &values, cwd, dir)
514            .map_err(RunError::MissingArg)?;
515        plan.push(inv);
516    }
517    Ok(plan)
518}
519
520/// Run a planned chain with captured output, aggregating stdout and stderr across
521/// steps and stopping on the first non-success step. The returned status is that
522/// step's (or the last step's on full success). The plan always holds the target,
523/// so it is never empty.
524fn run_plan_captured(plan: &[Invocation]) -> Result<std::process::Output, RunError> {
525    let mut stdout = Vec::new();
526    let mut stderr = Vec::new();
527    let mut status = None;
528    for inv in plan {
529        let out = inv.run_captured().map_err(RunError::Io)?;
530        stdout.extend_from_slice(&out.stdout);
531        stderr.extend_from_slice(&out.stderr);
532        let failed = !out.status.success();
533        status = Some(out.status);
534        if failed {
535            break;
536        }
537    }
538    Ok(std::process::Output {
539        status: status.expect("the plan always contains the target"),
540        stdout,
541        stderr,
542    })
543}
544
545/// Run `name` and its `Requires:` chain across the layered `files`, inheriting the
546/// parent's stdio so output streams straight through (the CLI path: a job is an
547/// interactive command, not a captured subprocess). Dependencies run first, each
548/// once, and each with its own defaults; only `name` receives `args`. Returns the
549/// first failing step's exit status, or the last step's on full success. This is
550/// **trusted**: it applies no agent gate, so a caller must not hand it a name from
551/// an untrusted source.
552pub fn run(
553    files: &[(PathBuf, TaskFile)],
554    name: &str,
555    args: &[String],
556    cwd: &Path,
557) -> Result<std::process::ExitStatus, RunError> {
558    let order = trusted_order(files, name)?;
559    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
560    let mut last = None;
561    for inv in &plan {
562        let status = inv.run_inherit().map_err(RunError::Io)?;
563        if !status.success() {
564            return Ok(status);
565        }
566        last = Some(status);
567    }
568    Ok(last.expect("the plan always contains the target"))
569}
570
571/// Like [`run`], but captured: run `name` and its `Requires:` chain across the
572/// layered `files` with output aggregated across steps into a single
573/// [`std::process::Output`] (its status is the failing step's, or the last on
574/// success). For an embedder (a TUI, an editor) that wants the text rather than a
575/// stream. Also **trusted**: no agent gate.
576pub fn run_captured(
577    files: &[(PathBuf, TaskFile)],
578    name: &str,
579    args: &[String],
580    cwd: &Path,
581) -> Result<std::process::Output, RunError> {
582    let order = trusted_order(files, name)?;
583    let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
584    run_plan_captured(&plan)
585}
586
587/// The agent gate: run `name` for an MCP or agent surface, captured, failing
588/// closed. Enforced, in order:
589///
590/// - The **nearest** definition of `name` across the layered files must carry
591///   `Agent: allow`, else [`RunError::NotAllowed`]. A nearer non-allowed
592///   definition shadows a farther allowed one (still `NotAllowed`), and a name
593///   that resolves nowhere is `NotAllowed` too: the agent never learns whether a
594///   hidden job exists.
595/// - The target must not raw-template a declared arg into its script via
596///   `{{ arg }}` (the agent controls the value), else [`RunError::Injects`]. The
597///   author must read the value from the environment instead.
598/// - The `Requires:` chain is resolved **within the target's own file**, not by
599///   the cross-file nearest-wins scan [`run`] uses. The author who wrote
600///   `Agent: allow` vouched for their file's jobs; a nearer, untrusted task file in
601///   the invocation directory must not be able to shadow a dependency and run
602///   attacker-controlled code through an allowed entry point. A dependency is never
603///   independently callable and never listed.
604///
605/// Only the target receives `args`; dependencies run argless with author-controlled
606/// defaults, so the target is the sole injection surface.
607pub fn run_agent(
608    files: &[(PathBuf, TaskFile)],
609    name: &str,
610    args: &[String],
611    cwd: &Path,
612) -> Result<std::process::Output, RunError> {
613    // The nearest definition wins. If it is not allowed (or the name resolves
614    // nowhere), refuse: fail closed.
615    let mut target: Option<(&Path, &TaskFile, &Job)> = None;
616    for (p, tf) in files {
617        if let Some(job) = tf.job(name) {
618            if job.agent_allow {
619                target = Some((p.as_path(), tf, job));
620            }
621            break; // the nearest definition decides, allowed or not
622        }
623    }
624    let Some((target_path, target_tf, target_job)) = target else {
625        return Err(RunError::NotAllowed(name.to_string()));
626    };
627
628    // Refuse a target that raw-templates an untrusted arg into its script.
629    let templated = target_job.script_arg_templates();
630    if !templated.is_empty() {
631        return Err(RunError::Injects {
632            task: name.to_string(),
633            args: templated.iter().map(|s| s.to_string()).collect(),
634        });
635    }
636
637    // Resolve the Requires: chain WITHIN the target's own file (the security
638    // boundary), not the cross-file scan.
639    let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
640        .map_err(RunError::Dependency)?;
641    let dir = target_path.parent();
642    let plan = plan_invocations(&order, name, args, cwd, |n| {
643        target_tf.job(n).map(|j| (target_tf, j, dir))
644    })?;
645    run_plan_captured(&plan)
646}
647
648impl Invocation {
649    /// The `std::process::Command` for this invocation (program, argv, env, cwd).
650    fn command(&self) -> std::process::Command {
651        let mut cmd = std::process::Command::new(&self.program);
652        cmd.args(&self.args)
653            .envs(self.env.iter().map(|(k, v)| (k, v)))
654            .current_dir(&self.cwd);
655        cmd
656    }
657
658    /// Run inheriting the parent's stdio so output streams straight through.
659    fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
660        self.command().status()
661    }
662
663    /// Run capturing stdout and stderr.
664    fn run_captured(&self) -> std::io::Result<std::process::Output> {
665        self.command().output()
666    }
667}
668
669/// Map a fence language to `(program, code-flag)`. Unlabeled or unknown falls
670/// back to `sh -c`, so a plain ` ``` ` block runs as a shell script.
671fn interpreter(lang: &str) -> (&'static str, &'static str) {
672    match lang.trim().to_ascii_lowercase().as_str() {
673        "" | "sh" | "shell" => ("sh", "-c"),
674        "bash" => ("bash", "-c"),
675        "zsh" => ("zsh", "-c"),
676        "fish" => ("fish", "-c"),
677        "python" | "py" | "python3" => ("python3", "-c"),
678        "ruby" => ("ruby", "-e"),
679        "node" | "js" | "javascript" => ("node", "-e"),
680        _ => ("sh", "-c"),
681    }
682}
683
684/// The strictness prelude for a language, if it has one.
685///
686/// Only shells, and only the settings that are about *detecting failure*, which
687/// is the task runner's job:
688///
689/// - `set -e` stops at the first failing command, so a gate cannot pass while a
690///   step inside it fails.
691/// - `pipefail` extends that through a pipeline, where the exit status would
692///   otherwise be the last stage's and a failing producer would go unnoticed.
693///
694/// Deliberately NOT `set -u`. Catching an unset variable is a lint rather than
695/// failure detection, and it changes the meaning of correct scripts: reading an
696/// optional variable is ordinary in a task file, and defaulting it to a hard
697/// error would break working tasks to catch a typo. Authors who want it can
698/// still write it themselves.
699///
700/// `pipefail` is not POSIX, so plain `sh` gets only `set -e`: dash rejects
701/// `set -o pipefail` outright, which would break every task on a Debian-ish
702/// `/bin/sh`. `fish` gets nothing, having neither the syntax nor the semantics,
703/// and non-shells are left alone entirely.
704fn strict_prelude(lang: &str) -> Option<&'static str> {
705    match lang.trim().to_ascii_lowercase().as_str() {
706        "" | "sh" | "shell" => Some("set -e"),
707        "bash" | "zsh" => Some("set -e\nset -o pipefail"),
708        _ => None,
709    }
710}
711
712/// Replace `{{ name }}` tokens (any inner whitespace) with `args[name]`. A token
713/// whose name is not in `args` is left as written, so a literal `{{x}}` that is
714/// not an argument survives.
715fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
716    let mut out = String::with_capacity(src.len());
717    let mut rest = src;
718    while let Some(open) = rest.find("{{") {
719        out.push_str(&rest[..open]);
720        let after = &rest[open + 2..];
721        if let Some(close) = after.find("}}") {
722            let name = after[..close].trim();
723            match args.get(name) {
724                Some(v) => out.push_str(v),
725                None => {
726                    // Not an argument: keep the token verbatim.
727                    out.push_str("{{");
728                    out.push_str(&after[..close]);
729                    out.push_str("}}");
730                }
731            }
732            rest = &after[close + 2..];
733        } else {
734            out.push_str("{{");
735            rest = after;
736        }
737    }
738    out.push_str(rest);
739    out
740}
741
742/// Parse a markdown task file. It is line-based (no CommonMark dependency): a
743/// heading starts a job, the first fenced block under it is the script, and
744/// `Key: value` lines set metadata. Parsing is infallible; problems are reported
745/// in [`TaskFile::warnings`] rather than dropped to silence. CRLF endings are
746/// normalized.
747pub fn parse(src: &str) -> TaskFile {
748    let mut file = TaskFile::default();
749    let mut cur: Option<Job> = None;
750    let mut in_fence = false;
751    let mut fence_marker = "";
752    let mut have_script = false; // first fence per job only
753    let mut script = String::new();
754
755    for raw in src.split('\n') {
756        let line = raw.strip_suffix('\r').unwrap_or(raw); // normalize CRLF
757        if in_fence {
758            // A fence is closed only by a BARE marker line (CommonMark): ` ``` `
759            // with an info string opens, it does not close, so a stray fence-open
760            // cannot accidentally terminate an unterminated block early.
761            if is_closing_fence(line, fence_marker) {
762                in_fence = false;
763                if let Some(t) = cur.as_mut()
764                    && !have_script
765                {
766                    t.script = std::mem::take(&mut script);
767                    have_script = true;
768                }
769                script.clear();
770            } else if cur.is_some() && !have_script {
771                script.push_str(line);
772                script.push('\n');
773            }
774            continue;
775        }
776        if let Some(marker) = opening_fence(line) {
777            in_fence = true;
778            fence_marker = marker;
779            if let Some(t) = cur.as_mut()
780                && !have_script
781            {
782                t.lang = info_string(line, marker);
783            }
784            script.clear();
785            continue;
786        }
787        if let Some(name) = heading(line) {
788            finalize(cur.take(), &mut file);
789            cur = Some(Job {
790                name,
791                ..Job::default()
792            });
793            have_script = false;
794            continue;
795        }
796        apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
797    }
798    // An unterminated fence at EOF: still capture the script so the job is not
799    // lost, but warn, since a forgotten closing fence is a common authoring slip.
800    if in_fence {
801        if let Some(t) = cur.as_mut()
802            && !have_script
803        {
804            t.script = std::mem::take(&mut script);
805        }
806        let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
807        file.warnings
808            .push(format!("unterminated code fence in task {name:?}"));
809    }
810    finalize(cur.take(), &mut file);
811    file
812}
813
814/// Finalize a heading into the file. A heading with a script is a job; one without
815/// (a `# Tasks` section) is not, but its `Env:` hoists to all jobs. Records
816/// warnings for a duplicate name or an unknown fence language.
817fn finalize(job: Option<Job>, file: &mut TaskFile) {
818    let Some(mut t) = job else {
819        return;
820    };
821    if t.script.is_empty() {
822        file.env.append(&mut t.env); // section heading, so hoist its env
823        return;
824    }
825    t.description = t.description.trim().to_string();
826    if file.jobs.iter().any(|x| x.name == t.name) {
827        file.warnings.push(format!(
828            "duplicate task {:?}; the first defined wins",
829            t.name
830        ));
831    }
832    if !is_known_lang(&t.lang) {
833        file.warnings.push(format!(
834            "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
835            t.name, t.lang
836        ));
837    }
838    file.jobs.push(t);
839}
840
841/// Whether a fence language maps to an interpreter (unlabeled counts as `sh`).
842fn is_known_lang(lang: &str) -> bool {
843    matches!(
844        lang.trim().to_ascii_lowercase().as_str(),
845        "" | "sh"
846            | "shell"
847            | "bash"
848            | "zsh"
849            | "fish"
850            | "python"
851            | "py"
852            | "python3"
853            | "ruby"
854            | "node"
855            | "js"
856            | "javascript"
857    )
858}
859
860/// The opening fence marker if `line` starts one, else `None`.
861fn opening_fence(line: &str) -> Option<&'static str> {
862    let t = line.trim_start();
863    if t.starts_with("```") {
864        Some("```")
865    } else if t.starts_with("~~~") {
866        Some("~~~")
867    } else {
868        None
869    }
870}
871
872/// Whether `line` is a bare closing fence for `marker`: only the fence char, no
873/// info string, per CommonMark's closing rule.
874fn is_closing_fence(line: &str, marker: &str) -> bool {
875    let ch = marker.as_bytes()[0];
876    let t = line.trim();
877    t.len() >= 3 && t.bytes().all(|b| b == ch)
878}
879
880/// Search for task files from `start` up to the filesystem root, **nearest
881/// first**. In each ancestor directory the first of `tasks.md`, `maskfile.md`,
882/// `README.md` that parses to at least one job is taken. The CLI layers these
883/// child-first, so a nearer file shadows a farther one by job name (like just's
884/// `set fallback`, letting a project inherit a baseline of jobs from a parent).
885/// Embedders with their own project root can ignore this and call [`parse`].
886pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
887    let mut found = Vec::new();
888    for dir in start.ancestors() {
889        for name in ["tasks.md", "maskfile.md", "README.md"] {
890            let path = dir.join(name);
891            if let Ok(src) = std::fs::read_to_string(&path) {
892                let tf = parse(&src);
893                if !tf.jobs.is_empty() {
894                    found.push((path, tf));
895                    break; // one file per directory
896                }
897            }
898        }
899    }
900    found
901}
902
903/// The info-string language after the opening fence marker.
904fn info_string(line: &str, marker: &str) -> String {
905    line.trim_start()
906        .strip_prefix(marker)
907        .unwrap_or("")
908        .split_whitespace()
909        .next()
910        .unwrap_or("")
911        .to_string()
912}
913
914/// The heading text if `line` is an ATX heading (`#`..`######`), else `None`.
915fn heading(line: &str) -> Option<String> {
916    let t = line.trim_start();
917    if !t.starts_with('#') {
918        return None;
919    }
920    let after = t.trim_start_matches('#');
921    // Must have a space after the `#` run (a real ATX heading), and not be all #.
922    if after == t || !after.starts_with(' ') {
923        return None;
924    }
925    Some(after.trim().to_string())
926}
927
928/// Apply a body line: a recognized `Key: value` sets metadata (case-insensitive
929/// key, xc vocabulary); anything else is description. `Env:` before the first job
930/// accumulates into the hoisted `file_env`.
931fn apply_line(
932    line: &str,
933    job: Option<&mut Job>,
934    file_env: &mut Vec<(String, String)>,
935    warnings: &mut Vec<String>,
936) {
937    if let Some((key, value)) = split_key(line) {
938        let value = value.trim();
939        match key.as_str() {
940            "env" | "environment" => {
941                let pairs = parse_env(value);
942                match job {
943                    Some(t) => t.env.extend(pairs),
944                    None => file_env.extend(pairs), // hoisted
945                }
946                return;
947            }
948            "opts" | "options" => {
949                if let Some(t) = job {
950                    t.opts = value.split_whitespace().map(str::to_string).collect();
951                    for flag in &t.opts {
952                        if !KNOWN_OPTS.contains(&flag.as_str()) {
953                            warnings.push(format!(
954                                "unknown option {flag:?} in `Opts:` (known: {})",
955                                KNOWN_OPTS.join(", ")
956                            ));
957                        }
958                    }
959                }
960                return;
961            }
962            "args" | "arguments" => {
963                if let Some(t) = job {
964                    t.args = parse_args(value);
965                }
966                return;
967            }
968            "requires" | "req" => {
969                if let Some(t) = job {
970                    t.requires = value
971                        .split(',')
972                        .map(|s| s.trim().to_string())
973                        .filter(|s| !s.is_empty())
974                        .collect();
975                }
976                return;
977            }
978            "agent" => {
979                if let Some(t) = job {
980                    t.agent_allow = value.eq_ignore_ascii_case("allow");
981                }
982                return;
983            }
984            _ => {}
985        }
986    }
987    // Description (only within a job; drop stray prose outside one).
988    if let Some(t) = job
989        && !line.trim().is_empty()
990    {
991        t.description.push_str(line.trim());
992        t.description.push('\n');
993    }
994}
995
996/// Split `Key: value`, returning the lowercased key if the line looks like one
997/// (a single-word key before the first colon). Leading indentation is allowed, so
998/// an `Env:` indented under a list still counts. This is safe because only *known*
999/// keys act (see `apply_line`), so ordinary prose with a colon stays description.
1000fn split_key(line: &str) -> Option<(String, &str)> {
1001    let colon = line.find(':')?;
1002    let key = line[..colon].trim();
1003    if key.is_empty() || key.contains(char::is_whitespace) {
1004        return None;
1005    }
1006    Some((key.to_ascii_lowercase(), &line[colon + 1..]))
1007}
1008
1009/// Parse an `Env:` value: comma-separated `KEY=VALUE` pairs.
1010fn parse_env(value: &str) -> Vec<(String, String)> {
1011    value
1012        .split(',')
1013        .filter_map(|p| {
1014            let (k, v) = p.split_once('=')?;
1015            let k = k.trim();
1016            if k.is_empty() {
1017                return None;
1018            }
1019            Some((k.to_string(), v.trim().to_string()))
1020        })
1021        .collect()
1022}
1023
1024/// Parse an `Args:` value into declared [`Arg`]s (just's syntax): `name` is
1025/// required, `*name` collects the rest (variadic), `name='default'` (or
1026/// `name="default"`) is optional. Tokens are whitespace-separated, but a quoted
1027/// default may itself contain spaces (`msg='hello world'`).
1028fn parse_args(value: &str) -> Vec<Arg> {
1029    tokenize_args(value)
1030        .into_iter()
1031        .filter_map(|tok| {
1032            let (name, default) = match tok.split_once('=') {
1033                Some((n, d)) => (n, Some(unquote(d).to_string())),
1034                None => (tok.as_str(), None),
1035            };
1036            let (name, variadic) = match name.strip_prefix('*') {
1037                Some(rest) => (rest, true),
1038                None => (name, false),
1039            };
1040            let name = name.trim();
1041            if name.is_empty() {
1042                return None;
1043            }
1044            Some(Arg {
1045                name: name.to_string(),
1046                variadic,
1047                default,
1048            })
1049        })
1050        .collect()
1051}
1052
1053/// Split an `Args:` value on whitespace, but keep a single- or double-quoted run
1054/// (a default value) together so `msg='a b'` is one token.
1055fn tokenize_args(value: &str) -> Vec<String> {
1056    let mut out = Vec::new();
1057    let mut cur = String::new();
1058    let mut quote: Option<char> = None;
1059    for c in value.chars() {
1060        match quote {
1061            Some(q) => {
1062                cur.push(c);
1063                if c == q {
1064                    quote = None;
1065                }
1066            }
1067            None if c == '\'' || c == '"' => {
1068                cur.push(c);
1069                quote = Some(c);
1070            }
1071            None if c.is_whitespace() => {
1072                if !cur.is_empty() {
1073                    out.push(std::mem::take(&mut cur));
1074                }
1075            }
1076            None => cur.push(c),
1077        }
1078    }
1079    if !cur.is_empty() {
1080        out.push(cur);
1081    }
1082    out
1083}
1084
1085/// Strip one matching pair of surrounding single or double quotes, if present.
1086fn unquote(s: &str) -> &str {
1087    let s = s.trim();
1088    let b = s.as_bytes();
1089    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
1090        &s[1..s.len() - 1]
1091    } else {
1092        s
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099
1100    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1101        pairs
1102            .iter()
1103            .map(|(k, v)| (k.to_string(), v.to_string()))
1104            .collect()
1105    }
1106
1107    fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
1108        pairs
1109            .iter()
1110            .map(|(path, src)| (PathBuf::from(path), parse(src)))
1111            .collect()
1112    }
1113
1114    #[test]
1115    fn parses_named_jobs_with_interpreter() {
1116        let tf =
1117            parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
1118        assert_eq!(tf.jobs.len(), 2);
1119        assert_eq!(tf.jobs[0].name, "build");
1120        assert_eq!(tf.jobs[0].lang, "sh");
1121        assert_eq!(tf.jobs[0].script.trim(), "cargo build");
1122        assert_eq!(tf.jobs[1].lang, "zsh");
1123    }
1124
1125    #[test]
1126    fn metadata_keys_are_case_insensitive() {
1127        let tf = parse(
1128            "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
1129        );
1130        let t = &tf.jobs[0];
1131        assert_eq!(t.opts, vec!["inherit-cwd"]);
1132        assert!(t.inherits_cwd());
1133        assert_eq!(
1134            t.env,
1135            vec![
1136                ("REGION".into(), "us".into()),
1137                ("TIER".into(), "prod".into())
1138            ]
1139        );
1140        assert_eq!(
1141            t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
1142            ["target"]
1143        );
1144        assert_eq!(t.requires, vec!["build", "test"]);
1145        assert!(t.agent_allow);
1146    }
1147
1148    #[test]
1149    fn agent_gate_is_off_by_default() {
1150        let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
1151        assert!(!tf.jobs[0].agent_allow);
1152    }
1153
1154    #[test]
1155    fn top_level_env_is_hoisted() {
1156        let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
1157        assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
1158    }
1159
1160    #[test]
1161    fn fence_content_is_not_parsed_as_structure() {
1162        // A `## heading` and a `Key:` line inside a fence stay in the script.
1163        let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
1164        assert_eq!(tf.jobs.len(), 1);
1165        assert!(tf.jobs[0].script.contains("## not a task"));
1166        assert!(tf.jobs[0].env.is_empty());
1167    }
1168
1169    #[test]
1170    fn substitutes_args_and_leaves_unknown_tokens() {
1171        let out = substitute(
1172            "hello {{ name }} and {{ other }}",
1173            &args(&[("name", "world")]),
1174        );
1175        assert_eq!(out, "hello world and {{ other }}");
1176    }
1177
1178    #[test]
1179    fn invocation_substitutes_sets_env_and_picks_interpreter() {
1180        let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
1181        let j = tf.job("greet").unwrap();
1182        let inv = tf
1183            .invocation(
1184                j,
1185                &args(&[("name", "sam")]),
1186                Path::new("/here"),
1187                Some(Path::new("/file")),
1188            )
1189            .unwrap();
1190        assert_eq!(inv.program, "zsh");
1191        assert_eq!(inv.args[0], "-c");
1192        assert!(inv.args[1].contains("hi sam"));
1193        assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
1194        // By default it runs in the task file's directory, not where invoked.
1195        assert_eq!(inv.cwd, Path::new("/file"));
1196    }
1197
1198    #[test]
1199    fn a_missing_required_arg_is_an_error() {
1200        let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
1201        let j = tf.job("t").unwrap();
1202        assert_eq!(
1203            tf.invocation(j, &args(&[]), Path::new("/here"), None),
1204            Err(MissingArg("file".into()))
1205        );
1206    }
1207
1208    #[test]
1209    fn optional_and_variadic_args_fill_from_defaults() {
1210        let tf = parse(
1211            "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
1212        );
1213        let j = tf.job("t").unwrap();
1214        assert!(!j.args[0].variadic && j.args[0].default.is_none());
1215        assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
1216        assert!(j.args[2].variadic);
1217        // Only `a` supplied: `b` uses its default, `rest` is empty.
1218        let inv = tf
1219            .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
1220            .unwrap();
1221        assert!(inv.args[1].contains("echo x fallback "));
1222        // bind() collects a trailing variadic from the leftover positionals.
1223        let bound =
1224            TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
1225        assert_eq!(bound.get("b").map(String::as_str), Some("y"));
1226        assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
1227    }
1228
1229    #[test]
1230    fn default_cwd_is_the_task_file_dir() {
1231        let tf = parse("## t\n\n```sh\ntrue\n```\n");
1232        let j = tf.job("t").unwrap();
1233        // Default: the file's directory, not where invoked.
1234        let inv = tf
1235            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1236            .unwrap();
1237        assert_eq!(inv.cwd, Path::new("/proj"));
1238        // With no job_file_dir known (headless), it falls back to cwd.
1239        let inv = tf
1240            .invocation(j, &args(&[]), Path::new("/here"), None)
1241            .unwrap();
1242        assert_eq!(inv.cwd, Path::new("/here"));
1243        // An empty job_file_dir (a bare filename's parent) also falls back to cwd,
1244        // since running in an empty path would fail.
1245        let inv = tf
1246            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
1247            .unwrap();
1248        assert_eq!(inv.cwd, Path::new("/here"));
1249    }
1250
1251    #[test]
1252    fn inherit_cwd_runs_in_the_invocation_dir() {
1253        let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
1254        let j = tf.job("t").unwrap();
1255        assert!(j.inherits_cwd());
1256        let inv = tf
1257            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1258            .unwrap();
1259        assert_eq!(inv.cwd, Path::new("/here"));
1260    }
1261
1262    #[test]
1263    fn an_unknown_opt_warns_but_is_ignored() {
1264        let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
1265        assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
1266        assert!(tf.jobs[0].inherits_cwd()); // the known flag still applies
1267        assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
1268    }
1269
1270    // A `requires_of` for tests: a map from job name to its dependency names.
1271    fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
1272        move |name| {
1273            map.iter()
1274                .find(|(n, _)| *n == name)
1275                .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
1276        }
1277    }
1278
1279    #[test]
1280    fn dependency_order_is_deps_first_target_last() {
1281        // a -> b -> c, plus a -> c: c runs once, before b, and a is last.
1282        let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
1283        assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
1284    }
1285
1286    #[test]
1287    fn dependency_order_dedupes_a_diamond() {
1288        let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
1289        let order = dependency_order("a", g).unwrap();
1290        assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
1291        // d before b and c; a last.
1292        let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
1293        assert!(pos("d") < pos("b") && pos("d") < pos("c"));
1294        assert_eq!(order.last().unwrap(), "a");
1295    }
1296
1297    #[test]
1298    fn dependency_order_detects_a_cycle() {
1299        let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
1300        assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
1301    }
1302
1303    #[test]
1304    fn dependency_order_flags_a_missing_dependency() {
1305        let g = deps_of(&[("a", &["ghost"])]);
1306        assert_eq!(
1307            dependency_order("a", g),
1308            Err(DepError::Missing {
1309                task: "ghost".into(),
1310                required_by: "a".into(),
1311            })
1312        );
1313    }
1314
1315    #[test]
1316    fn dependency_order_survives_a_pathologically_deep_chain() {
1317        // t0 -> t1 -> ... -> tN. Native recursion overflowed the stack here; the
1318        // iterative walk must return a full, correctly ordered chain instead.
1319        const N: usize = 200_000;
1320        let order = dependency_order("t0", |n| {
1321            let i: usize = n.strip_prefix('t')?.parse().ok()?;
1322            Some(if i + 1 < N {
1323                vec![format!("t{}", i + 1)]
1324            } else {
1325                vec![]
1326            })
1327        })
1328        .unwrap();
1329        assert_eq!(order.len(), N);
1330        assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); // deepest runs first
1331        assert_eq!(order.last().unwrap(), "t0"); // target runs last
1332    }
1333
1334    #[test]
1335    fn script_arg_templates_flags_only_declared_args_in_the_script() {
1336        // `name` is interpolated raw via {{ name }} (injectable); `safe` uses $safe.
1337        let tf =
1338            parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1339        let j = tf.job("t").unwrap();
1340        assert_eq!(j.script_arg_templates(), vec!["name"]);
1341
1342        // A job that only uses $arg has no raw template interpolation.
1343        let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1344        assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
1345    }
1346
1347    #[test]
1348    fn crlf_scripts_are_normalized() {
1349        let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1350        assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
1351        assert!(!tf.jobs[0].script.contains('\r'));
1352    }
1353
1354    #[test]
1355    fn an_unterminated_fence_warns_but_keeps_the_job() {
1356        let tf = parse("## a\n\n```sh\necho hi\n"); // no closing fence
1357        assert_eq!(tf.jobs.len(), 1);
1358        assert_eq!(tf.jobs[0].script.trim(), "echo hi");
1359        assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1360    }
1361
1362    #[test]
1363    fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1364        // ```sh has an info string, so it opens rather than closes; only a bare
1365        // ``` closes. (The trailing block here is what closes it.)
1366        let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1367        assert!(tf.jobs[0].script.contains("one"));
1368        assert!(tf.jobs[0].script.contains("```sh\ntwo"));
1369    }
1370
1371    #[test]
1372    fn indented_metadata_is_recognized() {
1373        let tf = parse("## a\n\n- steps:\n  Env: KEY=val\n\n```sh\ntrue\n```\n");
1374        assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
1375    }
1376
1377    #[test]
1378    fn duplicate_and_unknown_lang_warn() {
1379        let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1380        assert_eq!(tf.jobs.len(), 2);
1381        assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1382        assert!(tf.warnings.iter().any(|w| w.contains("json")));
1383    }
1384
1385    #[test]
1386    fn agent_jobs_filters_to_the_gated_ones() {
1387        let f = files(&[(
1388            "tasks.md",
1389            "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
1390        )]);
1391        let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
1392        assert_eq!(names, ["open"]);
1393    }
1394
1395    #[test]
1396    fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
1397        // The child redefines `deploy` WITHOUT the gate; the nearest definition
1398        // wins and it is not allowed, so `deploy` is not exposed (fail closed).
1399        let f = files(&[
1400            ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
1401            (
1402                "tasks.md",
1403                "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
1404            ),
1405        ]);
1406        assert!(agent_jobs(&f).is_empty());
1407    }
1408
1409    #[test]
1410    fn find_task_files_layers_child_over_parent() {
1411        // parent/tasks.md defines `base` + `shared`; parent/child/tasks.md
1412        // redefines `shared` + adds `only`. Nearest-first, so child wins.
1413        let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1414        let child = base.join("child");
1415        std::fs::create_dir_all(&child).unwrap();
1416        std::fs::write(
1417            base.join("tasks.md"),
1418            "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1419        )
1420        .unwrap();
1421        std::fs::write(
1422            child.join("tasks.md"),
1423            "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1424        )
1425        .unwrap();
1426
1427        let files = find_task_files(&child);
1428        assert_eq!(files.len(), 2, "child and parent files found");
1429        // Nearest first: child then parent.
1430        assert!(files[0].0.starts_with(&child));
1431        assert_eq!(
1432            files[0].1.job("shared").unwrap().script.trim(),
1433            "echo child"
1434        );
1435        // The parent still supplies `base` as an inherited baseline.
1436        assert!(files[1].1.job("base").is_some());
1437        std::fs::remove_dir_all(&base).ok();
1438    }
1439
1440    #[test]
1441    fn run_captured_returns_stdout() {
1442        let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
1443        let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
1444        assert!(out.status.success());
1445        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
1446    }
1447
1448    #[test]
1449    fn run_captured_runs_requires_deps_first() {
1450        let f = files(&[(
1451            "tasks.md",
1452            "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
1453        )]);
1454        let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
1455        let text = String::from_utf8_lossy(&out.stdout);
1456        // b runs before a (deps first).
1457        let bpos = text.find('B').expect("B in output");
1458        let apos = text.find('A').expect("A in output");
1459        assert!(bpos < apos, "deps must run first: {text}");
1460    }
1461
1462    #[test]
1463    fn run_reports_an_unknown_target_as_not_found() {
1464        let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
1465        match run_captured(&f, "ghost", &[], Path::new(".")) {
1466            Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
1467            other => panic!("expected NotFound, got {other:?}"),
1468        }
1469    }
1470
1471    // The two RCE regressions, exercised through the public agent gate.
1472
1473    #[test]
1474    fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
1475        // A nearer, untrusted `build` must NOT run when the allowed ancestor
1476        // `deploy` (which requires build) is invoked by name. The chain resolves
1477        // within deploy's own file, so the ancestor's real build runs, not PWNED.
1478        let f = files(&[
1479            ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
1480            (
1481                "tasks.md",
1482                "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
1483            ),
1484        ]);
1485        let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
1486        let text = String::from_utf8_lossy(&out.stdout);
1487        assert!(text.contains("real-build"), "got: {text}");
1488        assert!(text.contains("real-deploy"), "got: {text}");
1489        assert!(!text.contains("PWNED"), "nearer build ran: {text}");
1490        assert!(out.status.success());
1491    }
1492
1493    #[test]
1494    fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
1495        // greet interpolates {{ name }} raw into its script; an agent-supplied
1496        // value would be shell-injectable, so run_agent must refuse before running.
1497        let f = files(&[(
1498            "tasks.md",
1499            "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
1500        )]);
1501        match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
1502            Err(RunError::Injects { task, args }) => {
1503                assert_eq!(task, "greet");
1504                assert_eq!(args, vec!["name".to_string()]);
1505            }
1506            other => panic!("expected Injects, got {other:?}"),
1507        }
1508    }
1509
1510    #[test]
1511    fn run_agent_refuses_a_non_allowed_target() {
1512        let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
1513        match run_agent(&f, "secret", &[], Path::new(".")) {
1514            Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
1515            other => panic!("expected NotAllowed, got {other:?}"),
1516        }
1517    }
1518
1519    #[test]
1520    fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
1521        // The nearest `deploy` lacks the gate; it shadows the farther allowed one.
1522        let f = files(&[
1523            ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
1524            (
1525                "tasks.md",
1526                "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
1527            ),
1528        ]);
1529        match run_agent(&f, "deploy", &[], Path::new(".")) {
1530            Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
1531            other => panic!("expected NotAllowed, got {other:?}"),
1532        }
1533    }
1534    /// The bug this default exists to prevent: a shell runs the whole block as
1535    /// one script, so without `set -e` a failing early step is swallowed and the
1536    /// task exits with the status of the LAST command. A gate that cannot fail
1537    /// is worse than no gate, because it is trusted.
1538    #[test]
1539    fn a_failing_early_step_fails_the_job() {
1540        let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
1541        let out = run_captured(
1542            &[(PathBuf::from("tasks.md"), tf)],
1543            "check",
1544            &[],
1545            Path::new("."),
1546        )
1547        .expect("runs");
1548        assert!(
1549            !out.status.success(),
1550            "a job whose first command fails must not report success"
1551        );
1552    }
1553
1554    #[test]
1555    fn no_strict_restores_the_old_lenient_behavior() {
1556        let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
1557        let out = run_captured(
1558            &[(PathBuf::from("tasks.md"), tf)],
1559            "check",
1560            &[],
1561            Path::new("."),
1562        )
1563        .expect("runs");
1564        assert!(
1565            out.status.success(),
1566            "no-strict should exit with the last command's status"
1567        );
1568    }
1569
1570    #[test]
1571    fn a_passing_job_is_unaffected() {
1572        let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
1573        let out = run_captured(
1574            &[(PathBuf::from("tasks.md"), tf)],
1575            "ok",
1576            &[],
1577            Path::new("."),
1578        )
1579        .expect("runs");
1580        assert!(out.status.success());
1581        assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
1582    }
1583
1584    /// Existing task files already open with `set -euo pipefail` by hand, so the
1585    /// prelude has to be harmlessly redundant rather than conflicting.
1586    #[test]
1587    fn a_hand_written_prelude_still_works() {
1588        let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
1589        let out = run_captured(
1590            &[(PathBuf::from("tasks.md"), tf)],
1591            "ok",
1592            &[],
1593            Path::new("."),
1594        )
1595        .expect("runs");
1596        assert!(out.status.success());
1597    }
1598
1599    /// `pipefail` is not POSIX and dash rejects it outright, so plain `sh` must
1600    /// not receive it or every task breaks on a Debian-ish /bin/sh.
1601    #[test]
1602    fn plain_sh_does_not_get_pipefail() {
1603        assert_eq!(strict_prelude("sh"), Some("set -e"));
1604        assert_eq!(strict_prelude(""), Some("set -e"));
1605        assert!(strict_prelude("bash").unwrap().contains("pipefail"));
1606        assert!(strict_prelude("zsh").unwrap().contains("pipefail"));
1607    }
1608
1609    /// Injecting shell syntax into another language would be a syntax error, so
1610    /// non-shells are left alone.
1611    #[test]
1612    fn non_shells_get_no_prelude() {
1613        for lang in ["python", "ruby", "node", "fish"] {
1614            assert_eq!(
1615                strict_prelude(lang),
1616                None,
1617                "{lang} must not be given shell syntax"
1618            );
1619        }
1620    }
1621
1622    /// A python job still runs, which is the real check that the prelude is not
1623    /// being spliced into a language that cannot parse it.
1624    #[test]
1625    fn a_python_job_is_untouched() {
1626        let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
1627        let out = run_captured(
1628            &[(PathBuf::from("tasks.md"), tf)],
1629            "py",
1630            &[],
1631            Path::new("."),
1632        )
1633        .expect("runs");
1634        assert!(out.status.success());
1635        assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
1636    }
1637
1638    #[test]
1639    fn no_strict_is_a_known_opt_and_warns_no_one() {
1640        let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
1641        assert!(
1642            tf.warnings().is_empty(),
1643            "no-strict must be recognized: {:?}",
1644            tf.warnings()
1645        );
1646    }
1647}