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}