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 dir: Option<String>,
64 pub env: Vec<(String, String)>,
66 pub args: Vec<Arg>,
75 pub requires: Vec<String>,
80 pub agent_allow: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Arg {
91 pub name: String,
92 pub variadic: bool,
94 pub default: Option<String>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Invocation {
103 pub program: String,
104 pub args: Vec<String>,
105 pub env: Vec<(String, String)>,
106 pub cwd: PathBuf,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MissingArg(pub String);
112
113impl std::fmt::Display for MissingArg {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 write!(f, "missing value for argument `{}`", self.0)
116 }
117}
118impl std::error::Error for MissingArg {}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum DepError {
123 Missing { task: String, required_by: String },
125 Cycle(String),
127}
128
129impl std::fmt::Display for DepError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 DepError::Missing { task, required_by } => {
133 write!(f, "task {required_by:?} requires unknown task {task:?}")
134 }
135 DepError::Cycle(name) => write!(f, "dependency cycle through task {name:?}"),
136 }
137 }
138}
139impl std::error::Error for DepError {}
140
141pub fn dependency_order(
152 target: &str,
153 requires_of: impl Fn(&str) -> Option<Vec<String>>,
154) -> Result<Vec<String>, DepError> {
155 struct Frame {
159 name: String,
160 deps: Vec<String>,
161 next: usize,
162 }
163
164 let mut order = Vec::new();
165 let mut done = std::collections::BTreeSet::new();
166 let mut on_stack = std::collections::BTreeSet::new();
167 let mut stack: Vec<Frame> = Vec::new();
168
169 let deps = requires_of(target).ok_or_else(|| DepError::Missing {
170 task: target.to_string(),
171 required_by: target.to_string(),
172 })?;
173 on_stack.insert(target.to_string());
174 stack.push(Frame {
175 name: target.to_string(),
176 deps,
177 next: 0,
178 });
179
180 loop {
181 let descend = {
184 let Some(frame) = stack.last_mut() else { break };
185 if frame.next < frame.deps.len() {
186 let dep = frame.deps[frame.next].clone();
187 frame.next += 1;
188 Some(dep)
189 } else {
190 None
191 }
192 };
193 match descend {
194 Some(dep) => {
195 if done.contains(&dep) {
196 continue; }
198 if on_stack.contains(&dep) {
199 return Err(DepError::Cycle(dep));
200 }
201 let required_by = stack.last().expect("a top frame exists").name.clone();
202 let deps = requires_of(&dep).ok_or(DepError::Missing {
203 task: dep.clone(),
204 required_by,
205 })?;
206 on_stack.insert(dep.clone());
207 stack.push(Frame {
208 name: dep,
209 deps,
210 next: 0,
211 });
212 }
213 None => {
214 let frame = stack.pop().expect("a top frame exists");
215 on_stack.remove(&frame.name);
216 done.insert(frame.name.clone());
217 order.push(frame.name);
218 }
219 }
220 }
221 Ok(order)
222}
223
224impl Task {
225 pub fn script_arg_templates(&self) -> Vec<&str> {
234 let declared: std::collections::BTreeSet<&str> =
235 self.args.iter().map(|a| a.name.as_str()).collect();
236 let mut found: Vec<&str> = Vec::new();
237 let mut rest = self.script.as_str();
238 while let Some(open) = rest.find("{{") {
239 let after = &rest[open + 2..];
240 let Some(close) = after.find("}}") else { break };
241 let tok = after[..close].trim();
242 if declared.contains(tok) && !found.contains(&tok) {
243 found.push(tok);
244 }
245 rest = &after[close + 2..];
246 }
247 found
248 }
249}
250
251impl TaskFile {
252 pub fn task(&self, name: &str) -> Option<&Task> {
256 self.tasks.iter().find(|t| t.name == name)
257 }
258
259 pub fn agent_tasks(&self) -> impl Iterator<Item = &Task> {
265 self.tasks.iter().filter(|t| t.agent_allow)
266 }
267
268 pub fn invocation(
288 &self,
289 task: &Task,
290 args: &BTreeMap<String, String>,
291 cwd: &Path,
292 task_file_dir: Option<&Path>,
293 ) -> Result<Invocation, MissingArg> {
294 let mut effective = args.clone();
296 for a in &task.args {
297 if !effective.contains_key(&a.name) {
298 if a.variadic {
299 effective.insert(a.name.clone(), String::new());
300 } else if let Some(d) = &a.default {
301 effective.insert(a.name.clone(), d.clone());
302 } else {
303 return Err(MissingArg(a.name.clone()));
304 }
305 }
306 }
307
308 let script = substitute(&task.script, &effective);
309 let (program, flag) = interpreter(&task.lang);
310
311 let mut env = self.env.clone();
314 env.extend(task.env.iter().cloned());
315 env.extend(effective.iter().map(|(k, v)| (k.clone(), v.clone())));
316
317 let run_cwd = match &task.dir {
318 None => cwd.to_path_buf(),
319 Some(d) => {
320 let d = substitute(d, &effective);
321 let d = d.trim().to_string();
322 let p = Path::new(&d);
323 if p.is_absolute() {
324 p.to_path_buf()
325 } else {
326 task_file_dir.unwrap_or(cwd).join(&d)
327 }
328 }
329 };
330
331 Ok(Invocation {
332 program: program.to_string(),
333 args: vec![flag.to_string(), script],
334 env,
335 cwd: run_cwd,
336 })
337 }
338
339 pub fn bind(
344 task: &Task,
345 positional: &[String],
346 ) -> Result<BTreeMap<String, String>, MissingArg> {
347 let mut map = BTreeMap::new();
348 let mut i = 0;
349 for a in &task.args {
350 if a.variadic {
351 map.insert(
352 a.name.clone(),
353 positional[i.min(positional.len())..].join(" "),
354 );
355 i = positional.len();
356 } else if i < positional.len() {
357 map.insert(a.name.clone(), positional[i].clone());
358 i += 1;
359 } else if let Some(d) = &a.default {
360 map.insert(a.name.clone(), d.clone());
361 } else {
362 return Err(MissingArg(a.name.clone()));
363 }
364 }
365 Ok(map)
366 }
367}
368
369impl Invocation {
370 pub fn run(&self) -> std::io::Result<std::process::ExitStatus> {
376 std::process::Command::new(&self.program)
377 .args(&self.args)
378 .envs(self.env.iter().map(|(k, v)| (k, v)))
379 .current_dir(&self.cwd)
380 .status()
381 }
382}
383
384fn interpreter(lang: &str) -> (&'static str, &'static str) {
387 match lang.trim().to_ascii_lowercase().as_str() {
388 "" | "sh" | "shell" => ("sh", "-c"),
389 "bash" => ("bash", "-c"),
390 "zsh" => ("zsh", "-c"),
391 "fish" => ("fish", "-c"),
392 "python" | "py" | "python3" => ("python3", "-c"),
393 "ruby" => ("ruby", "-e"),
394 "node" | "js" | "javascript" => ("node", "-e"),
395 _ => ("sh", "-c"),
396 }
397}
398
399fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
403 let mut out = String::with_capacity(src.len());
404 let mut rest = src;
405 while let Some(open) = rest.find("{{") {
406 out.push_str(&rest[..open]);
407 let after = &rest[open + 2..];
408 if let Some(close) = after.find("}}") {
409 let name = after[..close].trim();
410 match args.get(name) {
411 Some(v) => out.push_str(v),
412 None => {
413 out.push_str("{{");
415 out.push_str(&after[..close]);
416 out.push_str("}}");
417 }
418 }
419 rest = &after[close + 2..];
420 } else {
421 out.push_str("{{");
422 rest = after;
423 }
424 }
425 out.push_str(rest);
426 out
427}
428
429pub fn parse(src: &str) -> TaskFile {
435 let mut file = TaskFile::default();
436 let mut cur: Option<Task> = None;
437 let mut in_fence = false;
438 let mut fence_marker = "";
439 let mut have_script = false; let mut script = String::new();
441
442 for raw in src.split('\n') {
443 let line = raw.strip_suffix('\r').unwrap_or(raw); if in_fence {
445 if is_closing_fence(line, fence_marker) {
449 in_fence = false;
450 if let Some(t) = cur.as_mut()
451 && !have_script
452 {
453 t.script = std::mem::take(&mut script);
454 have_script = true;
455 }
456 script.clear();
457 } else if cur.is_some() && !have_script {
458 script.push_str(line);
459 script.push('\n');
460 }
461 continue;
462 }
463 if let Some(marker) = opening_fence(line) {
464 in_fence = true;
465 fence_marker = marker;
466 if let Some(t) = cur.as_mut()
467 && !have_script
468 {
469 t.lang = info_string(line, marker);
470 }
471 script.clear();
472 continue;
473 }
474 if let Some(name) = heading(line) {
475 finalize(cur.take(), &mut file);
476 cur = Some(Task {
477 name,
478 ..Task::default()
479 });
480 have_script = false;
481 continue;
482 }
483 apply_line(line, cur.as_mut(), &mut file.env);
484 }
485 if in_fence {
488 if let Some(t) = cur.as_mut()
489 && !have_script
490 {
491 t.script = std::mem::take(&mut script);
492 }
493 let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
494 file.warnings
495 .push(format!("unterminated code fence in task {name:?}"));
496 }
497 finalize(cur.take(), &mut file);
498 file
499}
500
501fn finalize(task: Option<Task>, file: &mut TaskFile) {
505 let Some(mut t) = task else {
506 return;
507 };
508 if t.script.is_empty() {
509 file.env.append(&mut t.env); return;
511 }
512 t.description = t.description.trim().to_string();
513 if file.tasks.iter().any(|x| x.name == t.name) {
514 file.warnings.push(format!(
515 "duplicate task {:?}; the first defined wins",
516 t.name
517 ));
518 }
519 if !is_known_lang(&t.lang) {
520 file.warnings.push(format!(
521 "task {:?}: fenced language {:?} is not a known interpreter; running as sh",
522 t.name, t.lang
523 ));
524 }
525 file.tasks.push(t);
526}
527
528fn is_known_lang(lang: &str) -> bool {
530 matches!(
531 lang.trim().to_ascii_lowercase().as_str(),
532 "" | "sh"
533 | "shell"
534 | "bash"
535 | "zsh"
536 | "fish"
537 | "python"
538 | "py"
539 | "python3"
540 | "ruby"
541 | "node"
542 | "js"
543 | "javascript"
544 )
545}
546
547fn opening_fence(line: &str) -> Option<&'static str> {
549 let t = line.trim_start();
550 if t.starts_with("```") {
551 Some("```")
552 } else if t.starts_with("~~~") {
553 Some("~~~")
554 } else {
555 None
556 }
557}
558
559fn is_closing_fence(line: &str, marker: &str) -> bool {
562 let ch = marker.as_bytes()[0];
563 let t = line.trim();
564 t.len() >= 3 && t.bytes().all(|b| b == ch)
565}
566
567pub fn find_task_files(start: &Path) -> Vec<(PathBuf, TaskFile)> {
574 let mut found = Vec::new();
575 for dir in start.ancestors() {
576 for name in ["tasks.md", "maskfile.md", "README.md"] {
577 let path = dir.join(name);
578 if let Ok(src) = std::fs::read_to_string(&path) {
579 let tf = parse(&src);
580 if !tf.tasks.is_empty() {
581 found.push((path, tf));
582 break; }
584 }
585 }
586 }
587 found
588}
589
590fn info_string(line: &str, marker: &str) -> String {
592 line.trim_start()
593 .strip_prefix(marker)
594 .unwrap_or("")
595 .split_whitespace()
596 .next()
597 .unwrap_or("")
598 .to_string()
599}
600
601fn heading(line: &str) -> Option<String> {
603 let t = line.trim_start();
604 if !t.starts_with('#') {
605 return None;
606 }
607 let after = t.trim_start_matches('#');
608 if after == t || !after.starts_with(' ') {
610 return None;
611 }
612 Some(after.trim().to_string())
613}
614
615fn apply_line(line: &str, task: Option<&mut Task>, file_env: &mut Vec<(String, String)>) {
619 if let Some((key, value)) = split_key(line) {
620 let value = value.trim();
621 match key.as_str() {
622 "env" | "environment" => {
623 let pairs = parse_env(value);
624 match task {
625 Some(t) => t.env.extend(pairs),
626 None => file_env.extend(pairs), }
628 return;
629 }
630 "dir" | "directory" => {
631 if let Some(t) = task {
632 t.dir = Some(value.to_string());
633 }
634 return;
635 }
636 "args" | "arguments" => {
637 if let Some(t) = task {
638 t.args = parse_args(value);
639 }
640 return;
641 }
642 "requires" | "req" => {
643 if let Some(t) = task {
644 t.requires = value
645 .split(',')
646 .map(|s| s.trim().to_string())
647 .filter(|s| !s.is_empty())
648 .collect();
649 }
650 return;
651 }
652 "agent" => {
653 if let Some(t) = task {
654 t.agent_allow = value.eq_ignore_ascii_case("allow");
655 }
656 return;
657 }
658 _ => {}
659 }
660 }
661 if let Some(t) = task
663 && !line.trim().is_empty()
664 {
665 t.description.push_str(line.trim());
666 t.description.push('\n');
667 }
668}
669
670fn split_key(line: &str) -> Option<(String, &str)> {
675 let colon = line.find(':')?;
676 let key = line[..colon].trim();
677 if key.is_empty() || key.contains(char::is_whitespace) {
678 return None;
679 }
680 Some((key.to_ascii_lowercase(), &line[colon + 1..]))
681}
682
683fn parse_env(value: &str) -> Vec<(String, String)> {
685 value
686 .split(',')
687 .filter_map(|p| {
688 let (k, v) = p.split_once('=')?;
689 let k = k.trim();
690 if k.is_empty() {
691 return None;
692 }
693 Some((k.to_string(), v.trim().to_string()))
694 })
695 .collect()
696}
697
698fn parse_args(value: &str) -> Vec<Arg> {
703 tokenize_args(value)
704 .into_iter()
705 .filter_map(|tok| {
706 let (name, default) = match tok.split_once('=') {
707 Some((n, d)) => (n, Some(unquote(d).to_string())),
708 None => (tok.as_str(), None),
709 };
710 let (name, variadic) = match name.strip_prefix('*') {
711 Some(rest) => (rest, true),
712 None => (name, false),
713 };
714 let name = name.trim();
715 if name.is_empty() {
716 return None;
717 }
718 Some(Arg {
719 name: name.to_string(),
720 variadic,
721 default,
722 })
723 })
724 .collect()
725}
726
727fn tokenize_args(value: &str) -> Vec<String> {
730 let mut out = Vec::new();
731 let mut cur = String::new();
732 let mut quote: Option<char> = None;
733 for c in value.chars() {
734 match quote {
735 Some(q) => {
736 cur.push(c);
737 if c == q {
738 quote = None;
739 }
740 }
741 None if c == '\'' || c == '"' => {
742 cur.push(c);
743 quote = Some(c);
744 }
745 None if c.is_whitespace() => {
746 if !cur.is_empty() {
747 out.push(std::mem::take(&mut cur));
748 }
749 }
750 None => cur.push(c),
751 }
752 }
753 if !cur.is_empty() {
754 out.push(cur);
755 }
756 out
757}
758
759fn unquote(s: &str) -> &str {
761 let s = s.trim();
762 let b = s.as_bytes();
763 if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
764 &s[1..s.len() - 1]
765 } else {
766 s
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
775 pairs
776 .iter()
777 .map(|(k, v)| (k.to_string(), v.to_string()))
778 .collect()
779 }
780
781 #[test]
782 fn parses_named_tasks_with_interpreter() {
783 let tf =
784 parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
785 assert_eq!(tf.tasks.len(), 2);
786 assert_eq!(tf.tasks[0].name, "build");
787 assert_eq!(tf.tasks[0].lang, "sh");
788 assert_eq!(tf.tasks[0].script.trim(), "cargo build");
789 assert_eq!(tf.tasks[1].lang, "zsh");
790 }
791
792 #[test]
793 fn metadata_keys_are_xc_vocabulary_case_insensitive() {
794 let tf = parse(
795 "## deploy\n\nDIR: {{ target }}\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
796 );
797 let t = &tf.tasks[0];
798 assert_eq!(t.dir.as_deref(), Some("{{ target }}"));
799 assert_eq!(
800 t.env,
801 vec![
802 ("REGION".into(), "us".into()),
803 ("TIER".into(), "prod".into())
804 ]
805 );
806 assert_eq!(
807 t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
808 ["target"]
809 );
810 assert_eq!(t.requires, vec!["build", "test"]);
811 assert!(t.agent_allow);
812 }
813
814 #[test]
815 fn agent_gate_is_off_by_default() {
816 let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
817 assert!(!tf.tasks[0].agent_allow);
818 }
819
820 #[test]
821 fn top_level_env_is_hoisted() {
822 let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
823 assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
824 }
825
826 #[test]
827 fn fence_content_is_not_parsed_as_structure() {
828 let tf = parse("## a\n\n```sh\n## not a task\nDir: not-metadata\n```\n");
830 assert_eq!(tf.tasks.len(), 1);
831 assert!(tf.tasks[0].script.contains("## not a task"));
832 assert!(tf.tasks[0].dir.is_none());
833 }
834
835 #[test]
836 fn substitutes_args_and_leaves_unknown_tokens() {
837 let out = substitute(
838 "hello {{ name }} and {{ other }}",
839 &args(&[("name", "world")]),
840 );
841 assert_eq!(out, "hello world and {{ other }}");
842 }
843
844 #[test]
845 fn invocation_substitutes_sets_env_and_picks_interpreter() {
846 let tf = parse("## greet\n\nArgs: name\n\n```zsh\nprint \"hi {{ name }}\"\n```\n");
847 let t = tf.task("greet").unwrap();
848 let inv = tf
849 .invocation(
850 t,
851 &args(&[("name", "sam")]),
852 Path::new("/here"),
853 Some(Path::new("/file")),
854 )
855 .unwrap();
856 assert_eq!(inv.program, "zsh");
857 assert_eq!(inv.args[0], "-c");
858 assert!(inv.args[1].contains("hi sam"));
859 assert!(inv.env.contains(&("name".to_string(), "sam".to_string())));
860 assert_eq!(inv.cwd, Path::new("/here"));
862 }
863
864 #[test]
865 fn a_missing_required_arg_is_an_error() {
866 let tf = parse("## t\n\nArgs: file\n\n```sh\ncat {{ file }}\n```\n");
867 let t = tf.task("t").unwrap();
868 assert_eq!(
869 tf.invocation(t, &args(&[]), Path::new("/here"), None),
870 Err(MissingArg("file".into()))
871 );
872 }
873
874 #[test]
875 fn optional_and_variadic_args_fill_from_defaults() {
876 let tf = parse(
877 "## t\n\nArgs: a b='fallback' *rest\n\n```sh\necho {{ a }} {{ b }} {{ rest }}\n```\n",
878 );
879 let t = tf.task("t").unwrap();
880 assert!(!t.args[0].variadic && t.args[0].default.is_none());
881 assert_eq!(t.args[1].default.as_deref(), Some("fallback"));
882 assert!(t.args[2].variadic);
883 let inv = tf
885 .invocation(t, &args(&[("a", "x")]), Path::new("/here"), None)
886 .unwrap();
887 assert!(inv.args[1].contains("echo x fallback "));
888 let bound =
890 TaskFile::bind(t, &["x".into(), "y".into(), "one".into(), "two".into()]).unwrap();
891 assert_eq!(bound.get("b").map(String::as_str), Some("y"));
892 assert_eq!(bound.get("rest").map(String::as_str), Some("one two"));
893 }
894
895 #[test]
896 fn relative_dir_resolves_against_the_task_file_not_the_cwd() {
897 let tf = parse("## t\n\nDir: sub/dir\n\n```sh\ntrue\n```\n");
898 let t = tf.task("t").unwrap();
899 let inv = tf
900 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
901 .unwrap();
902 assert_eq!(inv.cwd, Path::new("/proj/sub/dir"));
903 }
904
905 #[test]
906 fn dir_dot_pins_to_the_task_file_dir_and_absolute_is_verbatim() {
907 let dot = parse("## t\n\nDir: .\n\n```sh\ntrue\n```\n");
908 let t = dot.task("t").unwrap();
909 let inv = dot
910 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
911 .unwrap();
912 assert_eq!(inv.cwd, Path::new("/proj"));
913
914 let abs = parse("## t\n\nDir: /opt/build\n\n```sh\ntrue\n```\n");
915 let t = abs.task("t").unwrap();
916 let inv = abs
917 .invocation(t, &args(&[]), Path::new("/here"), Some(Path::new("/proj")))
918 .unwrap();
919 assert_eq!(inv.cwd, Path::new("/opt/build"));
920 }
921
922 #[test]
923 fn dir_may_interpolate_an_arg() {
924 let tf = parse("## t\n\nArgs: name\nDir: {{ name }}\n\n```sh\ntrue\n```\n");
925 let t = tf.task("t").unwrap();
926 let inv = tf
927 .invocation(
928 t,
929 &args(&[("name", "out")]),
930 Path::new("/here"),
931 Some(Path::new("/proj")),
932 )
933 .unwrap();
934 assert_eq!(inv.cwd, Path::new("/proj/out"));
935 }
936
937 fn deps_of<'a>(map: &'a [(&str, &[&str])]) -> impl Fn(&str) -> Option<Vec<String>> + 'a {
939 move |name| {
940 map.iter()
941 .find(|(n, _)| *n == name)
942 .map(|(_, ds)| ds.iter().map(|s| s.to_string()).collect())
943 }
944 }
945
946 #[test]
947 fn dependency_order_is_deps_first_target_last() {
948 let g = deps_of(&[("a", &["b", "c"]), ("b", &["c"]), ("c", &[])]);
950 assert_eq!(dependency_order("a", g).unwrap(), ["c", "b", "a"]);
951 }
952
953 #[test]
954 fn dependency_order_dedupes_a_diamond() {
955 let g = deps_of(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
956 let order = dependency_order("a", g).unwrap();
957 assert_eq!(order.iter().filter(|n| *n == "d").count(), 1);
958 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
960 assert!(pos("d") < pos("b") && pos("d") < pos("c"));
961 assert_eq!(order.last().unwrap(), "a");
962 }
963
964 #[test]
965 fn dependency_order_detects_a_cycle() {
966 let g = deps_of(&[("a", &["b"]), ("b", &["a"])]);
967 assert_eq!(dependency_order("a", g), Err(DepError::Cycle("a".into())));
968 }
969
970 #[test]
971 fn dependency_order_flags_a_missing_dependency() {
972 let g = deps_of(&[("a", &["ghost"])]);
973 assert_eq!(
974 dependency_order("a", g),
975 Err(DepError::Missing {
976 task: "ghost".into(),
977 required_by: "a".into(),
978 })
979 );
980 }
981
982 #[test]
983 fn dependency_order_survives_a_pathologically_deep_chain() {
984 const N: usize = 200_000;
987 let order = dependency_order("t0", |n| {
988 let i: usize = n.strip_prefix('t')?.parse().ok()?;
989 Some(if i + 1 < N {
990 vec![format!("t{}", i + 1)]
991 } else {
992 vec![]
993 })
994 })
995 .unwrap();
996 assert_eq!(order.len(), N);
997 assert_eq!(order.first().unwrap(), &format!("t{}", N - 1)); assert_eq!(order.last().unwrap(), "t0"); }
1000
1001 #[test]
1002 fn script_arg_templates_flags_only_declared_args_in_the_script() {
1003 let tf =
1005 parse("## t\n\nArgs: name safe\n\n```sh\necho {{ name }} \"$safe\" {{ other }}\n```\n");
1006 let t = tf.task("t").unwrap();
1007 assert_eq!(t.script_arg_templates(), vec!["name"]);
1008
1009 let safe = parse("## t\n\nArgs: name\n\n```sh\necho \"$name\"\n```\n");
1011 assert!(safe.task("t").unwrap().script_arg_templates().is_empty());
1012 }
1013
1014 #[test]
1015 fn crlf_scripts_are_normalized() {
1016 let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
1017 assert_eq!(tf.tasks[0].script, "echo foo\necho bar\n");
1018 assert!(!tf.tasks[0].script.contains('\r'));
1019 }
1020
1021 #[test]
1022 fn an_unterminated_fence_warns_but_keeps_the_task() {
1023 let tf = parse("## a\n\n```sh\necho hi\n"); assert_eq!(tf.tasks.len(), 1);
1025 assert_eq!(tf.tasks[0].script.trim(), "echo hi");
1026 assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
1027 }
1028
1029 #[test]
1030 fn a_stray_fence_open_does_not_close_an_unterminated_block() {
1031 let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
1034 assert!(tf.tasks[0].script.contains("one"));
1035 assert!(tf.tasks[0].script.contains("```sh\ntwo"));
1036 }
1037
1038 #[test]
1039 fn indented_metadata_is_recognized() {
1040 let tf = parse("## a\n\n- steps:\n Dir: sub\n\n```sh\ntrue\n```\n");
1041 assert_eq!(tf.tasks[0].dir.as_deref(), Some("sub"));
1042 }
1043
1044 #[test]
1045 fn duplicate_and_unknown_lang_warn() {
1046 let tf = parse("## a\n\n```json\n{}\n```\n\n## a\n\n```sh\ntrue\n```\n");
1047 assert_eq!(tf.tasks.len(), 2);
1048 assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1049 assert!(tf.warnings.iter().any(|w| w.contains("json")));
1050 }
1051
1052 #[test]
1053 fn agent_tasks_filters_to_the_gated_ones() {
1054 let tf =
1055 parse("## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n");
1056 let names: Vec<_> = tf.agent_tasks().map(|t| t.name.as_str()).collect();
1057 assert_eq!(names, ["open"]);
1058 }
1059
1060 #[test]
1061 fn find_task_files_layers_child_over_parent() {
1062 let base = std::env::temp_dir().join(format!("mdtask-t-{}", std::process::id()));
1065 let child = base.join("child");
1066 std::fs::create_dir_all(&child).unwrap();
1067 std::fs::write(
1068 base.join("tasks.md"),
1069 "## base\n\n```sh\ntrue\n```\n\n## shared\n\n```sh\necho parent\n```\n",
1070 )
1071 .unwrap();
1072 std::fs::write(
1073 child.join("tasks.md"),
1074 "## shared\n\n```sh\necho child\n```\n\n## only\n\n```sh\ntrue\n```\n",
1075 )
1076 .unwrap();
1077
1078 let files = find_task_files(&child);
1079 assert_eq!(files.len(), 2, "child and parent files found");
1080 assert!(files[0].0.starts_with(&child));
1082 assert_eq!(
1083 files[0].1.task("shared").unwrap().script.trim(),
1084 "echo child"
1085 );
1086 assert!(files[1].1.task("base").is_some());
1088 std::fs::remove_dir_all(&base).ok();
1089 }
1090}