Skip to main content

mdtask_core/
model.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::run::{interpreter, substitute};
5
6// Referenced only by the intra-doc links in this module's documentation, which
7// resolve in module scope.
8#[allow(unused_imports)]
9use crate::{
10    discover::find_task_files,
11    run::{agent_jobs, run, run_agent, run_captured},
12};
13
14/// A parsed task file: the jobs, any file-level environment hoisted to all of
15/// them (an `Env:` under a section heading applies to **every** job regardless of
16/// where in the document it appears; hoisting is not positional), and any parse
17/// warnings (an unterminated fence, a duplicate job, an unknown fence language).
18/// Parsing is infallible. A malformed file still yields what it can, so an
19/// embedder should surface `warnings()` rather than trust silence. The internal
20/// fields carry execution mechanics; a consumer reaches jobs through [`jobs`] and
21/// [`job`], and runs them through [`run`], [`run_captured`], or [`run_agent`].
22///
23/// [`jobs`]: TaskFile::jobs
24/// [`job`]: TaskFile::job
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct TaskFile {
27    pub(crate) env: Vec<(String, String)>,
28    pub(crate) jobs: Vec<Job>,
29    pub(crate) warnings: Vec<String>,
30    /// File-level `Opts:`, set before the first task heading. Currently just
31    /// `include-parent`; see [`find_task_files`].
32    pub(crate) opts: Vec<String>,
33}
34
35/// One job: a named script with its metadata. The script, its interpreter
36/// language, its `Opts:` flags, and its extra environment are internal mechanics;
37/// a consumer deals in the name, description, declared args, dependencies, and the
38/// agent gate, and runs the job through [`run`], [`run_captured`], or
39/// [`run_agent`].
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
41pub struct Job {
42    /// The heading text (the job name).
43    pub name: String,
44    /// Prose in the job body that is not a recognized `Key: value` line.
45    pub description: String,
46    /// `Args:` declares positional arguments in just's syntax. A bare `name` is
47    /// required, `name='default'` is optional, and a trailing `*name` is variadic
48    /// (it collects the rest, space-joined). Each one is substituted as
49    /// `{{ name }}` in the script and also exported as `$name`. Note that
50    /// **`{{ name }}` is raw text substitution**, spliced in before the interpreter
51    /// parses the script, so `{{ name }}` is NOT injection-safe for untrusted values
52    /// in any language. The safe form is to read the value from the environment,
53    /// never to template it: `"$name"` in a shell, `os.environ["name"]` in Python,
54    /// `process.env.name` in Node, and so on. Reserve `{{ }}` for developer-authored
55    /// templates. An agent-run job that raw-templates an arg is refused by
56    /// [`run_agent`].
57    pub args: Vec<Arg>,
58    /// `Requires:` names the jobs this one depends on. The `run*` entry points
59    /// resolve the transitive order (deps first, cycle and typo detected) and run
60    /// each in turn, stopping on the first non-success step.
61    pub requires: Vec<Requirement>,
62    /// `Agent: allow` opts a job in to being listed and run by an MCP or agent
63    /// surface. The flag alone enforces nothing: [`run_agent`] is the gate that
64    /// checks it (and [`agent_jobs`] the listing that filters on it), so a plain
65    /// [`run`] or [`run_captured`] ignores it. It stays public as advisory data an
66    /// embedder can read.
67    pub agent_allow: bool,
68    /// The fenced block's info-string language (`sh`, `zsh`, `python`, ...); empty
69    /// means an unlabeled fence (treated as `sh`).
70    pub(crate) lang: String,
71    /// The script (the fenced block's contents), verbatim.
72    pub(crate) script: String,
73    /// `Opts:` carries per-job boolean flags, space-separated. The only flag today
74    /// is `inherit-cwd`: run the job in the directory mdtask was invoked from,
75    /// rather than the default (the directory of the task file that defines it).
76    pub(crate) opts: Vec<String>,
77    /// `Env:` adds extra environment for this job.
78    pub(crate) env: Vec<(String, String)>,
79}
80
81/// One entry in a `Requires:` list: a job to run first, and the arguments to run
82/// it with.
83///
84/// Comma-separated, and an entry in parentheses carries arguments, borrowing
85/// just's `(dist module)` shape:
86///
87/// ```text
88/// Requires: lint, (dist bonus-die)
89/// Requires: (dist {{ module }})
90/// ```
91///
92/// A bare name takes no arguments, which is what every `Requires:` meant before
93/// this existed, so old files keep working.
94///
95/// `{{ name }}` inside an argument resolves against the arguments of the job
96/// that *declares* the requirement. Unlike `{{ }}` in a script this is not an
97/// injection risk: the value becomes an argument to the dependency, which binds
98/// it as an environment variable, and is never spliced into a script's source.
99/// A dependency that then templates it into its own script is refused by
100/// [`run_agent`] exactly as before.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Requirement {
103    /// The job to run first.
104    pub name: String,
105    /// Positional arguments for it, as written, before `{{ }}` resolution.
106    pub args: Vec<String>,
107}
108
109/// One declared positional argument.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Arg {
112    pub name: String,
113    /// `*name`: collects all remaining positionals, space-joined.
114    pub variadic: bool,
115    /// `name='default'`: optional, with this value when not supplied.
116    pub default: Option<String>,
117}
118
119impl Arg {
120    /// Whether this name can actually become the shell variable the script will
121    /// read.
122    ///
123    /// An argument is bound as an environment variable, so a name that is not a
124    /// valid identifier cannot ever work. Worth knowing at parse time rather
125    /// than at `unbound variable` time, because the shell reports the name the
126    /// script used, which is spelled correctly, and says nothing about the
127    /// declaration that is actually wrong.
128    pub fn is_valid_name(&self) -> bool {
129        let mut chars = self.name.chars();
130        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
131            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
132    }
133}
134
135/// A runnable command built from a job: what to exec, with what environment, in
136/// which directory. Internal mechanics: the `run*` functions build it and spawn
137/// it, and no consumer ever sees the program, argv, or interpreter.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub(crate) struct Invocation {
140    /// The task this step runs, carried so a spawn failure can say which step
141    /// of a `Requires:` chain it was.
142    pub task: String,
143    pub program: String,
144    pub args: Vec<String>,
145    pub env: Vec<(String, String)>,
146    pub cwd: PathBuf,
147}
148
149/// A declared argument had no value supplied when binding a job's args.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct MissingArg(pub String);
152
153impl std::fmt::Display for MissingArg {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        write!(f, "missing value for argument `{}`", self.0)
156    }
157}
158impl std::error::Error for MissingArg {}
159
160/// A `Requires:` dependency chain could not be resolved.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum DepError {
163    /// A `Requires:` named a job that does not exist.
164    Missing { task: String, required_by: String },
165    /// A dependency cycle, reported at the job where the back edge closes.
166    Cycle(String),
167}
168
169impl std::fmt::Display for DepError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            DepError::Missing { task, required_by } => {
173                write!(f, "task {required_by:?} requires unknown task {task:?}")
174            }
175            DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
176        }
177    }
178}
179impl std::error::Error for DepError {}
180
181/// Why a `run*` call could not complete. It reports the failure to resolve or
182/// dispatch a job; a job that runs to a non-zero exit is not an error here (the
183/// exit status rides back in the `Ok`). Only `Debug` is derived, because `Io`
184/// wraps a [`std::io::Error`], which is neither `Clone` nor `PartialEq`.
185#[derive(Debug)]
186/// Non-exhaustive on purpose. This release adds a variant, which is a breaking
187/// change only because it was not marked before; marking it now means the next
188/// error mdtask learns to report costs consumers a `_` arm they already have
189/// rather than a major bump.
190#[non_exhaustive]
191pub enum RunError {
192    /// No job by that name across the resolved files (from [`run`]/[`run_captured`]).
193    NotFound(String),
194    /// The nearest definition of the named job is not `Agent: allow`, so an agent
195    /// surface may not run it (from [`run_agent`] only). A nearer non-allowed
196    /// definition shadowing a farther allowed one lands here too: fail closed.
197    NotAllowed(String),
198    /// The agent target raw-templates a declared arg into its script via
199    /// `{{ arg }}` (from [`run_agent`] only). `args` lists the offending names.
200    /// The job must read the value from the environment instead before an agent
201    /// may run it.
202    Injects { task: String, args: Vec<String> },
203    /// A required positional argument had no value.
204    MissingArg(MissingArg),
205    /// A job declares an argument whose name cannot be a shell variable, so the
206    /// script could never read it. `args` lists the offending names.
207    ///
208    /// Refused rather than run, because running it reaches the script and fails
209    /// there as `unbound variable` naming the *correct* spelling used in the
210    /// script, which points away from the declaration that is wrong.
211    InvalidArgName { task: String, args: Vec<String> },
212    /// The `Requires:` chain could not be resolved (a typo or a cycle).
213    Dependency(DepError),
214    /// The run was stopped through a [`Cancel`](crate::Cancel) handle.
215    ///
216    /// Distinct from a failing step: nothing went wrong, someone asked for it to
217    /// stop. A caller that treats every non-success as an error would otherwise
218    /// report a cancellation as a task failure.
219    Cancelled,
220    /// Spawning a step failed: the interpreter is not installed, or the
221    /// directory the task would run in is gone.
222    ///
223    /// Carries which task and which program, because the bare `io::Error` was
224    /// "No such file or directory (os error 2)" and nothing else. In a
225    /// `Requires:` chain that named neither the failing step nor the thing that
226    /// was missing, and the obvious reading of it, that a file the *script*
227    /// wanted was absent, was the wrong one.
228    Io {
229        task: String,
230        program: String,
231        cwd: PathBuf,
232        source: std::io::Error,
233    },
234}
235
236impl std::fmt::Display for RunError {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        match self {
239            RunError::NotFound(name) => write!(f, "no task named {name:?}"),
240            RunError::NotAllowed(name) => write!(
241                f,
242                "task {name:?} is not available to agents (it lacks `Agent: allow`)"
243            ),
244            RunError::Injects { task, args } => write!(
245                f,
246                "task {task:?} interpolates argument(s) [{}] into its script via {{{{ }}}} \
247                 (raw substitution, an injection risk with agent-supplied values); it must \
248                 read them from the environment instead (\"$arg\", os.environ[\"arg\"], ...) \
249                 before an agent can run it. Refused.",
250                args.join(", ")
251            ),
252            RunError::Cancelled => write!(f, "cancelled"),
253            RunError::InvalidArgName { task, args } => write!(
254                f,
255                "task {task:?} declares argument(s) [{}] whose name(s) cannot be a shell \
256                 variable, so the script could never read them. `Args:` is whitespace-separated \
257                 (just's syntax), so a comma becomes part of the name: write `Args: a b`, not \
258                 `Args: a, b`. Refused.",
259                args.join(", ")
260            ),
261            RunError::MissingArg(e) => e.fmt(f),
262            RunError::Dependency(e) => e.fmt(f),
263            RunError::Io {
264                task,
265                program,
266                cwd,
267                source,
268            } => {
269                write!(f, "task {task:?}: could not run {program:?}")?;
270                if source.kind() == std::io::ErrorKind::NotFound {
271                    // Distinguish the two NotFound cases, which read identically
272                    // and have completely different fixes.
273                    return if cwd.is_dir() {
274                        write!(f, ": not installed, or not on PATH")
275                    } else {
276                        write!(f, " in {}: that directory does not exist", cwd.display())
277                    };
278                }
279                write!(f, " in {}: {source}", cwd.display())
280            }
281        }
282    }
283}
284impl std::error::Error for RunError {
285    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
286        match self {
287            RunError::MissingArg(e) => Some(e),
288            RunError::Dependency(e) => Some(e),
289            RunError::Io { source, .. } => Some(source),
290            _ => None,
291        }
292    }
293}
294
295/// The `Opts:` flags mdtask recognizes. An `Opts:` value outside this set is
296/// recorded as a warning and otherwise ignored, so a file written for a newer
297/// mdtask does not hard-fail on an older one.
298pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd", "no-strict"];
299
300/// `Opts:` flags that only mean something at file level, before the first task.
301pub(crate) const KNOWN_FILE_OPTS: &[&str] = &["include-parent"];
302
303impl Job {
304    /// The script body, verbatim: the contents of the first fenced block under
305    /// the heading, before any argument substitution.
306    ///
307    /// Public so a consumer can show a task before running it. Knowing what a
308    /// task will do should not require running it, and for a tool whose job is
309    /// executing shell that is the difference between a considered decision and
310    /// a leap of faith.
311    pub fn script(&self) -> &str {
312        &self.script
313    }
314
315    /// The fenced block's info string, which selects the interpreter. Empty
316    /// means an unlabeled fence, which runs as `sh`.
317    pub fn lang(&self) -> &str {
318        &self.lang
319    }
320
321    /// The task's `Opts:` flags, in the order declared.
322    pub fn opts(&self) -> &[String] {
323        &self.opts
324    }
325
326    /// The task's own `Env:` pairs. Does not include the file-level `Env:`
327    /// hoisted to every task, which belongs to the file, not the job.
328    pub fn env(&self) -> &[(String, String)] {
329        &self.env
330    }
331
332    /// Whether this job opted into `Opts: inherit-cwd`: run it in the invocation
333    /// directory rather than the default (the task file's own directory).
334    pub(crate) fn inherits_cwd(&self) -> bool {
335        self.opts.iter().any(|o| o == "inherit-cwd")
336    }
337
338    /// Whether shell strictness applies. On unless `Opts: no-strict`.
339    ///
340    /// Strict is the default because the failure modes are asymmetric. A strict
341    /// default fails loudly when an author did not expect it, and they add
342    /// `no-strict`. A lenient default fails SILENTLY: a shell runs the whole
343    /// fenced block as one script, so an early failure is swallowed and the task
344    /// exits with the status of the last command. That turns a multi-step gate
345    /// into one that cannot fail, and it will report success while `cargo fmt`
346    /// is failing inside it.
347    ///
348    /// The other evidence is that authors were already writing the prelude by
349    /// hand: every multi-step task in mdtask's own dogfood repos opened with
350    /// `set -euo pipefail`. When everyone writes the same first line, it belongs
351    /// in the tool.
352    pub(crate) fn is_strict(&self) -> bool {
353        !self.opts.iter().any(|o| o == "no-strict")
354    }
355
356    /// The declared argument names this job interpolates into its **script** via
357    /// `{{ arg }}` (raw text substitution, spliced in before the interpreter parses
358    /// the script). Because it is not quoted, each of these is an injection point
359    /// for an untrusted argument value, in any language, so [`run_agent`] refuses a
360    /// job that has any. Empty for a job that reads its args from the environment,
361    /// the safe form.
362    pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
363        let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
364        let mut found: Vec<&str> = Vec::new();
365        let mut rest = self.script.as_str();
366        while let Some(open) = rest.find("{{") {
367            let after = &rest[open + 2..];
368            let Some(close) = after.find("}}") else { break };
369            let tok = after[..close].trim();
370            if declared.contains(tok) && !found.contains(&tok) {
371                found.push(tok);
372            }
373            rest = &after[close + 2..];
374        }
375        found
376    }
377}
378
379impl TaskFile {
380    /// The jobs in this file, in document order.
381    pub fn jobs(&self) -> &[Job] {
382        &self.jobs
383    }
384
385    /// Whether this file declared `Opts: include-parent` before its first task
386    /// heading, asking [`find_task_files`] to keep walking up and layer the
387    /// parent's tasks underneath its own.
388    pub fn includes_parent(&self) -> bool {
389        self.opts.iter().any(|o| o == "include-parent")
390    }
391
392    /// Find a job by name. The match is exact and case-sensitive, against the
393    /// heading text as written. The first definition wins if a name is duplicated
394    /// (a warning is recorded).
395    pub fn job(&self, name: &str) -> Option<&Job> {
396        self.jobs.iter().find(|j| j.name == name)
397    }
398
399    /// Any parse warnings (an unterminated fence, a duplicate job, an unknown fence
400    /// language). Parsing is infallible, so surface these rather than trust silence.
401    pub fn warnings(&self) -> &[String] {
402        &self.warnings
403    }
404
405    /// Build the invocation for `job`, given `args` mapping each name to a value.
406    /// It substitutes `{{ arg }}` in the script, exports the args and env, and
407    /// resolves the working directory: by default the job runs in `job_file_dir`
408    /// (the directory of the file that defines it; `None` or empty falls back to
409    /// `cwd`), while `Opts: inherit-cwd` runs it in `cwd`. Missing optional and
410    /// variadic args are filled from their defaults; only a missing required arg is
411    /// an error.
412    pub(crate) fn invocation(
413        &self,
414        job: &Job,
415        args: &BTreeMap<String, String>,
416        cwd: &Path,
417        job_file_dir: Option<&Path>,
418    ) -> Result<Invocation, MissingArg> {
419        // Fill defaults for any declared arg the caller did not supply.
420        let mut effective = args.clone();
421        for a in &job.args {
422            if !effective.contains_key(&a.name) {
423                if a.variadic {
424                    effective.insert(a.name.clone(), String::new());
425                } else if let Some(d) = &a.default {
426                    effective.insert(a.name.clone(), d.clone());
427                } else {
428                    return Err(MissingArg(a.name.clone()));
429                }
430            }
431        }
432
433        let script = substitute(&job.script, &effective);
434        // An unrecognized language resolves to a *strict* sh, never a bare one:
435        // the bare fallback is what let a ```console block report success on a
436        // failing step. The parser has already warned that this is happening.
437        let lang = interpreter(&job.lang);
438        let (program, flag) = (lang.program, lang.flag);
439        let script = match lang.prelude {
440            Some(prelude) if job.is_strict() => format!("{prelude}\n{script}"),
441            _ => script,
442        };
443
444        // Env precedence: hoisted, then job, then args. Args win, being the most
445        // specific, so `$name` resolves to the passed value.
446        let mut env = self.env.clone();
447        env.extend(job.env.iter().cloned());
448        env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
449
450        // The job's own directory is the default anchor; `inherit-cwd` opts into
451        // the invocation directory. An absent or empty job_file_dir (a bare
452        // filename with no directory part) falls back to cwd, since running in an
453        // empty path would fail.
454        let run_cwd = match job_file_dir {
455            _ if job.inherits_cwd() => cwd.to_path_buf(),
456            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
457            _ => cwd.to_path_buf(),
458        };
459
460        Ok(Invocation {
461            task: job.name.clone(),
462            program: program.to_string(),
463            args: vec![flag.to_string(), script],
464            env,
465            cwd: run_cwd,
466        })
467    }
468
469    /// Bind positional argument values to a job's declared `Args:`, applying
470    /// defaults and collecting a trailing `*variadic` from the rest. This feeds
471    /// [`TaskFile::invocation`] and errors on a missing required arg.
472    pub(crate) fn bind(
473        job: &Job,
474        positional: &[String],
475    ) -> Result<BTreeMap<String, String>, MissingArg> {
476        let mut map = BTreeMap::new();
477        let mut i = 0;
478        for a in &job.args {
479            if a.variadic {
480                map.insert(
481                    a.name.clone(),
482                    positional[i.min(positional.len())..].join(" "),
483                );
484                i = positional.len();
485            } else if i < positional.len() {
486                map.insert(a.name.clone(), positional[i].clone());
487                i += 1;
488            } else if let Some(d) = &a.default {
489                map.insert(a.name.clone(), d.clone());
490            } else {
491                return Err(MissingArg(a.name.clone()));
492            }
493        }
494        Ok(map)
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn io_error(kind: std::io::ErrorKind, cwd: &str) -> RunError {
503        RunError::Io {
504            task: "deploy".into(),
505            program: "ruby".into(),
506            cwd: PathBuf::from(cwd),
507            source: std::io::Error::new(kind, "boom"),
508        }
509    }
510
511    /// The message was "No such file or directory (os error 2)" and nothing
512    /// else: in a `Requires:` chain it named neither the failing step nor the
513    /// thing that was missing, and read as though a file the *script* wanted was
514    /// absent, which is the wrong problem entirely.
515    #[test]
516    fn a_spawn_failure_says_which_task_and_which_program() {
517        let msg = io_error(std::io::ErrorKind::NotFound, ".").to_string();
518        assert!(msg.contains("deploy"), "{msg}");
519        assert!(msg.contains("ruby"), "{msg}");
520    }
521
522    /// Two NotFounds with the same words and completely different fixes: the
523    /// interpreter is missing, or the directory it would run in is. The current
524    /// directory exists, so the first reading is the right one.
525    #[test]
526    fn a_missing_interpreter_and_a_missing_directory_read_differently() {
527        let missing_program = io_error(std::io::ErrorKind::NotFound, ".").to_string();
528        assert!(missing_program.contains("not on PATH"), "{missing_program}");
529
530        let missing_dir =
531            io_error(std::io::ErrorKind::NotFound, "/no/such/place/at/all").to_string();
532        assert!(
533            missing_dir.contains("that directory does not exist"),
534            "{missing_dir}"
535        );
536        assert!(
537            missing_dir.contains("/no/such/place/at/all"),
538            "{missing_dir}"
539        );
540    }
541
542    #[test]
543    fn another_spawn_failure_still_reports_the_underlying_error() {
544        let msg = io_error(std::io::ErrorKind::PermissionDenied, ".").to_string();
545        assert!(msg.contains("deploy") && msg.contains("boom"), "{msg}");
546    }
547    use crate::parse::parse;
548
549    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
550        pairs
551            .iter()
552            .map(|(k, v)| (k.to_string(), v.to_string()))
553            .collect()
554    }
555
556    #[test]
557    fn invocation_substitutes_sets_env_and_picks_interpreter() {
558        let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
559        let j = tf.job("greet").unwrap();
560        let inv = tf
561            .invocation(
562                j,
563                &args(&[("name", "sam")]),
564                Path::new("/here"),
565                Some(Path::new("/file")),
566            )
567            .unwrap();
568        assert_eq!(inv.program, "zsh");
569        assert_eq!(inv.args[0], "-c");
570        assert!(inv.args[1].contains("hi sam"));
571        assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
572        // By default it runs in the task file's directory, not where invoked.
573        assert_eq!(inv.cwd, Path::new("/file"));
574    }
575
576    #[test]
577    fn a_missing_required_arg_is_an_error() {
578        let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
579        let j = tf.job("t").unwrap();
580        assert_eq!(
581            tf.invocation(j, &args(&[]), Path::new("/here"), None),
582            Err(MissingArg("file".into()))
583        );
584    }
585
586    #[test]
587    fn optional_and_variadic_args_fill_from_defaults() {
588        let tf = parse(
589            "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
590        );
591        let j = tf.job("t").unwrap();
592        assert!(!j.args[0].variadic && j.args[0].default.is_none());
593        assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
594        assert!(j.args[2].variadic);
595        // Only `a` supplied: `b` uses its default, `rest` is empty.
596        let inv = tf
597            .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
598            .unwrap();
599        assert!(inv.args[1].contains("echo x fallback "));
600        // bind() collects a trailing variadic from the leftover positionals.
601        let bound =
602            TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
603        assert_eq!(bound.get("b").map(String::as_str), Some("y"));
604        assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
605    }
606
607    #[test]
608    fn default_cwd_is_the_task_file_dir() {
609        let tf = parse("## t\n\n```sh\ntrue\n```\n");
610        let j = tf.job("t").unwrap();
611        // Default: the file's directory, not where invoked.
612        let inv = tf
613            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
614            .unwrap();
615        assert_eq!(inv.cwd, Path::new("/proj"));
616        // With no job_file_dir known (headless), it falls back to cwd.
617        let inv = tf
618            .invocation(j, &args(&[]), Path::new("/here"), None)
619            .unwrap();
620        assert_eq!(inv.cwd, Path::new("/here"));
621        // An empty job_file_dir (a bare filename's parent) also falls back to cwd,
622        // since running in an empty path would fail.
623        let inv = tf
624            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
625            .unwrap();
626        assert_eq!(inv.cwd, Path::new("/here"));
627    }
628
629    #[test]
630    fn inherit_cwd_runs_in_the_invocation_dir() {
631        let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
632        let j = tf.job("t").unwrap();
633        assert!(j.inherits_cwd());
634        let inv = tf
635            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
636            .unwrap();
637        assert_eq!(inv.cwd, Path::new("/here"));
638    }
639
640    #[test]
641    fn script_arg_templates_flags_only_declared_args_in_the_script() {
642        // `name` is interpolated raw via {{ name }} (injectable); `safe` uses $safe.
643        let tf =
644            parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
645        let j = tf.job("t").unwrap();
646        assert_eq!(j.script_arg_templates(), vec!["name"]);
647
648        // A job that only uses $arg has no raw template interpolation.
649        let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
650        assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
651    }
652}