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}