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
119/// A runnable command built from a job: what to exec, with what environment, in
120/// which directory. Internal mechanics: the `run*` functions build it and spawn
121/// it, and no consumer ever sees the program, argv, or interpreter.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub(crate) struct Invocation {
124    /// The task this step runs, carried so a spawn failure can say which step
125    /// of a `Requires:` chain it was.
126    pub task: String,
127    pub program: String,
128    pub args: Vec<String>,
129    pub env: Vec<(String, String)>,
130    pub cwd: PathBuf,
131}
132
133/// A declared argument had no value supplied when binding a job's args.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct MissingArg(pub String);
136
137impl std::fmt::Display for MissingArg {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        write!(f, "missing value for argument `{}`", self.0)
140    }
141}
142impl std::error::Error for MissingArg {}
143
144/// A `Requires:` dependency chain could not be resolved.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum DepError {
147    /// A `Requires:` named a job that does not exist.
148    Missing { task: String, required_by: String },
149    /// A dependency cycle, reported at the job where the back edge closes.
150    Cycle(String),
151}
152
153impl std::fmt::Display for DepError {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            DepError::Missing { task, required_by } => {
157                write!(f, "task {required_by:?} requires unknown task {task:?}")
158            }
159            DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
160        }
161    }
162}
163impl std::error::Error for DepError {}
164
165/// Why a `run*` call could not complete. It reports the failure to resolve or
166/// dispatch a job; a job that runs to a non-zero exit is not an error here (the
167/// exit status rides back in the `Ok`). Only `Debug` is derived, because `Io`
168/// wraps a [`std::io::Error`], which is neither `Clone` nor `PartialEq`.
169#[derive(Debug)]
170pub enum RunError {
171    /// No job by that name across the resolved files (from [`run`]/[`run_captured`]).
172    NotFound(String),
173    /// The nearest definition of the named job is not `Agent: allow`, so an agent
174    /// surface may not run it (from [`run_agent`] only). A nearer non-allowed
175    /// definition shadowing a farther allowed one lands here too: fail closed.
176    NotAllowed(String),
177    /// The agent target raw-templates a declared arg into its script via
178    /// `{{ arg }}` (from [`run_agent`] only). `args` lists the offending names.
179    /// The job must read the value from the environment instead before an agent
180    /// may run it.
181    Injects { task: String, args: Vec<String> },
182    /// A required positional argument had no value.
183    MissingArg(MissingArg),
184    /// The `Requires:` chain could not be resolved (a typo or a cycle).
185    Dependency(DepError),
186    /// The run was stopped through a [`Cancel`](crate::Cancel) handle.
187    ///
188    /// Distinct from a failing step: nothing went wrong, someone asked for it to
189    /// stop. A caller that treats every non-success as an error would otherwise
190    /// report a cancellation as a task failure.
191    Cancelled,
192    /// Spawning a step failed: the interpreter is not installed, or the
193    /// directory the task would run in is gone.
194    ///
195    /// Carries which task and which program, because the bare `io::Error` was
196    /// "No such file or directory (os error 2)" and nothing else. In a
197    /// `Requires:` chain that named neither the failing step nor the thing that
198    /// was missing, and the obvious reading of it, that a file the *script*
199    /// wanted was absent, was the wrong one.
200    Io {
201        task: String,
202        program: String,
203        cwd: PathBuf,
204        source: std::io::Error,
205    },
206}
207
208impl std::fmt::Display for RunError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            RunError::NotFound(name) => write!(f, "no task named {name:?}"),
212            RunError::NotAllowed(name) => write!(
213                f,
214                "task {name:?} is not available to agents (it lacks `Agent: allow`)"
215            ),
216            RunError::Injects { task, args } => write!(
217                f,
218                "task {task:?} interpolates argument(s) [{}] into its script via {{{{ }}}} \
219                 (raw substitution, an injection risk with agent-supplied values); it must \
220                 read them from the environment instead (\"$arg\", os.environ[\"arg\"], ...) \
221                 before an agent can run it. Refused.",
222                args.join(", ")
223            ),
224            RunError::Cancelled => write!(f, "cancelled"),
225            RunError::MissingArg(e) => e.fmt(f),
226            RunError::Dependency(e) => e.fmt(f),
227            RunError::Io {
228                task,
229                program,
230                cwd,
231                source,
232            } => {
233                write!(f, "task {task:?}: could not run {program:?}")?;
234                if source.kind() == std::io::ErrorKind::NotFound {
235                    // Distinguish the two NotFound cases, which read identically
236                    // and have completely different fixes.
237                    return if cwd.is_dir() {
238                        write!(f, ": not installed, or not on PATH")
239                    } else {
240                        write!(f, " in {}: that directory does not exist", cwd.display())
241                    };
242                }
243                write!(f, " in {}: {source}", cwd.display())
244            }
245        }
246    }
247}
248impl std::error::Error for RunError {
249    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
250        match self {
251            RunError::MissingArg(e) => Some(e),
252            RunError::Dependency(e) => Some(e),
253            RunError::Io { source, .. } => Some(source),
254            _ => None,
255        }
256    }
257}
258
259/// The `Opts:` flags mdtask recognizes. An `Opts:` value outside this set is
260/// recorded as a warning and otherwise ignored, so a file written for a newer
261/// mdtask does not hard-fail on an older one.
262pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd", "no-strict"];
263
264/// `Opts:` flags that only mean something at file level, before the first task.
265pub(crate) const KNOWN_FILE_OPTS: &[&str] = &["include-parent"];
266
267impl Job {
268    /// The script body, verbatim: the contents of the first fenced block under
269    /// the heading, before any argument substitution.
270    ///
271    /// Public so a consumer can show a task before running it. Knowing what a
272    /// task will do should not require running it, and for a tool whose job is
273    /// executing shell that is the difference between a considered decision and
274    /// a leap of faith.
275    pub fn script(&self) -> &str {
276        &self.script
277    }
278
279    /// The fenced block's info string, which selects the interpreter. Empty
280    /// means an unlabeled fence, which runs as `sh`.
281    pub fn lang(&self) -> &str {
282        &self.lang
283    }
284
285    /// The task's `Opts:` flags, in the order declared.
286    pub fn opts(&self) -> &[String] {
287        &self.opts
288    }
289
290    /// The task's own `Env:` pairs. Does not include the file-level `Env:`
291    /// hoisted to every task, which belongs to the file, not the job.
292    pub fn env(&self) -> &[(String, String)] {
293        &self.env
294    }
295
296    /// Whether this job opted into `Opts: inherit-cwd`: run it in the invocation
297    /// directory rather than the default (the task file's own directory).
298    pub(crate) fn inherits_cwd(&self) -> bool {
299        self.opts.iter().any(|o| o == "inherit-cwd")
300    }
301
302    /// Whether shell strictness applies. On unless `Opts: no-strict`.
303    ///
304    /// Strict is the default because the failure modes are asymmetric. A strict
305    /// default fails loudly when an author did not expect it, and they add
306    /// `no-strict`. A lenient default fails SILENTLY: a shell runs the whole
307    /// fenced block as one script, so an early failure is swallowed and the task
308    /// exits with the status of the last command. That turns a multi-step gate
309    /// into one that cannot fail, and it will report success while `cargo fmt`
310    /// is failing inside it.
311    ///
312    /// The other evidence is that authors were already writing the prelude by
313    /// hand: every multi-step task in mdtask's own dogfood repos opened with
314    /// `set -euo pipefail`. When everyone writes the same first line, it belongs
315    /// in the tool.
316    pub(crate) fn is_strict(&self) -> bool {
317        !self.opts.iter().any(|o| o == "no-strict")
318    }
319
320    /// The declared argument names this job interpolates into its **script** via
321    /// `{{ arg }}` (raw text substitution, spliced in before the interpreter parses
322    /// the script). Because it is not quoted, each of these is an injection point
323    /// for an untrusted argument value, in any language, so [`run_agent`] refuses a
324    /// job that has any. Empty for a job that reads its args from the environment,
325    /// the safe form.
326    pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
327        let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
328        let mut found: Vec<&str> = Vec::new();
329        let mut rest = self.script.as_str();
330        while let Some(open) = rest.find("{{") {
331            let after = &rest[open + 2..];
332            let Some(close) = after.find("}}") else { break };
333            let tok = after[..close].trim();
334            if declared.contains(tok) && !found.contains(&tok) {
335                found.push(tok);
336            }
337            rest = &after[close + 2..];
338        }
339        found
340    }
341}
342
343impl TaskFile {
344    /// The jobs in this file, in document order.
345    pub fn jobs(&self) -> &[Job] {
346        &self.jobs
347    }
348
349    /// Whether this file declared `Opts: include-parent` before its first task
350    /// heading, asking [`find_task_files`] to keep walking up and layer the
351    /// parent's tasks underneath its own.
352    pub fn includes_parent(&self) -> bool {
353        self.opts.iter().any(|o| o == "include-parent")
354    }
355
356    /// Find a job by name. The match is exact and case-sensitive, against the
357    /// heading text as written. The first definition wins if a name is duplicated
358    /// (a warning is recorded).
359    pub fn job(&self, name: &str) -> Option<&Job> {
360        self.jobs.iter().find(|j| j.name == name)
361    }
362
363    /// Any parse warnings (an unterminated fence, a duplicate job, an unknown fence
364    /// language). Parsing is infallible, so surface these rather than trust silence.
365    pub fn warnings(&self) -> &[String] {
366        &self.warnings
367    }
368
369    /// Build the invocation for `job`, given `args` mapping each name to a value.
370    /// It substitutes `{{ arg }}` in the script, exports the args and env, and
371    /// resolves the working directory: by default the job runs in `job_file_dir`
372    /// (the directory of the file that defines it; `None` or empty falls back to
373    /// `cwd`), while `Opts: inherit-cwd` runs it in `cwd`. Missing optional and
374    /// variadic args are filled from their defaults; only a missing required arg is
375    /// an error.
376    pub(crate) fn invocation(
377        &self,
378        job: &Job,
379        args: &BTreeMap<String, String>,
380        cwd: &Path,
381        job_file_dir: Option<&Path>,
382    ) -> Result<Invocation, MissingArg> {
383        // Fill defaults for any declared arg the caller did not supply.
384        let mut effective = args.clone();
385        for a in &job.args {
386            if !effective.contains_key(&a.name) {
387                if a.variadic {
388                    effective.insert(a.name.clone(), String::new());
389                } else if let Some(d) = &a.default {
390                    effective.insert(a.name.clone(), d.clone());
391                } else {
392                    return Err(MissingArg(a.name.clone()));
393                }
394            }
395        }
396
397        let script = substitute(&job.script, &effective);
398        // An unrecognized language resolves to a *strict* sh, never a bare one:
399        // the bare fallback is what let a ```console block report success on a
400        // failing step. The parser has already warned that this is happening.
401        let lang = interpreter(&job.lang);
402        let (program, flag) = (lang.program, lang.flag);
403        let script = match lang.prelude {
404            Some(prelude) if job.is_strict() => format!("{prelude}\n{script}"),
405            _ => script,
406        };
407
408        // Env precedence: hoisted, then job, then args. Args win, being the most
409        // specific, so `$name` resolves to the passed value.
410        let mut env = self.env.clone();
411        env.extend(job.env.iter().cloned());
412        env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
413
414        // The job's own directory is the default anchor; `inherit-cwd` opts into
415        // the invocation directory. An absent or empty job_file_dir (a bare
416        // filename with no directory part) falls back to cwd, since running in an
417        // empty path would fail.
418        let run_cwd = match job_file_dir {
419            _ if job.inherits_cwd() => cwd.to_path_buf(),
420            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
421            _ => cwd.to_path_buf(),
422        };
423
424        Ok(Invocation {
425            task: job.name.clone(),
426            program: program.to_string(),
427            args: vec![flag.to_string(), script],
428            env,
429            cwd: run_cwd,
430        })
431    }
432
433    /// Bind positional argument values to a job's declared `Args:`, applying
434    /// defaults and collecting a trailing `*variadic` from the rest. This feeds
435    /// [`TaskFile::invocation`] and errors on a missing required arg.
436    pub(crate) fn bind(
437        job: &Job,
438        positional: &[String],
439    ) -> Result<BTreeMap<String, String>, MissingArg> {
440        let mut map = BTreeMap::new();
441        let mut i = 0;
442        for a in &job.args {
443            if a.variadic {
444                map.insert(
445                    a.name.clone(),
446                    positional[i.min(positional.len())..].join(" "),
447                );
448                i = positional.len();
449            } else if i < positional.len() {
450                map.insert(a.name.clone(), positional[i].clone());
451                i += 1;
452            } else if let Some(d) = &a.default {
453                map.insert(a.name.clone(), d.clone());
454            } else {
455                return Err(MissingArg(a.name.clone()));
456            }
457        }
458        Ok(map)
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn io_error(kind: std::io::ErrorKind, cwd: &str) -> RunError {
467        RunError::Io {
468            task: "deploy".into(),
469            program: "ruby".into(),
470            cwd: PathBuf::from(cwd),
471            source: std::io::Error::new(kind, "boom"),
472        }
473    }
474
475    /// The message was "No such file or directory (os error 2)" and nothing
476    /// else: in a `Requires:` chain it named neither the failing step nor the
477    /// thing that was missing, and read as though a file the *script* wanted was
478    /// absent, which is the wrong problem entirely.
479    #[test]
480    fn a_spawn_failure_says_which_task_and_which_program() {
481        let msg = io_error(std::io::ErrorKind::NotFound, ".").to_string();
482        assert!(msg.contains("deploy"), "{msg}");
483        assert!(msg.contains("ruby"), "{msg}");
484    }
485
486    /// Two NotFounds with the same words and completely different fixes: the
487    /// interpreter is missing, or the directory it would run in is. The current
488    /// directory exists, so the first reading is the right one.
489    #[test]
490    fn a_missing_interpreter_and_a_missing_directory_read_differently() {
491        let missing_program = io_error(std::io::ErrorKind::NotFound, ".").to_string();
492        assert!(missing_program.contains("not on PATH"), "{missing_program}");
493
494        let missing_dir =
495            io_error(std::io::ErrorKind::NotFound, "/no/such/place/at/all").to_string();
496        assert!(
497            missing_dir.contains("that directory does not exist"),
498            "{missing_dir}"
499        );
500        assert!(
501            missing_dir.contains("/no/such/place/at/all"),
502            "{missing_dir}"
503        );
504    }
505
506    #[test]
507    fn another_spawn_failure_still_reports_the_underlying_error() {
508        let msg = io_error(std::io::ErrorKind::PermissionDenied, ".").to_string();
509        assert!(msg.contains("deploy") && msg.contains("boom"), "{msg}");
510    }
511    use crate::parse::parse;
512
513    fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
514        pairs
515            .iter()
516            .map(|(k, v)| (k.to_string(), v.to_string()))
517            .collect()
518    }
519
520    #[test]
521    fn invocation_substitutes_sets_env_and_picks_interpreter() {
522        let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
523        let j = tf.job("greet").unwrap();
524        let inv = tf
525            .invocation(
526                j,
527                &args(&[("name", "sam")]),
528                Path::new("/here"),
529                Some(Path::new("/file")),
530            )
531            .unwrap();
532        assert_eq!(inv.program, "zsh");
533        assert_eq!(inv.args[0], "-c");
534        assert!(inv.args[1].contains("hi sam"));
535        assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
536        // By default it runs in the task file's directory, not where invoked.
537        assert_eq!(inv.cwd, Path::new("/file"));
538    }
539
540    #[test]
541    fn a_missing_required_arg_is_an_error() {
542        let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
543        let j = tf.job("t").unwrap();
544        assert_eq!(
545            tf.invocation(j, &args(&[]), Path::new("/here"), None),
546            Err(MissingArg("file".into()))
547        );
548    }
549
550    #[test]
551    fn optional_and_variadic_args_fill_from_defaults() {
552        let tf = parse(
553            "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
554        );
555        let j = tf.job("t").unwrap();
556        assert!(!j.args[0].variadic && j.args[0].default.is_none());
557        assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
558        assert!(j.args[2].variadic);
559        // Only `a` supplied: `b` uses its default, `rest` is empty.
560        let inv = tf
561            .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
562            .unwrap();
563        assert!(inv.args[1].contains("echo x fallback "));
564        // bind() collects a trailing variadic from the leftover positionals.
565        let bound =
566            TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
567        assert_eq!(bound.get("b").map(String::as_str), Some("y"));
568        assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
569    }
570
571    #[test]
572    fn default_cwd_is_the_task_file_dir() {
573        let tf = parse("## t\n\n```sh\ntrue\n```\n");
574        let j = tf.job("t").unwrap();
575        // Default: the file's directory, not where invoked.
576        let inv = tf
577            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
578            .unwrap();
579        assert_eq!(inv.cwd, Path::new("/proj"));
580        // With no job_file_dir known (headless), it falls back to cwd.
581        let inv = tf
582            .invocation(j, &args(&[]), Path::new("/here"), None)
583            .unwrap();
584        assert_eq!(inv.cwd, Path::new("/here"));
585        // An empty job_file_dir (a bare filename's parent) also falls back to cwd,
586        // since running in an empty path would fail.
587        let inv = tf
588            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
589            .unwrap();
590        assert_eq!(inv.cwd, Path::new("/here"));
591    }
592
593    #[test]
594    fn inherit_cwd_runs_in_the_invocation_dir() {
595        let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
596        let j = tf.job("t").unwrap();
597        assert!(j.inherits_cwd());
598        let inv = tf
599            .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
600            .unwrap();
601        assert_eq!(inv.cwd, Path::new("/here"));
602    }
603
604    #[test]
605    fn script_arg_templates_flags_only_declared_args_in_the_script() {
606        // `name` is interpolated raw via {{ name }} (injectable); `safe` uses $safe.
607        let tf =
608            parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
609        let j = tf.job("t").unwrap();
610        assert_eq!(j.script_arg_templates(), vec!["name"]);
611
612        // A job that only uses $arg has no raw template interpolation.
613        let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
614        assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
615    }
616}