1use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct TaskFile {
40 pub env: Vec<(String, String)>,
41 pub tasks: Vec<Task>,
42 pub warnings: Vec<String>,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct Task {
48 pub name: String,
50 pub description: String,
52 pub lang: String,
55 pub script: String,
57 pub opts: Vec<String>,
64 pub env: Vec<(String, String)>,
66 pub args: Vec<Arg>,
77 pub requires: Vec<String>,
82 pub agent_allow: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Arg {
93 pub name: String,
94 pub variadic: bool,
96 pub default: Option<String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Invocation {
105 pub program: String,
106 pub args: Vec<String>,
107 pub env: Vec<(String, String)>,
108 pub cwd: PathBuf,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct MissingArg(pub String);
114
115impl std::fmt::Display for MissingArg {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 write!(f, "missing value for argument `{}`", self.0)
118 }
119}
120impl std::error::Error for MissingArg {}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum DepError {
125 Missing { task: String, required_by: String },
127 Cycle(String),
129}
130
131impl std::fmt::Display for DepError {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
134 DepError::Missing { task, required_by } => {
135 write!(f, "task {required_by:?} requires unknown task {task:?}")
136 }
137 DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
138 }
139 }
140}
141impl std::error::Error for DepError {}
142
143pub fn dependency_order(
154 target: &str,
155 requires_of: impl Fn(&str) -> Option<Vec<String>>,
156) -> Result<Vec<String>, DepError> {
157 struct Frame {
161 name: String,
162 deps: Vec<String>,
163 next: usize,
164 }
165
166 let mut order = Vec::new();
167 let mut done = std::collections::BTreeSet::new();
168 let mut on_stack = std::collections::BTreeSet::new();
169 let mut stack: Vec<Frame> = Vec::new();
170
171 let deps = requires_of(target).ok_or_else(|| DepError::Missing {
172 task: target.to_string(),
173 required_by: target.to_string(),
174 })?;
175 on_stack.insert(target.to_string());
176 stack.push(Frame {
177 name: target.to_string(),
178 deps,
179 next: 0,
180 });
181
182 loop {
183 let descend = {
186 let Some(frame) = stack.last_mut() else { break };
187 if frame.next < frame.deps.len() {
188 let dep = frame.deps[frame.next].clone();
189 frame.next += 1;
190 Some(dep)
191 } else {
192 None
193 }
194 };
195 match descend {
196 Some(dep) => {
197 if done.contains(&dep) {
198 continue; }
200 if on_stack.contains(&dep) {
201 return Err(DepError::Cycle(dep));
202 }
203 let required_by = stack.last().expect("a top frame exists").name.clone();
204 let deps = requires_of(&dep).ok_or(DepError::Missing {
205 task: dep.clone(),
206 required_by,
207 })?;
208 on_stack.insert(dep.clone());
209 stack.push(Frame {
210 name: dep,
211 deps,
212 next: 0,
213 });
214 }
215 None => {
216 let frame = stack.pop().expect("a top frame exists");
217 on_stack.remove(&frame.name);
218 done.insert(frame.name.clone());
219 order.push(frame.name);
220 }
221 }
222 }
223 Ok(order)
224}
225
226pub const KNOWN_OPTS: &[&str] = &["inherit-cwd"];
230
231impl Task {
232 pub fn inherits_cwd(&self) -> bool {
235 self.opts.iter().any(|o| o == "inherit-cwd")
236 }
237
238 pub fn script_arg_templates(&self) -> Vec<&str> {
247 let declared: std::collections::BTreeSet<&str> =
248 self.args.iter().map(|a| a.name.as_str()).collect();
249 let mut found: Vec<&str> = Vec::new();
250 let mut rest = self.script.as_str();
251 while let Some(open) = rest.find("{{") {
252 let after = &rest[open + 2..];
253 let Some(close) = after.find("}}") else { break };
254 let tok = after[..close].trim();
255 if declared.contains(tok) && !found.contains(&tok) {
256 found.push(tok);
257 }
258 rest = &after[close + 2..];
259 }
260 found
261 }
262}
263
264impl TaskFile {
265 pub fn task(&self, name: &str) -> Option<&Task> {
269 self.tasks.iter().find(|t| t.name == name)
270 }
271
272 pub fn agent_tasks(&self) -> impl Iterator<Item = &Task> {
278 self.tasks.iter().filter(|t| t.agent_allow)
279 }
280
281 pub fn invocation(
304 &self,
305 task: &Task,
306 args: &BTreeMap<String, String>,
307 cwd: &Path,
308 task_file_dir: Option<&Path>,
309 ) -> Result<Invocation, MissingArg> {
310 let mut effective = args.clone();
312 for a in &task.args {
313 if !effective.contains_key(&a.name) {
314 if a.variadic {
315 effective.insert(a.name.clone(), String::new());
316 } else if let Some(d) = &a.default {
317 effective.insert(a.name.clone(), d.clone());
318 } else {
319 return Err(MissingArg(a.name.clone()));
320 }
321 }
322 }
323
324 let script = substitute(&task.script, &effective);
325 let (program, flag) = interpreter(&task.lang);
326
327 let mut env = self.env.clone();
330 env.extend(task.env.iter().cloned());
331 env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
332
333 let run_cwd = match task_file_dir {
338 _ if task.inherits_cwd() => cwd.to_path_buf(),
339 Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
340 _ => cwd.to_path_buf(),
341 };
342
343 Ok(Invocation {
344 program: program.to_string(),
345 args: vec![flag.to_string(), script],
346 env,
347 cwd: run_cwd,
348 })
349 }
350
351 pub fn bind(
356 task: &Task,
357 positional: &[String],
358 ) -> Result<BTreeMap<String, String>, MissingArg> {
359 let mut map = BTreeMap::new();
360 let mut i = 0;
361 for a in &task.args {
362 if a.variadic {
363 map.insert(
364 a.name.clone(),
365 positional[i.min(positional.len())..].join(" "),
366 );
367 i = positional.len();
368 } else if i < positional.len() {
369 map.insert(a.name.clone(), positional[i].clone());
370 i += 1;
371 } else if let Some(d) = &a.default {
372 map.insert(a.name.clone(), d.clone());
373 } else {
374 return Err(MissingArg(a.name.clone()));
375 }
376 }
377 Ok(map)
378 }
379}
380
381impl Invocation {
382 pub fn run(&self) -> std::io::Result<std::process::ExitStatus> {
388 std::process::Command::new(&self.program)
389 .args(&self.args)
390 .envs(self.env.iter().map(|(k, v)| (k, v)))
391 .current_dir(&self.cwd)
392 .status()
393 }
394}
395
396fn interpreter(lang: &str) -> (&'static str, &'static str) {
399 match lang.trim().to_ascii_lowercase().as_str() {
400 "" | "sh" | "shell" => ("sh", "-c"),
401 "bash" => ("bash", "-c"),
402 "zsh" => ("zsh", "-c"),
403 "fish" => ("fish", "-c"),
404 "python" | "py" | "python3" => ("python3", "-c"),
405 "ruby" => ("ruby", "-e"),
406 "node" | "js" | "javascript" => ("node", "-e"),
407 _ => ("sh", "-c"),
408 }
409}
410
411fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
415 let mut out = String::with_capacity(src.len());
416 let mut rest = src;
417 while let Some(open) = rest.find("{{") {
418 out.push_str(&rest[..open]);
419 let after = &rest[open + 2..];
420 if let Some(close) = after.find("}}") {
421 let name = after[..close].trim();
422 match args.get(name) {
423 Some(v) => out.push_str(v),
424 None => {
425 out.push_str("{{");
427 out.push_str(&after[..close]);
428 out.push_str("}}");
429 }
430 }
431 rest = &after[close + 2..];
432 } else {
433 out.push_str("{{");
434 rest = after;
435 }
436 }
437 out.push_str(rest);
438 out
439}
440
441pub fn parse(src: &str) -> TaskFile {
447 let mut file = TaskFile::default();
448 let mut cur: Option<Task> = None;
449 let mut in_fence = false;
450 let mut fence_marker = "";
451 let mut have_script = false; let mut script = String::new();
453
454 for raw in src.split('\n') {
455 let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
457 if is_closing_fence(line, fence_marker) {
461 in_fence = false;
462 if let Some(t) = cur.as_mut()
463 && !have_script
464 {
465 t.script = std::mem::take(&mut script);
466 have_script = true;
467 }
468 script.clear();
469 } else if cur.is_some() && !have_script {
470 script.push_str(line);
471 script.push('\n');
472 }
473 continue;
474 }
475 if let Some(marker) = opening_fence(line) {
476 in_fence = true;
477 fence_marker = marker;
478 if let Some(t) = cur.as_mut()
479 && !have_script
480 {
481 t.lang = info_string(line, marker);
482 }
483 script.clear();
484 continue;
485 }
486 if let Some(name) = heading(line) {
487 finalize(cur.take(), &mut file);
488 cur = Some(Task {
489 name,
490 ..Task::default()
491 });
492 have_script = false;
493 continue;
494 }
495 apply_line(line, cur.as_mut(), &mut file.env, &mut file.warnings);
496 }
497 if in_fence {
500 if let Some(t) = cur.as_mut()
501 && !have_script
502 {
503 t.script = std::mem::take(&mut script);
504 }
505 let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
506 file.warnings
507 .push(format!("unterminated code fence in task {name:?}"));
508 }
509 finalize(cur.take(), &mut file);
510 file
511}
512
513fn finalize(task: Option<Task>, file: &mut TaskFile) {
517 let Some(mut t) = task else {
518 return;
519 };
520 if t.script.is_empty() {
521 file.env.append(&mut t.env); return;
523 }
524 t.description = t.description.trim().to_string();
525 if file.tasks.iter().any(|x| x.name == t.name) {
526 file.warnings.push(format!(
527 "duplicate task {:?}; the first defined wins",
528 t.name
529 ));
530 }
531 if !is_known_lang(&t.lang) {
532 file.warnings.push(format!(
533 "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
534 t.name, t.lang
535 ));
536 }
537 file.tasks.push(t);
538}
539
540fn is_known_lang(lang: &str) -> bool {
542 matches!(
543 lang.trim().to_ascii_lowercase().as_str(),
544 "" | "sh"
545 | "shell"
546 | "bash"
547 | "zsh"
548 | "fish"
549 | "python"
550 | "py"
551 | "python3"
552 | "ruby"
553 | "node"
554 | "js"
555 | "javascript"
556 )
557}
558
559fn opening_fence(line: &str) -> Option<&'static str> {
561 let t = line.trim_start();
562 if t.starts_with("```") {
563 Some("```")
564 } else if t.starts_with("~~~") {
565 Some("~~~")
566 } else {
567 None
568 }
569}
570
571fn is_closing_fence(line: &str, marker: &str) -> bool {
574 let ch = marker.as_bytes()[0];
575 let t = line.trim();
576 t.len() >= 3 && t.bytes().all(|b| b == ch)
577}
578
579pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
586 let mut found = Vec::new();
587 for dir in start.ancestors() {
588 for name in ["tasks.md", "maskfile.md", "README.md"] {
589 let path = dir.join(name);
590 if let Ok(src) = std::fs::read_to_string(&path) {
591 let tf = parse(&src);
592 if !tf.tasks.is_empty() {
593 found.push((path, tf));
594 break; }
596 }
597 }
598 }
599 found
600}
601
602fn info_string(line: &str, marker: &str) -> String {
604 line.trim_start()
605 .strip_prefix(marker)
606 .unwrap_or("")
607 .split_whitespace()
608 .next()
609 .unwrap_or("")
610 .to_string()
611}
612
613fn heading(line: &str) -> Option<String> {
615 let t = line.trim_start();
616 if !t.starts_with('#') {
617 return None;
618 }
619 let after = t.trim_start_matches('#');
620 if after == t || !after.starts_with(' ') {
622 return None;
623 }
624 Some(after.trim().to_string())
625}
626
627fn apply_line(
631 line: &str,
632 task: Option<&mut Task>,
633 file_env: &mut Vec<(String, String)>,
634 warnings: &mut Vec<String>,
635) {
636 if let Some((key, value)) = split_key(line) {
637 let value = value.trim();
638 match key.as_str() {
639 "env" | "environment" => {
640 let pairs = parse_env(value);
641 match task {
642 Some(t) => t.env.extend(pairs),
643 None => file_env.extend(pairs), }
645 return;
646 }
647 "opts" | "options" => {
648 if let Some(t) = task {
649 t.opts = value.split_whitespace().map(str::to_string).collect();
650 for flag in &t.opts {
651 if !KNOWN_OPTS.contains(&flag.as_str()) {
652 warnings.push(format!(
653 "unknown option {flag:?} in `Opts:` (known: {})",
654 KNOWN_OPTS.join(", ")
655 ));
656 }
657 }
658 }
659 return;
660 }
661 "args" | "arguments" => {
662 if let Some(t) = task {
663 t.args = parse_args(value);
664 }
665 return;
666 }
667 "requires" | "req" => {
668 if let Some(t) = task {
669 t.requires = value
670 .split(',')
671 .map(|s| s.trim().to_string())
672 .filter(|s| !s.is_empty())
673 .collect();
674 }
675 return;
676 }
677 "agent" => {
678 if let Some(t) = task {
679 t.agent_allow = value.eq_ignore_ascii_case("allow");
680 }
681 return;
682 }
683 _ => {}
684 }
685 }
686 if let Some(t) = task
688 && !line.trim().is_empty()
689 {
690 t.description.push_str(line.trim());
691 t.description.push('\n');
692 }
693}
694
695fn split_key(line: &str) -> Option<(String, &str)> {
700 let colon = line.find(':')?;
701 let key = line[..colon].trim();
702 if key.is_empty() || key.contains(char::is_whitespace) {
703 return None;
704 }
705 Some((key.to_ascii_lowercase(), &line[colon + 1..]))
706}
707
708fn parse_env(value: &str) -> Vec<(String, String)> {
710 value
711 .split(',')
712 .filter_map(|p| {
713 let (k, v) = p.split_once('=')?;
714 let k = k.trim();
715 if k.is_empty() {
716 return None;
717 }
718 Some((k.to_string(), v.trim().to_string()))
719 })
720 .collect()
721}
722
723fn parse_args(value: &str) -> Vec<Arg> {
728 tokenize_args(value)
729 .into_iter()
730 .filter_map(|tok| {
731 let (name, default) = match tok.split_once('=') {
732 Some((n, d)) => (n, Some(unquote(d).to_string())),
733 None => (tok.as_str(), None),
734 };
735 let (name, variadic) = match name.strip_prefix('*') {
736 Some(rest) => (rest, true),
737 None => (name, false),
738 };
739 let name = name.trim();
740 if name.is_empty() {
741 return None;
742 }
743 Some(Arg {
744 name: name.to_string(),
745 variadic,
746 default,
747 })
748 })
749 .collect()
750}
751
752fn tokenize_args(value: &str) -> Vec<String> {
755 let mut out = Vec::new();
756 let mut cur = String::new();
757 let mut quote: Option<char> = None;
758 for c in value.chars() {
759 match quote {
760 Some(q) => {
761 cur.push(c);
762 if c == q {
763 quote = None;
764 }
765 }
766 None if c == '\'' || c == '"' => {
767 cur.push(c);
768 quote = Some(c);
769 }
770 None if c.is_whitespace() => {
771 if !cur.is_empty() {
772 out.push(std::mem::take(&mut cur));
773 }
774 }
775 None => cur.push(c),
776 }
777 }
778 if !cur.is_empty() {
779 out.push(cur);
780 }
781 out
782}
783
784fn unquote(s: &str) -> &str {
786 let s = s.trim();
787 let b = s.as_bytes();
788 if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
789 &s[1..s.len() - 1]
790 } else {
791 s
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
800 pairs
801 .iter()
802 .map(|(k, v)| (k.to_string(), v.to_string()))
803 .collect()
804 }
805
806 #[test]
807 fn parses_named_tasks_with_interpreter() {
808 let tf =
809 parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
810 assert_eq!(tf.tasks.len(), 2);
811 assert_eq!(tf.tasks[0].name, "build");
812 assert_eq!(tf.tasks[0].lang, "sh");
813 assert_eq!(tf.tasks[0].script.trim(), "cargo build");
814 assert_eq!(tf.tasks[1].lang, "zsh");
815 }
816
817 #[test]
818 fn metadata_keys_are_case_insensitive() {
819 let tf = parse(
820 "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
821 );
822 let t = &tf.tasks[0];
823 assert_eq!(t.opts, vec!["inherit-cwd"]);
824 assert!(t.inherits_cwd());
825 assert_eq!(
826 t.env,
827 vec![
828 ("REGION".into(), "us".into()),
829 ("TIER".into(), "prod".into())
830 ]
831 );
832 assert_eq!(
833 t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
834 ["target"]
835 );
836 assert_eq!(t.requires, vec!["build", "test"]);
837 assert!(t.agent_allow);
838 }
839
840 #[test]
841 fn agent_gate_is_off_by_default() {
842 let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
843 assert!(!tf.tasks[0].agent_allow);
844 }
845
846 #[test]
847 fn top_level_env_is_hoisted() {
848 let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
849 assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
850 }
851
852 #[test]
853 fn fence_content_is_not_parsed_as_structure() {
854 let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
856 assert_eq!(tf.tasks.len(), 1);
857 assert!(tf.tasks[0].script.contains("## not a task"));
858 assert!(tf.tasks[0].env.is_empty());
859 }
860
861 #[test]
862 fn substitutes_args_and_leaves_unknown_tokens() {
863 let out = substitute(
864 "hello {{ name }} and {{ other }}",
865 &args(&[("name", "world")]),
866 );
867 assert_eq!(out, "hello world and {{ other }}");
868 }
869
870 #[test]
871 fn invocation_substitutes_sets_env_and_picks_interpreter() {
872 let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
873 let t = tf.task("greet").unwrap();
874 let inv = tf
875 .invocation(
876 t,
877 &args(&[("name", "sam")]),
878 Path::new("/here"),
879 Some(Path::new("/file")),
880 )
881 .unwrap();
882 assert_eq!(inv.program, "zsh");
883 assert_eq!(inv.args[0], "-c");
884 assert!(inv.args[1].contains("hi sam"));
885 assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
886 assert_eq!(inv.cwd, Path::new("/file"));
888 }
889
890 #[test]
891 fn a_missing_required_arg_is_an_error() {
892 let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
893 let t = tf.task("t").unwrap();
894 assert_eq!(
895 tf.invocation(t, &args(&[]), Path::new("/here"), None),
896 Err(MissingArg("file".into()))
897 );
898 }
899
900 #[test]
901 fn optional_and_variadic_args_fill_from_defaults() {
902 let tf = parse(
903 "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
904 );
905 let t = tf.task("t").unwrap();
906 assert!(!t.args[0].variadic && t.args[0].default.is_none());
907 assert_eq!(t.args[1].default.as_deref(), Some("fallback"));
908 assert!(t.args[2].variadic);
909 let inv = tf
911 .invocation(t, &args(&[("a", "x")]), Path::new("/here"), None)
912 .unwrap();
913 assert!(inv.args[1].contains("echo x fallback "));
914 let bound =
916 TaskFile::bind(t, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
917 assert_eq!(bound.get("b").map(String::as_str), Some("y"));
918 assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
919 }
920
921 #[test]
922 fn default_cwd_is_the_task_file_dir() {
923 let tf = parse("## t\n\n```sh\ntrue\n```\n");
924 let t = tf.task("t").unwrap();
925 let inv = tf
927 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
928 .unwrap();
929 assert_eq!(inv.cwd, Path::new("/proj"));
930 let inv = tf
932 .invocation(t, &args(&[]), Path::new("/here"), None)
933 .unwrap();
934 assert_eq!(inv.cwd, Path::new("/here"));
935 let inv = tf
938 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("")))
939 .unwrap();
940 assert_eq!(inv.cwd, Path::new("/here"));
941 }
942
943 #[test]
944 fn inherit_cwd_runs_in_the_invocation_dir() {
945 let tf = parse("## t\n\nOpts: inherit-cwd\n\n```sh\ntrue\n```\n");
946 let t = tf.task("t").unwrap();
947 assert!(t.inherits_cwd());
948 let inv = tf
949 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
950 .unwrap();
951 assert_eq!(inv.cwd, Path::new("/here"));
952 }
953
954 #[test]
955 fn an_unknown_opt_warns_but_is_ignored() {
956 let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
957 assert_eq!(tf.tasks[0].opts, vec!["inherit-cwd", "bogus"]);
958 assert!(tf.tasks[0].inherits_cwd()); assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
960 }
961
962 fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
964 move |name| {
965 map.iter()
966 .find(|(n, _)| *n == name)
967 .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
968 }
969 }
970
971 #[test]
972 fn dependency_order_is_deps_first_target_last() {
973 let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
975 assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
976 }
977
978 #[test]
979 fn dependency_order_dedupes_a_diamond() {
980 let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
981 let order = dependency_order("a", g).unwrap();
982 assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
983 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
985 assert!(pos("d") < pos("b") && pos("d") < pos("c"));
986 assert_eq!(order.last().unwrap(), "a");
987 }
988
989 #[test]
990 fn dependency_order_detects_a_cycle() {
991 let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
992 assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
993 }
994
995 #[test]
996 fn dependency_order_flags_a_missing_dependency() {
997 let g = deps_of(&[("a", &["ghost"])]);
998 assert_eq!(
999 dependency_order("a", g),
1000 Err(DepError::Missing {
1001 task: "ghost".into(),
1002 required_by: "a".into(),
1003 })
1004 );
1005 }
1006
1007 #[test]
1008 fn dependency_order_survives_a_pathologically_deep_chain() {
1009 const N: usize = 200_000;
1012 let order = dependency_order("t0", |n| {
1013 let i: usize = n.strip_prefix('t')?.parse().ok()?;
1014 Some(if i + 1 < N {
1015 vec![format!("t{}", i + 1)]
1016 } else {
1017 vec![]
1018 })
1019 })
1020 .unwrap();
1021 assert_eq!(order.len(), N);
1022 assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); assert_eq!(order.last().unwrap(), "t0"); }
1025
1026 #[test]
1027 fn script_arg_templates_flags_only_declared_args_in_the_script() {
1028 let tf =
1030 parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1031 let t = tf.task("t").unwrap();
1032 assert_eq!(t.script_arg_templates(), vec!["name"]);
1033
1034 let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1036 assert!(safe.task("t").unwrap().script_arg_templates().is_empty());
1037 }
1038
1039 #[test]
1040 fn crlf_scripts_are_normalized() {
1041 let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1042 assert_eq!(tf.tasks[0].script, "echo foo\necho bar\n");
1043 assert!(!tf.tasks[0].script.contains('\r'));
1044 }
1045
1046 #[test]
1047 fn an_unterminated_fence_warns_but_keeps_the_task() {
1048 let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.tasks.len(), 1);
1050 assert_eq!(tf.tasks[0].script.trim(), "echo hi");
1051 assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1052 }
1053
1054 #[test]
1055 fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1056 let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1059 assert!(tf.tasks[0].script.contains("one"));
1060 assert!(tf.tasks[0].script.contains("```sh\ntwo"));
1061 }
1062
1063 #[test]
1064 fn indented_metadata_is_recognized() {
1065 let tf = parse("## a\n\n- steps:\n Env: KEY=val\n\n```sh\ntrue\n```\n");
1066 assert_eq!(tf.tasks[0].env, vec![("KEY".into(), "val".into())]);
1067 }
1068
1069 #[test]
1070 fn duplicate_and_unknown_lang_warn() {
1071 let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1072 assert_eq!(tf.tasks.len(), 2);
1073 assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1074 assert!(tf.warnings.iter().any(|w| w.contains("json")));
1075 }
1076
1077 #[test]
1078 fn agent_tasks_filters_to_the_gated_ones() {
1079 let tf =
1080 parse("## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n");
1081 let names: Vec<_> = tf.agent_tasks().map(|t| t.name.as_str()).collect();
1082 assert_eq!(names, ["open"]);
1083 }
1084
1085 #[test]
1086 fn find_task_files_layers_child_over_parent() {
1087 let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1090 let child = base.join("child");
1091 std::fs::create_dir_all(&child).unwrap();
1092 std::fs::write(
1093 base.join("tasks.md"),
1094 "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1095 )
1096 .unwrap();
1097 std::fs::write(
1098 child.join("tasks.md"),
1099 "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1100 )
1101 .unwrap();
1102
1103 let files = find_task_files(&child);
1104 assert_eq!(files.len(), 2, "child and parent files found");
1105 assert!(files[0].0.starts_with(&child));
1107 assert_eq!(
1108 files[0].1.task("shared").unwrap().script.trim(),
1109 "echo child"
1110 );
1111 assert!(files[1].1.task("base").is_some());
1113 std::fs::remove_dir_all(&base).ok();
1114 }
1115}