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"];
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 script_arg_templates(&self) -> Vec<&str> {
312 let declared: BTreeSet<&str> = self.args.iter().map(|a| a.name.as_str()).collect();
313 let mut found: Vec<&str> = Vec::new();
314 let mut rest = self.script.as_str();
315 while let Some(open) = rest.find("{{") {
316 let after = &rest[open + 2..];
317 let Some(close) = after.find("}}") else { break };
318 let tok = after[..close].trim();
319 if declared.contains(tok) && !found.contains(&tok) {
320 found.push(tok);
321 }
322 rest = &after[close + 2..];
323 }
324 found
325 }
326}
327
328impl TaskFile {
329 pub fn jobs(&self) -> &[Job] {
331 &self.jobs
332 }
333
334 pub fn job(&self, name: &str) -> Option<&Job> {
338 self.jobs.iter().find(|j| j.name == name)
339 }
340
341 pub fn warnings(&self) -> &[String] {
344 &self.warnings
345 }
346
347 pub(crate) fn invocation(
355 &self,
356 job: &Job,
357 args: &BTreeMap<String, String>,
358 cwd: &Path,
359 job_file_dir: Option<&Path>,
360 ) -> Result<Invocation, MissingArg> {
361 let mut effective = args.clone();
363 for a in &job.args {
364 if !effective.contains_key(&a.name) {
365 if a.variadic {
366 effective.insert(a.name.clone(), String::new());
367 } else if let Some(d) = &a.default {
368 effective.insert(a.name.clone(), d.clone());
369 } else {
370 return Err(MissingArg(a.name.clone()));
371 }
372 }
373 }
374
375 let script = substitute(&job.script, &effective);
376 let (program, flag) = interpreter(&job.lang);
377
378 let mut env = self.env.clone();
381 env.extend(job.env.iter().cloned());
382 env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
383
384 let run_cwd = match job_file_dir {
389 _ if job.inherits_cwd() => cwd.to_path_buf(),
390 Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
391 _ => cwd.to_path_buf(),
392 };
393
394 Ok(Invocation {
395 program: program.to_string(),
396 args: vec![flag.to_string(), script],
397 env,
398 cwd: run_cwd,
399 })
400 }
401
402 pub(crate) fn bind(
406 job: &Job,
407 positional: &[String],
408 ) -> Result<BTreeMap<String, String>, MissingArg> {
409 let mut map = BTreeMap::new();
410 let mut i = 0;
411 for a in &job.args {
412 if a.variadic {
413 map.insert(
414 a.name.clone(),
415 positional[i.min(positional.len())..].join(" "),
416 );
417 i = positional.len();
418 } else if i < positional.len() {
419 map.insert(a.name.clone(), positional[i].clone());
420 i += 1;
421 } else if let Some(d) = &a.default {
422 map.insert(a.name.clone(), d.clone());
423 } else {
424 return Err(MissingArg(a.name.clone()));
425 }
426 }
427 Ok(map)
428 }
429}
430
431pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
438 let mut seen = BTreeSet::new();
439 let mut out = Vec::new();
440 for (_, tf) in files {
441 for job in &tf.jobs {
442 if seen.insert(job.name.clone()) && job.agent_allow {
443 out.push(job);
444 }
445 }
446 }
447 out
448}
449
450fn trusted_lookup<'a>(
455 files: &'a [(PathBuf, TaskFile)],
456 name: &str,
457) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
458 files
459 .iter()
460 .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
461}
462
463fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<String>, RunError> {
466 if trusted_lookup(files, target).is_none() {
467 return Err(RunError::NotFound(target.to_string()));
468 }
469 dependency_order(target, |n| {
470 trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
471 })
472 .map_err(RunError::Dependency)
473}
474
475fn plan_invocations<'a>(
479 order: &[String],
480 target: &str,
481 args: &[String],
482 cwd: &Path,
483 lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
484) -> Result<Vec<Invocation>, RunError> {
485 let mut plan = Vec::with_capacity(order.len());
486 for step in order {
487 let (tf, job, dir) = lookup(step).expect("a resolved name still resolves");
488 let step_args: &[String] = if step == target { args } else { &[] };
489 let values = TaskFile::bind(job, step_args).map_err(RunError::MissingArg)?;
490 let inv = tf
491 .invocation(job, &values, cwd, dir)
492 .map_err(RunError::MissingArg)?;
493 plan.push(inv);
494 }
495 Ok(plan)
496}
497
498fn run_plan_captured(plan: &[Invocation]) -> Result<std::process::Output, RunError> {
503 let mut stdout = Vec::new();
504 let mut stderr = Vec::new();
505 let mut status = None;
506 for inv in plan {
507 let out = inv.run_captured().map_err(RunError::Io)?;
508 stdout.extend_from_slice(&out.stdout);
509 stderr.extend_from_slice(&out.stderr);
510 let failed = !out.status.success();
511 status = Some(out.status);
512 if failed {
513 break;
514 }
515 }
516 Ok(std::process::Output {
517 status: status.expect("the plan always contains the target"),
518 stdout,
519 stderr,
520 })
521}
522
523pub fn run(
531 files: &[(PathBuf, TaskFile)],
532 name: &str,
533 args: &[String],
534 cwd: &Path,
535) -> Result<std::process::ExitStatus, RunError> {
536 let order = trusted_order(files, name)?;
537 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
538 let mut last = None;
539 for inv in &plan {
540 let status = inv.run_inherit().map_err(RunError::Io)?;
541 if !status.success() {
542 return Ok(status);
543 }
544 last = Some(status);
545 }
546 Ok(last.expect("the plan always contains the target"))
547}
548
549pub fn run_captured(
555 files: &[(PathBuf, TaskFile)],
556 name: &str,
557 args: &[String],
558 cwd: &Path,
559) -> Result<std::process::Output, RunError> {
560 let order = trusted_order(files, name)?;
561 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
562 run_plan_captured(&plan)
563}
564
565pub fn run_agent(
586 files: &[(PathBuf, TaskFile)],
587 name: &str,
588 args: &[String],
589 cwd: &Path,
590) -> Result<std::process::Output, RunError> {
591 let mut target: Option<(&Path, &TaskFile, &Job)> = None;
594 for (p, tf) in files {
595 if let Some(job) = tf.job(name) {
596 if job.agent_allow {
597 target = Some((p.as_path(), tf, job));
598 }
599 break; }
601 }
602 let Some((target_path, target_tf, target_job)) = target else {
603 return Err(RunError::NotAllowed(name.to_string()));
604 };
605
606 let templated = target_job.script_arg_templates();
608 if !templated.is_empty() {
609 return Err(RunError::Injects {
610 task: name.to_string(),
611 args: templated.iter().map(|s| s.to_string()).collect(),
612 });
613 }
614
615 let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
618 .map_err(RunError::Dependency)?;
619 let dir = target_path.parent();
620 let plan = plan_invocations(&order, name, args, cwd, |n| {
621 target_tf.job(n).map(|j| (target_tf, j, dir))
622 })?;
623 run_plan_captured(&plan)
624}
625
626impl Invocation {
627 fn command(&self) -> std::process::Command {
629 let mut cmd = std::process::Command::new(&self.program);
630 cmd.args(&self.args)
631 .envs(self.env.iter().map(|(k, v)| (k, v)))
632 .current_dir(&self.cwd);
633 cmd
634 }
635
636 fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
638 self.command().status()
639 }
640
641 fn run_captured(&self) -> std::io::Result<std::process::Output> {
643 self.command().output()
644 }
645}
646
647fn interpreter(lang: &str) -> (&'static str, &'static str) {
650 match lang.trim().to_ascii_lowercase().as_str() {
651 "" | "sh" | "shell" => ("sh", "-c"),
652 "bash" => ("bash", "-c"),
653 "zsh" => ("zsh", "-c"),
654 "fish" => ("fish", "-c"),
655 "python" | "py" | "python3" => ("python3", "-c"),
656 "ruby" => ("ruby", "-e"),
657 "node" | "js" | "javascript" => ("node", "-e"),
658 _ => ("sh", "-c"),
659 }
660}
661
662fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
666 let mut out = String::with_capacity(src.len());
667 let mut rest = src;
668 while let Some(open) = rest.find("{{") {
669 out.push_str(&rest[..open]);
670 let after = &rest[open + 2..];
671 if let Some(close) = after.find("}}") {
672 let name = after[..close].trim();
673 match args.get(name) {
674 Some(v) => out.push_str(v),
675 None => {
676 out.push_str("{{");
678 out.push_str(&after[..close]);
679 out.push_str("}}");
680 }
681 }
682 rest = &after[close + 2..];
683 } else {
684 out.push_str("{{");
685 rest = after;
686 }
687 }
688 out.push_str(rest);
689 out
690}
691
692pub fn parse(src: &str) -> TaskFile {
698 let mut file = TaskFile::default();
699 let mut cur: Option<Job> = None;
700 let mut in_fence = false;
701 let mut fence_marker = "";
702 let mut have_script = false; let mut script = String::new();
704
705 for raw in src.split('\n') {
706 let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
708 if is_closing_fence(line, fence_marker) {
712 in_fence = false;
713 if let Some(t) = cur.as_mut()
714 && !have_script
715 {
716 t.script = std::mem::take(&mut script);
717 have_script = true;
718 }
719 script.clear();
720 } else if cur.is_some() && !have_script {
721 script.push_str(line);
722 script.push('\n');
723 }
724 continue;
725 }
726 if let Some(marker) = opening_fence(line) {
727 in_fence = true;
728 fence_marker = marker;
729 if let Some(t) = cur.as_mut()
730 && !have_script
731 {
732 t.lang = info_string(line, marker);
733 }
734 script.clear();
735 continue;
736 }
737 if let Some(name) = heading(line) {
738 finalize(cur.take(), &mut file);
739 cur = Some(Job {
740 name,
741 ..Job::default()
742 });
743 have_script = false;
744 continue;
745 }
746 apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
747 }
748 if in_fence {
751 if let Some(t) = cur.as_mut()
752 && !have_script
753 {
754 t.script = std::mem::take(&mut script);
755 }
756 let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
757 file.warnings
758 .push(format!("unterminated code fence in task {name:?}"));
759 }
760 finalize(cur.take(), &mut file);
761 file
762}
763
764fn finalize(job: Option<Job>, file: &mut TaskFile) {
768 let Some(mut t) = job else {
769 return;
770 };
771 if t.script.is_empty() {
772 file.env.append(&mut t.env); return;
774 }
775 t.description = t.description.trim().to_string();
776 if file.jobs.iter().any(|x| x.name == t.name) {
777 file.warnings.push(format!(
778 "duplicate task {:?}; the first defined wins",
779 t.name
780 ));
781 }
782 if !is_known_lang(&t.lang) {
783 file.warnings.push(format!(
784 "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
785 t.name, t.lang
786 ));
787 }
788 file.jobs.push(t);
789}
790
791fn is_known_lang(lang: &str) -> bool {
793 matches!(
794 lang.trim().to_ascii_lowercase().as_str(),
795 "" | "sh"
796 | "shell"
797 | "bash"
798 | "zsh"
799 | "fish"
800 | "python"
801 | "py"
802 | "python3"
803 | "ruby"
804 | "node"
805 | "js"
806 | "javascript"
807 )
808}
809
810fn opening_fence(line: &str) -> Option<&'static str> {
812 let t = line.trim_start();
813 if t.starts_with("```") {
814 Some("```")
815 } else if t.starts_with("~~~") {
816 Some("~~~")
817 } else {
818 None
819 }
820}
821
822fn is_closing_fence(line: &str, marker: &str) -> bool {
825 let ch = marker.as_bytes()[0];
826 let t = line.trim();
827 t.len() >= 3 && t.bytes().all(|b| b == ch)
828}
829
830pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
837 let mut found = Vec::new();
838 for dir in start.ancestors() {
839 for name in ["tasks.md", "maskfile.md", "README.md"] {
840 let path = dir.join(name);
841 if let Ok(src) = std::fs::read_to_string(&path) {
842 let tf = parse(&src);
843 if !tf.jobs.is_empty() {
844 found.push((path, tf));
845 break; }
847 }
848 }
849 }
850 found
851}
852
853fn info_string(line: &str, marker: &str) -> String {
855 line.trim_start()
856 .strip_prefix(marker)
857 .unwrap_or("")
858 .split_whitespace()
859 .next()
860 .unwrap_or("")
861 .to_string()
862}
863
864fn heading(line: &str) -> Option<String> {
866 let t = line.trim_start();
867 if !t.starts_with('#') {
868 return None;
869 }
870 let after = t.trim_start_matches('#');
871 if after == t || !after.starts_with(' ') {
873 return None;
874 }
875 Some(after.trim().to_string())
876}
877
878fn apply_line(
882 line: &str,
883 job: Option<&mut Job>,
884 file_env: &mut Vec<(String, String)>,
885 warnings: &mut Vec<String>,
886) {
887 if let Some((key, value)) = split_key(line) {
888 let value = value.trim();
889 match key.as_str() {
890 "env" | "environment" => {
891 let pairs = parse_env(value);
892 match job {
893 Some(t) => t.env.extend(pairs),
894 None => file_env.extend(pairs), }
896 return;
897 }
898 "opts" | "options" => {
899 if let Some(t) = job {
900 t.opts = value.split_whitespace().map(str::to_string).collect();
901 for flag in &t.opts {
902 if !KNOWN_OPTS.contains(&flag.as_str()) {
903 warnings.push(format!(
904 "unknown option {flag:?} in `Opts:` (known: {})",
905 KNOWN_OPTS.join(", ")
906 ));
907 }
908 }
909 }
910 return;
911 }
912 "args" | "arguments" => {
913 if let Some(t) = job {
914 t.args = parse_args(value);
915 }
916 return;
917 }
918 "requires" | "req" => {
919 if let Some(t) = job {
920 t.requires = value
921 .split(',')
922 .map(|s| s.trim().to_string())
923 .filter(|s| !s.is_empty())
924 .collect();
925 }
926 return;
927 }
928 "agent" => {
929 if let Some(t) = job {
930 t.agent_allow = value.eq_ignore_ascii_case("allow");
931 }
932 return;
933 }
934 _ => {}
935 }
936 }
937 if let Some(t) = job
939 && !line.trim().is_empty()
940 {
941 t.description.push_str(line.trim());
942 t.description.push('\n');
943 }
944}
945
946fn split_key(line: &str) -> Option<(String, &str)> {
951 let colon = line.find(':')?;
952 let key = line[..colon].trim();
953 if key.is_empty() || key.contains(char::is_whitespace) {
954 return None;
955 }
956 Some((key.to_ascii_lowercase(), &line[colon + 1..]))
957}
958
959fn parse_env(value: &str) -> Vec<(String, String)> {
961 value
962 .split(',')
963 .filter_map(|p| {
964 let (k, v) = p.split_once('=')?;
965 let k = k.trim();
966 if k.is_empty() {
967 return None;
968 }
969 Some((k.to_string(), v.trim().to_string()))
970 })
971 .collect()
972}
973
974fn parse_args(value: &str) -> Vec<Arg> {
979 tokenize_args(value)
980 .into_iter()
981 .filter_map(|tok| {
982 let (name, default) = match tok.split_once('=') {
983 Some((n, d)) => (n, Some(unquote(d).to_string())),
984 None => (tok.as_str(), None),
985 };
986 let (name, variadic) = match name.strip_prefix('*') {
987 Some(rest) => (rest, true),
988 None => (name, false),
989 };
990 let name = name.trim();
991 if name.is_empty() {
992 return None;
993 }
994 Some(Arg {
995 name: name.to_string(),
996 variadic,
997 default,
998 })
999 })
1000 .collect()
1001}
1002
1003fn tokenize_args(value: &str) -> Vec<String> {
1006 let mut out = Vec::new();
1007 let mut cur = String::new();
1008 let mut quote: Option<char> = None;
1009 for c in value.chars() {
1010 match quote {
1011 Some(q) => {
1012 cur.push(c);
1013 if c == q {
1014 quote = None;
1015 }
1016 }
1017 None if c == '\'' || c == '"' => {
1018 cur.push(c);
1019 quote = Some(c);
1020 }
1021 None if c.is_whitespace() => {
1022 if !cur.is_empty() {
1023 out.push(std::mem::take(&mut cur));
1024 }
1025 }
1026 None => cur.push(c),
1027 }
1028 }
1029 if !cur.is_empty() {
1030 out.push(cur);
1031 }
1032 out
1033}
1034
1035fn unquote(s: &str) -> &str {
1037 let s = s.trim();
1038 let b = s.as_bytes();
1039 if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
1040 &s[1..s.len() - 1]
1041 } else {
1042 s
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049
1050 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1051 pairs
1052 .iter()
1053 .map(|(k, v)| (k.to_string(), v.to_string()))
1054 .collect()
1055 }
1056
1057 fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
1058 pairs
1059 .iter()
1060 .map(|(path, src)| (PathBuf::from(path), parse(src)))
1061 .collect()
1062 }
1063
1064 #[test]
1065 fn parses_named_jobs_with_interpreter() {
1066 let tf =
1067 parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
1068 assert_eq!(tf.jobs.len(), 2);
1069 assert_eq!(tf.jobs[0].name, "build");
1070 assert_eq!(tf.jobs[0].lang, "sh");
1071 assert_eq!(tf.jobs[0].script.trim(), "cargo build");
1072 assert_eq!(tf.jobs[1].lang, "zsh");
1073 }
1074
1075 #[test]
1076 fn metadata_keys_are_case_insensitive() {
1077 let tf = parse(
1078 "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
1079 );
1080 let t = &tf.jobs[0];
1081 assert_eq!(t.opts, vec!["inherit-cwd"]);
1082 assert!(t.inherits_cwd());
1083 assert_eq!(
1084 t.env,
1085 vec![
1086 ("REGION".into(), "us".into()),
1087 ("TIER".into(), "prod".into())
1088 ]
1089 );
1090 assert_eq!(
1091 t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
1092 ["target"]
1093 );
1094 assert_eq!(t.requires, vec!["build", "test"]);
1095 assert!(t.agent_allow);
1096 }
1097
1098 #[test]
1099 fn agent_gate_is_off_by_default() {
1100 let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
1101 assert!(!tf.jobs[0].agent_allow);
1102 }
1103
1104 #[test]
1105 fn top_level_env_is_hoisted() {
1106 let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
1107 assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
1108 }
1109
1110 #[test]
1111 fn fence_content_is_not_parsed_as_structure() {
1112 let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
1114 assert_eq!(tf.jobs.len(), 1);
1115 assert!(tf.jobs[0].script.contains("## not a task"));
1116 assert!(tf.jobs[0].env.is_empty());
1117 }
1118
1119 #[test]
1120 fn substitutes_args_and_leaves_unknown_tokens() {
1121 let out = substitute(
1122 "hello {{ name }} and {{ other }}",
1123 &args(&[("name", "world")]),
1124 );
1125 assert_eq!(out, "hello world and {{ other }}");
1126 }
1127
1128 #[test]
1129 fn invocation_substitutes_sets_env_and_picks_interpreter() {
1130 let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
1131 let j = tf.job("greet").unwrap();
1132 let inv = tf
1133 .invocation(
1134 j,
1135 &args(&[("name", "sam")]),
1136 Path::new("/here"),
1137 Some(Path::new("/file")),
1138 )
1139 .unwrap();
1140 assert_eq!(inv.program, "zsh");
1141 assert_eq!(inv.args[0], "-c");
1142 assert!(inv.args[1].contains("hi sam"));
1143 assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
1144 assert_eq!(inv.cwd, Path::new("/file"));
1146 }
1147
1148 #[test]
1149 fn a_missing_required_arg_is_an_error() {
1150 let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
1151 let j = tf.job("t").unwrap();
1152 assert_eq!(
1153 tf.invocation(j, &args(&[]), Path::new("/here"), None),
1154 Err(MissingArg("file".into()))
1155 );
1156 }
1157
1158 #[test]
1159 fn optional_and_variadic_args_fill_from_defaults() {
1160 let tf = parse(
1161 "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
1162 );
1163 let j = tf.job("t").unwrap();
1164 assert!(!j.args[0].variadic && j.args[0].default.is_none());
1165 assert_eq!(j.args[1].default.as_deref(), Some("fallback"));
1166 assert!(j.args[2].variadic);
1167 let inv = tf
1169 .invocation(j, &args(&[("a", "x")]), Path::new("/here"), None)
1170 .unwrap();
1171 assert!(inv.args[1].contains("echo x fallback "));
1172 let bound =
1174 TaskFile::bind(j, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
1175 assert_eq!(bound.get("b").map(String::as_str), Some("y"));
1176 assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
1177 }
1178
1179 #[test]
1180 fn default_cwd_is_the_task_file_dir() {
1181 let tf = parse("## t\n\n```sh\ntrue\n```\n");
1182 let j = tf.job("t").unwrap();
1183 let inv = tf
1185 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1186 .unwrap();
1187 assert_eq!(inv.cwd, Path::new("/proj"));
1188 let inv = tf
1190 .invocation(j, &args(&[]), Path::new("/here"), None)
1191 .unwrap();
1192 assert_eq!(inv.cwd, Path::new("/here"));
1193 let inv = tf
1196 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("")))
1197 .unwrap();
1198 assert_eq!(inv.cwd, Path::new("/here"));
1199 }
1200
1201 #[test]
1202 fn inherit_cwd_runs_in_the_invocation_dir() {
1203 let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
1204 let j = tf.job("t").unwrap();
1205 assert!(j.inherits_cwd());
1206 let inv = tf
1207 .invocation(j, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
1208 .unwrap();
1209 assert_eq!(inv.cwd, Path::new("/here"));
1210 }
1211
1212 #[test]
1213 fn an_unknown_opt_warns_but_is_ignored() {
1214 let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
1215 assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
1216 assert!(tf.jobs[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
1218 }
1219
1220 fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
1222 move |name| {
1223 map.iter()
1224 .find(|(n, _)| *n == name)
1225 .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
1226 }
1227 }
1228
1229 #[test]
1230 fn dependency_order_is_deps_first_target_last() {
1231 let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
1233 assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
1234 }
1235
1236 #[test]
1237 fn dependency_order_dedupes_a_diamond() {
1238 let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
1239 let order = dependency_order("a", g).unwrap();
1240 assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
1241 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
1243 assert!(pos("d") < pos("b") && pos("d") < pos("c"));
1244 assert_eq!(order.last().unwrap(), "a");
1245 }
1246
1247 #[test]
1248 fn dependency_order_detects_a_cycle() {
1249 let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
1250 assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
1251 }
1252
1253 #[test]
1254 fn dependency_order_flags_a_missing_dependency() {
1255 let g = deps_of(&[("a", &["ghost"])]);
1256 assert_eq!(
1257 dependency_order("a", g),
1258 Err(DepError::Missing {
1259 task: "ghost".into(),
1260 required_by: "a".into(),
1261 })
1262 );
1263 }
1264
1265 #[test]
1266 fn dependency_order_survives_a_pathologically_deep_chain() {
1267 const N: usize = 200_000;
1270 let order = dependency_order("t0", |n| {
1271 let i: usize = n.strip_prefix('t')?.parse().ok()?;
1272 Some(if i + 1 < N {
1273 vec![format!("t{}", i + 1)]
1274 } else {
1275 vec![]
1276 })
1277 })
1278 .unwrap();
1279 assert_eq!(order.len(), N);
1280 assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); assert_eq!(order.last().unwrap(), "t0"); }
1283
1284 #[test]
1285 fn script_arg_templates_flags_only_declared_args_in_the_script() {
1286 let tf =
1288 parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1289 let j = tf.job("t").unwrap();
1290 assert_eq!(j.script_arg_templates(), vec!["name"]);
1291
1292 let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1294 assert!(safe.job("t").unwrap().script_arg_templates().is_empty());
1295 }
1296
1297 #[test]
1298 fn crlf_scripts_are_normalized() {
1299 let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1300 assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
1301 assert!(!tf.jobs[0].script.contains('\r'));
1302 }
1303
1304 #[test]
1305 fn an_unterminated_fence_warns_but_keeps_the_job() {
1306 let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.jobs.len(), 1);
1308 assert_eq!(tf.jobs[0].script.trim(), "echo hi");
1309 assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1310 }
1311
1312 #[test]
1313 fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1314 let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1317 assert!(tf.jobs[0].script.contains("one"));
1318 assert!(tf.jobs[0].script.contains("```sh\ntwo"));
1319 }
1320
1321 #[test]
1322 fn indented_metadata_is_recognized() {
1323 let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
1324 assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
1325 }
1326
1327 #[test]
1328 fn duplicate_and_unknown_lang_warn() {
1329 let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1330 assert_eq!(tf.jobs.len(), 2);
1331 assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1332 assert!(tf.warnings.iter().any(|w| w.contains("json")));
1333 }
1334
1335 #[test]
1336 fn agent_jobs_filters_to_the_gated_ones() {
1337 let f = files(&[(
1338 "tasks.md",
1339 "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
1340 )]);
1341 let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
1342 assert_eq!(names, ["open"]);
1343 }
1344
1345 #[test]
1346 fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
1347 let f = files(&[
1350 ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
1351 (
1352 "tasks.md",
1353 "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
1354 ),
1355 ]);
1356 assert!(agent_jobs(&f).is_empty());
1357 }
1358
1359 #[test]
1360 fn find_task_files_layers_child_over_parent() {
1361 let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1364 let child = base.join("child");
1365 std::fs::create_dir_all(&child).unwrap();
1366 std::fs::write(
1367 base.join("tasks.md"),
1368 "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1369 )
1370 .unwrap();
1371 std::fs::write(
1372 child.join("tasks.md"),
1373 "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1374 )
1375 .unwrap();
1376
1377 let files = find_task_files(&child);
1378 assert_eq!(files.len(), 2, "child and parent files found");
1379 assert!(files[0].0.starts_with(&child));
1381 assert_eq!(
1382 files[0].1.job("shared").unwrap().script.trim(),
1383 "echo child"
1384 );
1385 assert!(files[1].1.job("base").is_some());
1387 std::fs::remove_dir_all(&base).ok();
1388 }
1389
1390 #[test]
1391 fn run_captured_returns_stdout() {
1392 let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
1393 let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
1394 assert!(out.status.success());
1395 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
1396 }
1397
1398 #[test]
1399 fn run_captured_runs_requires_deps_first() {
1400 let f = files(&[(
1401 "tasks.md",
1402 "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
1403 )]);
1404 let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
1405 let text = String::from_utf8_lossy(&out.stdout);
1406 let bpos = text.find('B').expect("B in output");
1408 let apos = text.find('A').expect("A in output");
1409 assert!(bpos < apos, "deps must run first: {text}");
1410 }
1411
1412 #[test]
1413 fn run_reports_an_unknown_target_as_not_found() {
1414 let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
1415 match run_captured(&f, "ghost", &[], Path::new(".")) {
1416 Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
1417 other => panic!("expected NotFound, got {other:?}"),
1418 }
1419 }
1420
1421 #[test]
1424 fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
1425 let f = files(&[
1429 ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
1430 (
1431 "tasks.md",
1432 "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
1433 ),
1434 ]);
1435 let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
1436 let text = String::from_utf8_lossy(&out.stdout);
1437 assert!(text.contains("real-build"), "got: {text}");
1438 assert!(text.contains("real-deploy"), "got: {text}");
1439 assert!(!text.contains("PWNED"), "nearer build ran: {text}");
1440 assert!(out.status.success());
1441 }
1442
1443 #[test]
1444 fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
1445 let f = files(&[(
1448 "tasks.md",
1449 "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
1450 )]);
1451 match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
1452 Err(RunError::Injects { task, args }) => {
1453 assert_eq!(task, "greet");
1454 assert_eq!(args, vec!["name".to_string()]);
1455 }
1456 other => panic!("expected Injects, got {other:?}"),
1457 }
1458 }
1459
1460 #[test]
1461 fn run_agent_refuses_a_non_allowed_target() {
1462 let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
1463 match run_agent(&f, "secret", &[], Path::new(".")) {
1464 Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
1465 other => panic!("expected NotAllowed, got {other:?}"),
1466 }
1467 }
1468
1469 #[test]
1470 fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
1471 let f = files(&[
1473 ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
1474 (
1475 "tasks.md",
1476 "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
1477 ),
1478 ]);
1479 match run_agent(&f, "deploy", &[], Path::new(".")) {
1480 Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
1481 other => panic!("expected NotAllowed, got {other:?}"),
1482 }
1483 }
1484}