1use std::collections::{BTreeMap, BTreeSet};
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct TaskFile {
49 pub(crate) env: Vec<(String, String)>,
50 pub(crate) jobs: Vec<Job>,
51 pub(crate) warnings: Vec<String>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct Job {
61 pub name: String,
63 pub description: String,
65 pub args: Vec<Arg>,
77 pub requires: Vec<String>,
81 pub agent_allow: bool,
87 pub(crate) lang: String,
90 pub(crate) script: String,
92 pub(crate) opts: Vec<String>,
96 pub(crate) env: Vec<(String, String)>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Arg {
103 pub name: String,
104 pub variadic: bool,
106 pub default: Option<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
114pub(crate) struct Invocation {
115 pub program: String,
116 pub args: Vec<String>,
117 pub env: Vec<(String, String)>,
118 pub cwd: PathBuf,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct MissingArg(pub String);
124
125impl std::fmt::Display for MissingArg {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 write!(f, "missing value for argument `{}`", self.0)
128 }
129}
130impl std::error::Error for MissingArg {}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum DepError {
135 Missing { task: String, required_by: String },
137 Cycle(String),
139}
140
141impl std::fmt::Display for DepError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 DepError::Missing { task, required_by } => {
145 write!(f, "task {required_by:?} requires unknown task {task:?}")
146 }
147 DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
148 }
149 }
150}
151impl std::error::Error for DepError {}
152
153#[derive(Debug)]
158pub enum RunError {
159 NotFound(String),
161 NotAllowed(String),
165 Injects { task: String, args: Vec<String> },
170 MissingArg(MissingArg),
172 Dependency(DepError),
174 Io(std::io::Error),
176}
177
178impl std::fmt::Display for RunError {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 RunError::NotFound(name) => write!(f, "no task named {name:?}"),
182 RunError::NotAllowed(name) => write!(
183 f,
184 "task {name:?} is not available to agents (it lacks `Agent: allow`)"
185 ),
186 RunError::Injects { task, args } => write!(
187 f,
188 "task {task:?} interpolates argument(s) [{}] into its script via {{{{ }}}} \
189 (raw substitution, an injection risk with agent-supplied values); it must \
190 read them from the environment instead (\"$arg\", os.environ[\"arg\"], ...) \
191 before an agent can run it. Refused.",
192 args.join(", ")
193 ),
194 RunError::MissingArg(e) => e.fmt(f),
195 RunError::Dependency(e) => e.fmt(f),
196 RunError::Io(e) => e.fmt(f),
197 }
198 }
199}
200impl std::error::Error for RunError {
201 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
202 match self {
203 RunError::MissingArg(e) => Some(e),
204 RunError::Dependency(e) => Some(e),
205 RunError::Io(e) => Some(e),
206 _ => None,
207 }
208 }
209}
210
211pub(crate) fn dependency_order(
221 target: &str,
222 requires_of: impl Fn(&str) -> Option<Vec<String>>,
223) -> Result<Vec<String>, DepError> {
224 struct Frame {
228 name: String,
229 deps: Vec<String>,
230 next: usize,
231 }
232
233 let mut order = Vec::new();
234 let mut done = BTreeSet::new();
235 let mut on_stack = BTreeSet::new();
236 let mut stack: Vec<Frame> = Vec::new();
237
238 let deps = requires_of(target).ok_or_else(|| DepError::Missing {
239 task: target.to_string(),
240 required_by: target.to_string(),
241 })?;
242 on_stack.insert(target.to_string());
243 stack.push(Frame {
244 name: target.to_string(),
245 deps,
246 next: 0,
247 });
248
249 loop {
250 let descend = {
253 let Some(frame) = stack.last_mut() else { break };
254 if frame.next < frame.deps.len() {
255 let dep = frame.deps[frame.next].clone();
256 frame.next += 1;
257 Some(dep)
258 } else {
259 None
260 }
261 };
262 match descend {
263 Some(dep) => {
264 if done.contains(&dep) {
265 continue; }
267 if on_stack.contains(&dep) {
268 return Err(DepError::Cycle(dep));
269 }
270 let required_by = stack.last().expect("a top frame exists").name.clone();
271 let deps = requires_of(&dep).ok_or(DepError::Missing {
272 task: dep.clone(),
273 required_by,
274 })?;
275 on_stack.insert(dep.clone());
276 stack.push(Frame {
277 name: dep,
278 deps,
279 next: 0,
280 });
281 }
282 None => {
283 let frame = stack.pop().expect("a top frame exists");
284 on_stack.remove(&frame.name);
285 done.insert(frame.name.clone());
286 order.push(frame.name);
287 }
288 }
289 }
290 Ok(order)
291}
292
293pub(crate) const KNOWN_OPTS: &[&str] = &["inherit-cwd", "no-strict"];
297
298impl Job {
299 pub(crate) fn inherits_cwd(&self) -> bool {
302 self.opts.iter().any(|o| o == "inherit-cwd")
303 }
304
305 pub(crate) fn is_strict(&self) -> bool {
320 !self.opts.iter().any(|o| o == "no-strict")
321 }
322
323 pub(crate) fn script_arg_templates(&self) -> Vec<&str> {
330 let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
331 let mut found: Vec<&str> = Vec::new();
332 let mut rest = self.script.as_str();
333 while let Some(open) = rest.find("{{") {
334 let after = &rest[open + 2..];
335 let Some(close) = after.find("}}") else { break };
336 let tok = after[..close].trim();
337 if declared.contains(tok) && !found.contains(&tok) {
338 found.push(tok);
339 }
340 rest = &after[close + 2..];
341 }
342 found
343 }
344}
345
346impl TaskFile {
347 pub fn jobs(&self) -> &[Job] {
349 &self.jobs
350 }
351
352 pub fn job(&self, name: &str) -> Option<&Job> {
356 self.jobs.iter().find(|j| j.name == name)
357 }
358
359 pub fn warnings(&self) -> &[String] {
362 &self.warnings
363 }
364
365 pub(crate) fn invocation(
373 &self,
374 job: &Job,
375 args: &BTreeMap<String, String>,
376 cwd: &Path,
377 job_file_dir: Option<&Path>,
378 ) -> Result<Invocation, MissingArg> {
379 let mut effective = args.clone();
381 for a in &job.args {
382 if !effective.contains_key(&a.name) {
383 if a.variadic {
384 effective.insert(a.name.clone(), String::new());
385 } else if let Some(d) = &a.default {
386 effective.insert(a.name.clone(), d.clone());
387 } else {
388 return Err(MissingArg(a.name.clone()));
389 }
390 }
391 }
392
393 let script = substitute(&job.script, &effective);
394 let (program, flag) = interpreter(&job.lang);
395 let script = match strict_prelude(&job.lang) {
396 Some(prelude) if job.is_strict() => format!("{prelude}\n{script}"),
397 _ => script,
398 };
399
400 let mut env = self.env.clone();
403 env.extend(job.env.iter().cloned());
404 env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
405
406 let run_cwd = match job_file_dir {
411 _ if job.inherits_cwd() => cwd.to_path_buf(),
412 Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
413 _ => cwd.to_path_buf(),
414 };
415
416 Ok(Invocation {
417 program: program.to_string(),
418 args: vec![flag.to_string(), script],
419 env,
420 cwd: run_cwd,
421 })
422 }
423
424 pub(crate) fn bind(
428 job: &Job,
429 positional: &[String],
430 ) -> Result<BTreeMap<String, String>, MissingArg> {
431 let mut map = BTreeMap::new();
432 let mut i = 0;
433 for a in &job.args {
434 if a.variadic {
435 map.insert(
436 a.name.clone(),
437 positional[i.min(positional.len())..].join(" "),
438 );
439 i = positional.len();
440 } else if i < positional.len() {
441 map.insert(a.name.clone(), positional[i].clone());
442 i += 1;
443 } else if let Some(d) = &a.default {
444 map.insert(a.name.clone(), d.clone());
445 } else {
446 return Err(MissingArg(a.name.clone()));
447 }
448 }
449 Ok(map)
450 }
451}
452
453pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
460 let mut seen = BTreeSet::new();
461 let mut out = Vec::new();
462 for (_, tf) in files {
463 for job in &tf.jobs {
464 if seen.insert(job.name.clone()) && job.agent_allow {
465 out.push(job);
466 }
467 }
468 }
469 out
470}
471
472fn trusted_lookup<'a>(
477 files: &'a [(PathBuf, TaskFile)],
478 name: &str,
479) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
480 files
481 .iter()
482 .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
483}
484
485fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<String>, RunError> {
488 if trusted_lookup(files, target).is_none() {
489 return Err(RunError::NotFound(target.to_string()));
490 }
491 dependency_order(target, |n| {
492 trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
493 })
494 .map_err(RunError::Dependency)
495}
496
497fn plan_invocations<'a>(
501 order: &[String],
502 target: &str,
503 args: &[String],
504 cwd: &Path,
505 lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
506) -> Result<Vec<Invocation>, RunError> {
507 let mut plan = Vec::with_capacity(order.len());
508 for step in order {
509 let (tf, job, dir) = lookup(step).expect("a resolved name still resolves");
510 let step_args: &[String] = if step == target { args } else { &[] };
511 let values = TaskFile::bind(job, step_args).map_err(RunError::MissingArg)?;
512 let inv = tf
513 .invocation(job, &values, cwd, dir)
514 .map_err(RunError::MissingArg)?;
515 plan.push(inv);
516 }
517 Ok(plan)
518}
519
520fn run_plan_captured(plan: &[Invocation]) -> Result<std::process::Output, RunError> {
525 let mut stdout = Vec::new();
526 let mut stderr = Vec::new();
527 let mut status = None;
528 for inv in plan {
529 let out = inv.run_captured().map_err(RunError::Io)?;
530 stdout.extend_from_slice(&out.stdout);
531 stderr.extend_from_slice(&out.stderr);
532 let failed = !out.status.success();
533 status = Some(out.status);
534 if failed {
535 break;
536 }
537 }
538 Ok(std::process::Output {
539 status: status.expect("the plan always contains the target"),
540 stdout,
541 stderr,
542 })
543}
544
545pub fn run(
553 files: &[(PathBuf, TaskFile)],
554 name: &str,
555 args: &[String],
556 cwd: &Path,
557) -> Result<std::process::ExitStatus, RunError> {
558 let order = trusted_order(files, name)?;
559 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
560 let mut last = None;
561 for inv in &plan {
562 let status = inv.run_inherit().map_err(RunError::Io)?;
563 if !status.success() {
564 return Ok(status);
565 }
566 last = Some(status);
567 }
568 Ok(last.expect("the plan always contains the target"))
569}
570
571pub fn run_captured(
577 files: &[(PathBuf, TaskFile)],
578 name: &str,
579 args: &[String],
580 cwd: &Path,
581) -> Result<std::process::Output, RunError> {
582 let order = trusted_order(files, name)?;
583 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
584 run_plan_captured(&plan)
585}
586
587pub fn run_agent(
608 files: &[(PathBuf, TaskFile)],
609 name: &str,
610 args: &[String],
611 cwd: &Path,
612) -> Result<std::process::Output, RunError> {
613 let mut target: Option<(&Path, &TaskFile, &Job)> = None;
616 for (p, tf) in files {
617 if let Some(job) = tf.job(name) {
618 if job.agent_allow {
619 target = Some((p.as_path(), tf, job));
620 }
621 break; }
623 }
624 let Some((target_path, target_tf, target_job)) = target else {
625 return Err(RunError::NotAllowed(name.to_string()));
626 };
627
628 let templated = target_job.script_arg_templates();
630 if !templated.is_empty() {
631 return Err(RunError::Injects {
632 task: name.to_string(),
633 args: templated.iter().map(|s| s.to_string()).collect(),
634 });
635 }
636
637 let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
640 .map_err(RunError::Dependency)?;
641 let dir = target_path.parent();
642 let plan = plan_invocations(&order, name, args, cwd, |n| {
643 target_tf.job(n).map(|j| (target_tf, j, dir))
644 })?;
645 run_plan_captured(&plan)
646}
647
648impl Invocation {
649 fn command(&self) -> std::process::Command {
651 let mut cmd = std::process::Command::new(&self.program);
652 cmd.args(&self.args)
653 .envs(self.env.iter().map(|(k, v)| (k, v)))
654 .current_dir(&self.cwd);
655 cmd
656 }
657
658 fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
660 self.command().status()
661 }
662
663 fn run_captured(&self) -> std::io::Result<std::process::Output> {
665 self.command().output()
666 }
667}
668
669fn interpreter(lang: &str) -> (&'static str, &'static str) {
672 match lang.trim().to_ascii_lowercase().as_str() {
673 "" | "sh" | "shell" => ("sh", "-c"),
674 "bash" => ("bash", "-c"),
675 "zsh" => ("zsh", "-c"),
676 "fish" => ("fish", "-c"),
677 "python" | "py" | "python3" => ("python3", "-c"),
678 "ruby" => ("ruby", "-e"),
679 "node" | "js" | "javascript" => ("node", "-e"),
680 _ => ("sh", "-c"),
681 }
682}
683
684fn strict_prelude(lang: &str) -> Option<&'static str> {
705 match lang.trim().to_ascii_lowercase().as_str() {
706 "" | "sh" | "shell" => Some("set -e"),
707 "bash" | "zsh" => Some("set -e\nset -o pipefail"),
708 _ => None,
709 }
710}
711
712fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
716 let mut out = String::with_capacity(src.len());
717 let mut rest = src;
718 while let Some(open) = rest.find("{{") {
719 out.push_str(&rest[..open]);
720 let after = &rest[open + 2..];
721 if let Some(close) = after.find("}}") {
722 let name = after[..close].trim();
723 match args.get(name) {
724 Some(v) => out.push_str(v),
725 None => {
726 out.push_str("{{");
728 out.push_str(&after[..close]);
729 out.push_str("}}");
730 }
731 }
732 rest = &after[close + 2..];
733 } else {
734 out.push_str("{{");
735 rest = after;
736 }
737 }
738 out.push_str(rest);
739 out
740}
741
742pub fn parse(src: &str) -> TaskFile {
748 let mut file = TaskFile::default();
749 let mut cur: Option<Job> = None;
750 let mut in_fence = false;
751 let mut fence_marker = "";
752 let mut have_script = false; let mut script = String::new();
754
755 for raw in src.split('\n') {
756 let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
758 if is_closing_fence(line, fence_marker) {
762 in_fence = false;
763 if let Some(t) = cur.as_mut()
764 && !have_script
765 {
766 t.script = std::mem::take(&mut script);
767 have_script = true;
768 }
769 script.clear();
770 } else if cur.is_some() && !have_script {
771 script.push_str(line);
772 script.push('\n');
773 }
774 continue;
775 }
776 if let Some(marker) = opening_fence(line) {
777 in_fence = true;
778 fence_marker = marker;
779 if let Some(t) = cur.as_mut()
780 && !have_script
781 {
782 t.lang = info_string(line, marker);
783 }
784 script.clear();
785 continue;
786 }
787 if let Some(name) = heading(line) {
788 finalize(cur.take(), &mut file);
789 cur = Some(Job {
790 name,
791 ..Job::default()
792 });
793 have_script = false;
794 continue;
795 }
796 apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
797 }
798 if in_fence {
801 if let Some(t) = cur.as_mut()
802 && !have_script
803 {
804 t.script = std::mem::take(&mut script);
805 }
806 let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
807 file.warnings
808 .push(format!("unterminated code fence in task {name:?}"));
809 }
810 finalize(cur.take(), &mut file);
811 file
812}
813
814fn finalize(job: Option<Job>, file: &mut TaskFile) {
818 let Some(mut t) = job else {
819 return;
820 };
821 if t.script.is_empty() {
822 file.env.append(&mut t.env); return;
824 }
825 t.description = t.description.trim().to_string();
826 if file.jobs.iter().any(|x| x.name == t.name) {
827 file.warnings.push(format!(
828 "duplicate task {:?}; the first defined wins",
829 t.name
830 ));
831 }
832 if !is_known_lang(&t.lang) {
833 file.warnings.push(format!(
834 "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
835 t.name, t.lang
836 ));
837 }
838 file.jobs.push(t);
839}
840
841fn is_known_lang(lang: &str) -> bool {
843 matches!(
844 lang.trim().to_ascii_lowercase().as_str(),
845 "" | "sh"
846 | "shell"
847 | "bash"
848 | "zsh"
849 | "fish"
850 | "python"
851 | "py"
852 | "python3"
853 | "ruby"
854 | "node"
855 | "js"
856 | "javascript"
857 )
858}
859
860fn opening_fence(line: &str) -> Option<&'static str> {
862 let t = line.trim_start();
863 if t.starts_with("```") {
864 Some("```")
865 } else if t.starts_with("~~~") {
866 Some("~~~")
867 } else {
868 None
869 }
870}
871
872fn is_closing_fence(line: &str, marker: &str) -> bool {
875 let ch = marker.as_bytes()[0];
876 let t = line.trim();
877 t.len() >= 3 && t.bytes().all(|b| b == ch)
878}
879
880pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
887 let mut found = Vec::new();
888 for dir in start.ancestors() {
889 for name in ["tasks.md", "maskfile.md", "README.md"] {
890 let path = dir.join(name);
891 if let Ok(src) = std::fs::read_to_string(&path) {
892 let tf = parse(&src);
893 if !tf.jobs.is_empty() {
894 found.push((path, tf));
895 break; }
897 }
898 }
899 }
900 found
901}
902
903fn info_string(line: &str, marker: &str) -> String {
905 line.trim_start()
906 .strip_prefix(marker)
907 .unwrap_or("")
908 .split_whitespace()
909 .next()
910 .unwrap_or("")
911 .to_string()
912}
913
914fn heading(line: &str) -> Option<String> {
916 let t = line.trim_start();
917 if !t.starts_with('#') {
918 return None;
919 }
920 let after = t.trim_start_matches('#');
921 if after == t || !after.starts_with(' ') {
923 return None;
924 }
925 Some(after.trim().to_string())
926}
927
928fn apply_line(
932 line: &str,
933 job: Option<&mut Job>,
934 file_env: &mut Vec<(String, String)>,
935 warnings: &mut Vec<String>,
936) {
937 if let Some((key, value)) = split_key(line) {
938 let value = value.trim();
939 match key.as_str() {
940 "env" | "environment" => {
941 let pairs = parse_env(value);
942 match job {
943 Some(t) => t.env.extend(pairs),
944 None => file_env.extend(pairs), }
946 return;
947 }
948 "opts" | "options" => {
949 if let Some(t) = job {
950 t.opts = value.split_whitespace().map(str::to_string).collect();
951 for flag in &t.opts {
952 if !KNOWN_OPTS.contains(&flag.as_str()) {
953 warnings.push(format!(
954 "unknown option {flag:?} in `Opts:` (known: {})",
955 KNOWN_OPTS.join(", ")
956 ));
957 }
958 }
959 }
960 return;
961 }
962 "args" | "arguments" => {
963 if let Some(t) = job {
964 t.args = parse_args(value);
965 }
966 return;
967 }
968 "requires" | "req" => {
969 if let Some(t) = job {
970 t.requires = value
971 .split(',')
972 .map(|s| s.trim().to_string())
973 .filter(|s| !s.is_empty())
974 .collect();
975 }
976 return;
977 }
978 "agent" => {
979 if let Some(t) = job {
980 t.agent_allow = value.eq_ignore_ascii_case("allow");
981 }
982 return;
983 }
984 _ => {}
985 }
986 }
987 if let Some(t) = job
989 && !line.trim().is_empty()
990 {
991 t.description.push_str(line.trim());
992 t.description.push('\n');
993 }
994}
995
996fn split_key(line: &str) -> Option<(String, &str)> {
1001 let colon = line.find(':')?;
1002 let key = line[..colon].trim();
1003 if key.is_empty() || key.contains(char::is_whitespace) {
1004 return None;
1005 }
1006 Some((key.to_ascii_lowercase(), &line[colon + 1..]))
1007}
1008
1009fn parse_env(value: &str) -> Vec<(String, String)> {
1011 value
1012 .split(',')
1013 .filter_map(|p| {
1014 let (k, v) = p.split_once('=')?;
1015 let k = k.trim();
1016 if k.is_empty() {
1017 return None;
1018 }
1019 Some((k.to_string(), v.trim().to_string()))
1020 })
1021 .collect()
1022}
1023
1024fn parse_args(value: &str) -> Vec<Arg> {
1029 tokenize_args(value)
1030 .into_iter()
1031 .filter_map(|tok| {
1032 let (name, default) = match tok.split_once('=') {
1033 Some((n, d)) => (n, Some(unquote(d).to_string())),
1034 None => (tok.as_str(), None),
1035 };
1036 let (name, variadic) = match name.strip_prefix('*') {
1037 Some(rest) => (rest, true),
1038 None => (name, false),
1039 };
1040 let name = name.trim();
1041 if name.is_empty() {
1042 return None;
1043 }
1044 Some(Arg {
1045 name: name.to_string(),
1046 variadic,
1047 default,
1048 })
1049 })
1050 .collect()
1051}
1052
1053fn tokenize_args(value: &str) -> Vec<String> {
1056 let mut out = Vec::new();
1057 let mut cur = String::new();
1058 let mut quote: Option<char> = None;
1059 for c in value.chars() {
1060 match quote {
1061 Some(q) => {
1062 cur.push(c);
1063 if c == q {
1064 quote = None;
1065 }
1066 }
1067 None if c == '\'' || c == '"' => {
1068 cur.push(c);
1069 quote = Some(c);
1070 }
1071 None if c.is_whitespace() => {
1072 if !cur.is_empty() {
1073 out.push(std::mem::take(&mut cur));
1074 }
1075 }
1076 None => cur.push(c),
1077 }
1078 }
1079 if !cur.is_empty() {
1080 out.push(cur);
1081 }
1082 out
1083}
1084
1085fn unquote(s: &str) -> &str {
1087 let s = s.trim();
1088 let b = s.as_bytes();
1089 if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
1090 &s[1..s.len() - 1]
1091 } else {
1092 s
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099
1100 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1101 pairs
1102 .iter()
1103 .map(|(k, v)| (k.to_string(), v.to_string()))
1104 .collect()
1105 }
1106
1107 fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
1108 pairs
1109 .iter()
1110 .map(|(path, src)| (PathBuf::from(path), parse(src)))
1111 .collect()
1112 }
1113
1114 #[test]
1115 fn parses_named_jobs_with_interpreter() {
1116 let tf =
1117 parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
1118 assert_eq!(tf.jobs.len(), 2);
1119 assert_eq!(tf.jobs[0].name, "build");
1120 assert_eq!(tf.jobs[0].lang, "sh");
1121 assert_eq!(tf.jobs[0].script.trim(), "cargo build");
1122 assert_eq!(tf.jobs[1].lang, "zsh");
1123 }
1124
1125 #[test]
1126 fn metadata_keys_are_case_insensitive() {
1127 let tf = parse(
1128 "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
1129 );
1130 let t = &tf.jobs[0];
1131 assert_eq!(t.opts, vec!["inherit-cwd"]);
1132 assert!(t.inherits_cwd());
1133 assert_eq!(
1134 t.env,
1135 vec![
1136 ("REGION".into(), "us".into()),
1137 ("TIER".into(), "prod".into())
1138 ]
1139 );
1140 assert_eq!(
1141 t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
1142 ["target"]
1143 );
1144 assert_eq!(t.requires, vec!["build", "test"]);
1145 assert!(t.agent_allow);
1146 }
1147
1148 #[test]
1149 fn agent_gate_is_off_by_default() {
1150 let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
1151 assert!(!tf.jobs[0].agent_allow);
1152 }
1153
1154 #[test]
1155 fn top_level_env_is_hoisted() {
1156 let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
1157 assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
1158 }
1159
1160 #[test]
1161 fn fence_content_is_not_parsed_as_structure() {
1162 let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
1164 assert_eq!(tf.jobs.len(), 1);
1165 assert!(tf.jobs[0].script.contains("## not a task"));
1166 assert!(tf.jobs[0].env.is_empty());
1167 }
1168
1169 #[test]
1170 fn substitutes_args_and_leaves_unknown_tokens() {
1171 let out = substitute(
1172 "hello {{ name }} and {{ other }}",
1173 &args(&[("name", "world")]),
1174 );
1175 assert_eq!(out, "hello world and {{ other }}");
1176 }
1177
1178 #[test]
1179 fn invocation_substitutes_sets_env_and_picks_interpreter() {
1180 let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
1181 let j = tf.job("greet").unwrap();
1182 let inv = tf
1183 .invocation(
1184 j,
1185 &args(&[("name", "sam")]),
1186 Path::new("/here"),
1187 Some(Path::new("/file")),
1188 )
1189 .unwrap();
1190 assert_eq!(inv.program, "zsh");
1191 assert_eq!(inv.args[0], "-c");
1192 assert!(inv.args[1].contains("hi sam"));
1193 assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
1194 assert_eq!(inv.cwd, Path::new("/file"));
1196 }
1197
1198 #[test]
1199 fn a_missing_required_arg_is_an_error() {
1200 let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
1201 let j = tf.job("t").unwrap();
1202 assert_eq!(
1203 tf.invocation(j, &args(&[]), Path::new("/here"), None),
1204 Err(MissingArg("file".into()))
1205 );
1206 }
1207
1208 #[test]
1209 fn optional_and_variadic_args_fill_from_defaults() {
1210 let tf = parse(
1211 "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
1212 );
1213 let j = tf.job("t").unwrap();
1214 assert!(!j.args[0].variadic && j.args[0].default.is_none());
1215 assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
1216 assert!(j.args[2].variadic);
1217 let inv = tf
1219 .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
1220 .unwrap();
1221 assert!(inv.args[1].contains("echo x fallback "));
1222 let bound =
1224 TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
1225 assert_eq!(bound.get("b").map(String::as_str), Some("y"));
1226 assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
1227 }
1228
1229 #[test]
1230 fn default_cwd_is_the_task_file_dir() {
1231 let tf = parse("## t\n\n```sh\ntrue\n```\n");
1232 let j = tf.job("t").unwrap();
1233 let inv = tf
1235 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1236 .unwrap();
1237 assert_eq!(inv.cwd, Path::new("/proj"));
1238 let inv = tf
1240 .invocation(j, &args(&[]), Path::new("/here"), None)
1241 .unwrap();
1242 assert_eq!(inv.cwd, Path::new("/here"));
1243 let inv = tf
1246 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
1247 .unwrap();
1248 assert_eq!(inv.cwd, Path::new("/here"));
1249 }
1250
1251 #[test]
1252 fn inherit_cwd_runs_in_the_invocation_dir() {
1253 let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
1254 let j = tf.job("t").unwrap();
1255 assert!(j.inherits_cwd());
1256 let inv = tf
1257 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1258 .unwrap();
1259 assert_eq!(inv.cwd, Path::new("/here"));
1260 }
1261
1262 #[test]
1263 fn an_unknown_opt_warns_but_is_ignored() {
1264 let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
1265 assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
1266 assert!(tf.jobs[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
1268 }
1269
1270 fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
1272 move |name| {
1273 map.iter()
1274 .find(|(n, _)| *n == name)
1275 .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
1276 }
1277 }
1278
1279 #[test]
1280 fn dependency_order_is_deps_first_target_last() {
1281 let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
1283 assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
1284 }
1285
1286 #[test]
1287 fn dependency_order_dedupes_a_diamond() {
1288 let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
1289 let order = dependency_order("a", g).unwrap();
1290 assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
1291 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
1293 assert!(pos("d") < pos("b") && pos("d") < pos("c"));
1294 assert_eq!(order.last().unwrap(), "a");
1295 }
1296
1297 #[test]
1298 fn dependency_order_detects_a_cycle() {
1299 let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
1300 assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
1301 }
1302
1303 #[test]
1304 fn dependency_order_flags_a_missing_dependency() {
1305 let g = deps_of(&[("a", &["ghost"])]);
1306 assert_eq!(
1307 dependency_order("a", g),
1308 Err(DepError::Missing {
1309 task: "ghost".into(),
1310 required_by: "a".into(),
1311 })
1312 );
1313 }
1314
1315 #[test]
1316 fn dependency_order_survives_a_pathologically_deep_chain() {
1317 const N: usize = 200_000;
1320 let order = dependency_order("t0", |n| {
1321 let i: usize = n.strip_prefix('t')?.parse().ok()?;
1322 Some(if i + 1 < N {
1323 vec![format!("t{}", i + 1)]
1324 } else {
1325 vec![]
1326 })
1327 })
1328 .unwrap();
1329 assert_eq!(order.len(), N);
1330 assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); assert_eq!(order.last().unwrap(), "t0"); }
1333
1334 #[test]
1335 fn script_arg_templates_flags_only_declared_args_in_the_script() {
1336 let tf =
1338 parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1339 let j = tf.job("t").unwrap();
1340 assert_eq!(j.script_arg_templates(), vec!["name"]);
1341
1342 let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1344 assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
1345 }
1346
1347 #[test]
1348 fn crlf_scripts_are_normalized() {
1349 let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1350 assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
1351 assert!(!tf.jobs[0].script.contains('\r'));
1352 }
1353
1354 #[test]
1355 fn an_unterminated_fence_warns_but_keeps_the_job() {
1356 let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.jobs.len(), 1);
1358 assert_eq!(tf.jobs[0].script.trim(), "echo hi");
1359 assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1360 }
1361
1362 #[test]
1363 fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1364 let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1367 assert!(tf.jobs[0].script.contains("one"));
1368 assert!(tf.jobs[0].script.contains("```sh\ntwo"));
1369 }
1370
1371 #[test]
1372 fn indented_metadata_is_recognized() {
1373 let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
1374 assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
1375 }
1376
1377 #[test]
1378 fn duplicate_and_unknown_lang_warn() {
1379 let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1380 assert_eq!(tf.jobs.len(), 2);
1381 assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1382 assert!(tf.warnings.iter().any(|w| w.contains("json")));
1383 }
1384
1385 #[test]
1386 fn agent_jobs_filters_to_the_gated_ones() {
1387 let f = files(&[(
1388 "tasks.md",
1389 "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
1390 )]);
1391 let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
1392 assert_eq!(names, ["open"]);
1393 }
1394
1395 #[test]
1396 fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
1397 let f = files(&[
1400 ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
1401 (
1402 "tasks.md",
1403 "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
1404 ),
1405 ]);
1406 assert!(agent_jobs(&f).is_empty());
1407 }
1408
1409 #[test]
1410 fn find_task_files_layers_child_over_parent() {
1411 let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1414 let child = base.join("child");
1415 std::fs::create_dir_all(&child).unwrap();
1416 std::fs::write(
1417 base.join("tasks.md"),
1418 "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1419 )
1420 .unwrap();
1421 std::fs::write(
1422 child.join("tasks.md"),
1423 "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1424 )
1425 .unwrap();
1426
1427 let files = find_task_files(&child);
1428 assert_eq!(files.len(), 2, "child and parent files found");
1429 assert!(files[0].0.starts_with(&child));
1431 assert_eq!(
1432 files[0].1.job("shared").unwrap().script.trim(),
1433 "echo child"
1434 );
1435 assert!(files[1].1.job("base").is_some());
1437 std::fs::remove_dir_all(&base).ok();
1438 }
1439
1440 #[test]
1441 fn run_captured_returns_stdout() {
1442 let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
1443 let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
1444 assert!(out.status.success());
1445 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
1446 }
1447
1448 #[test]
1449 fn run_captured_runs_requires_deps_first() {
1450 let f = files(&[(
1451 "tasks.md",
1452 "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
1453 )]);
1454 let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
1455 let text = String::from_utf8_lossy(&out.stdout);
1456 let bpos = text.find('B').expect("B in output");
1458 let apos = text.find('A').expect("A in output");
1459 assert!(bpos < apos, "deps must run first: {text}");
1460 }
1461
1462 #[test]
1463 fn run_reports_an_unknown_target_as_not_found() {
1464 let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
1465 match run_captured(&f, "ghost", &[], Path::new(".")) {
1466 Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
1467 other => panic!("expected NotFound, got {other:?}"),
1468 }
1469 }
1470
1471 #[test]
1474 fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
1475 let f = files(&[
1479 ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
1480 (
1481 "tasks.md",
1482 "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
1483 ),
1484 ]);
1485 let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
1486 let text = String::from_utf8_lossy(&out.stdout);
1487 assert!(text.contains("real-build"), "got: {text}");
1488 assert!(text.contains("real-deploy"), "got: {text}");
1489 assert!(!text.contains("PWNED"), "nearer build ran: {text}");
1490 assert!(out.status.success());
1491 }
1492
1493 #[test]
1494 fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
1495 let f = files(&[(
1498 "tasks.md",
1499 "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
1500 )]);
1501 match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
1502 Err(RunError::Injects { task, args }) => {
1503 assert_eq!(task, "greet");
1504 assert_eq!(args, vec!["name".to_string()]);
1505 }
1506 other => panic!("expected Injects, got {other:?}"),
1507 }
1508 }
1509
1510 #[test]
1511 fn run_agent_refuses_a_non_allowed_target() {
1512 let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
1513 match run_agent(&f, "secret", &[], Path::new(".")) {
1514 Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
1515 other => panic!("expected NotAllowed, got {other:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
1521 let f = files(&[
1523 ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
1524 (
1525 "tasks.md",
1526 "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
1527 ),
1528 ]);
1529 match run_agent(&f, "deploy", &[], Path::new(".")) {
1530 Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
1531 other => panic!("expected NotAllowed, got {other:?}"),
1532 }
1533 }
1534 #[test]
1539 fn a_failing_early_step_fails_the_job() {
1540 let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
1541 let out = run_captured(
1542 &[(PathBuf::from("tasks.md"), tf)],
1543 "check",
1544 &[],
1545 Path::new("."),
1546 )
1547 .expect("runs");
1548 assert!(
1549 !out.status.success(),
1550 "a job whose first command fails must not report success"
1551 );
1552 }
1553
1554 #[test]
1555 fn no_strict_restores_the_old_lenient_behavior() {
1556 let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
1557 let out = run_captured(
1558 &[(PathBuf::from("tasks.md"), tf)],
1559 "check",
1560 &[],
1561 Path::new("."),
1562 )
1563 .expect("runs");
1564 assert!(
1565 out.status.success(),
1566 "no-strict should exit with the last command's status"
1567 );
1568 }
1569
1570 #[test]
1571 fn a_passing_job_is_unaffected() {
1572 let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
1573 let out = run_captured(
1574 &[(PathBuf::from("tasks.md"), tf)],
1575 "ok",
1576 &[],
1577 Path::new("."),
1578 )
1579 .expect("runs");
1580 assert!(out.status.success());
1581 assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
1582 }
1583
1584 #[test]
1587 fn a_hand_written_prelude_still_works() {
1588 let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
1589 let out = run_captured(
1590 &[(PathBuf::from("tasks.md"), tf)],
1591 "ok",
1592 &[],
1593 Path::new("."),
1594 )
1595 .expect("runs");
1596 assert!(out.status.success());
1597 }
1598
1599 #[test]
1602 fn plain_sh_does_not_get_pipefail() {
1603 assert_eq!(strict_prelude("sh"), Some("set -e"));
1604 assert_eq!(strict_prelude(""), Some("set -e"));
1605 assert!(strict_prelude("bash").unwrap().contains("pipefail"));
1606 assert!(strict_prelude("zsh").unwrap().contains("pipefail"));
1607 }
1608
1609 #[test]
1612 fn non_shells_get_no_prelude() {
1613 for lang in ["python", "ruby", "node", "fish"] {
1614 assert_eq!(
1615 strict_prelude(lang),
1616 None,
1617 "{lang} must not be given shell syntax"
1618 );
1619 }
1620 }
1621
1622 #[test]
1625 fn a_python_job_is_untouched() {
1626 let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
1627 let out = run_captured(
1628 &[(PathBuf::from("tasks.md"), tf)],
1629 "py",
1630 &[],
1631 Path::new("."),
1632 )
1633 .expect("runs");
1634 assert!(out.status.success());
1635 assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
1636 }
1637
1638 #[test]
1639 fn no_strict_is_a_known_opt_and_warns_no_one() {
1640 let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
1641 assert!(
1642 tf.warnings().is_empty(),
1643 "no-strict must be recognized: {:?}",
1644 tf.warnings()
1645 );
1646 }
1647}