1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use crate::cancel::Cancel;
5use crate::deps::{Step, dependency_order};
6use crate::model::{Invocation, Job, RunError, TaskFile};
7
8pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
15 let mut seen = BTreeSet::new();
16 let mut out = Vec::new();
17 for (_, tf) in files {
18 for job in &tf.jobs {
19 if seen.insert(job.name.clone()) && job.agent_allow {
20 out.push(job);
21 }
22 }
23 }
24 out
25}
26
27fn trusted_lookup<'a>(
32 files: &'a [(PathBuf, TaskFile)],
33 name: &str,
34) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
35 files
36 .iter()
37 .find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
38}
39
40fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<Step>, RunError> {
43 if trusted_lookup(files, target).is_none() {
44 return Err(RunError::NotFound(target.to_string()));
45 }
46 dependency_order(target, |n| {
47 trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
48 })
49 .map_err(RunError::Dependency)
50}
51
52fn plan_invocations<'a>(
69 order: &[Step],
70 target: &str,
71 args: &[String],
72 cwd: &Path,
73 lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
74) -> Result<Vec<Invocation>, RunError> {
75 let scope = {
78 let (_, job, _) = lookup(target).expect("the target resolves");
79 TaskFile::bind(job, args).map_err(RunError::MissingArg)?
80 };
81
82 let mut seen = BTreeSet::new();
88 let mut resolved: Vec<(&str, Vec<String>)> = Vec::with_capacity(order.len());
89 for step in order {
90 let step_args: Vec<String> = if step.name == target {
91 args.to_vec()
92 } else {
93 step.args.iter().map(|a| substitute(a, &scope)).collect()
94 };
95 if seen.insert((step.name.clone(), step_args.clone())) {
96 resolved.push((step.name.as_str(), step_args));
97 }
98 }
99
100 let mut plan = Vec::with_capacity(resolved.len());
101 for (name, step_args) in resolved {
102 let (tf, job, dir) = lookup(name).expect("a resolved name still resolves");
103 let values = TaskFile::bind(job, &step_args).map_err(RunError::MissingArg)?;
104 let inv = tf
105 .invocation(job, &values, cwd, dir)
106 .map_err(RunError::MissingArg)?;
107 plan.push(inv);
108 }
109 Ok(plan)
110}
111
112fn run_plan_captured(
117 plan: &[Invocation],
118 cancel: Option<&Cancel>,
119) -> Result<std::process::Output, RunError> {
120 let mut stdout = Vec::new();
121 let mut stderr = Vec::new();
122 let mut status = None;
123 for inv in plan {
124 if cancel.is_some_and(Cancel::is_cancelled) {
128 return Err(RunError::Cancelled);
129 }
130 let out = inv.run_captured(cancel).map_err(|e| inv.spawn_error(e))?;
131 stdout.extend_from_slice(&out.stdout);
132 stderr.extend_from_slice(&out.stderr);
133 if cancel.is_some_and(Cancel::is_cancelled) {
136 return Err(RunError::Cancelled);
137 }
138 let failed = !out.status.success();
139 status = Some(out.status);
140 if failed {
141 break;
142 }
143 }
144 Ok(std::process::Output {
145 status: status.expect("the plan always contains the target"),
146 stdout,
147 stderr,
148 })
149}
150
151pub fn run(
159 files: &[(PathBuf, TaskFile)],
160 name: &str,
161 args: &[String],
162 cwd: &Path,
163) -> Result<std::process::ExitStatus, RunError> {
164 let order = trusted_order(files, name)?;
165 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
166 let mut last = None;
167 for inv in &plan {
168 let status = inv.run_inherit().map_err(|e| inv.spawn_error(e))?;
169 if !status.success() {
170 return Ok(status);
171 }
172 last = Some(status);
173 }
174 Ok(last.expect("the plan always contains the target"))
175}
176
177pub fn run_captured(
183 files: &[(PathBuf, TaskFile)],
184 name: &str,
185 args: &[String],
186 cwd: &Path,
187) -> Result<std::process::Output, RunError> {
188 let order = trusted_order(files, name)?;
189 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
190 run_plan_captured(&plan, None)
191}
192
193pub fn run_agent(
214 files: &[(PathBuf, TaskFile)],
215 name: &str,
216 args: &[String],
217 cwd: &Path,
218) -> Result<std::process::Output, RunError> {
219 run_agent_inner(files, name, args, cwd, None)
220}
221
222pub fn run_agent_cancellable(
234 files: &[(PathBuf, TaskFile)],
235 name: &str,
236 args: &[String],
237 cwd: &Path,
238 cancel: &Cancel,
239) -> Result<std::process::Output, RunError> {
240 run_agent_inner(files, name, args, cwd, Some(cancel))
241}
242
243fn run_agent_inner(
244 files: &[(PathBuf, TaskFile)],
245 name: &str,
246 args: &[String],
247 cwd: &Path,
248 cancel: Option<&Cancel>,
249) -> Result<std::process::Output, RunError> {
250 let mut target: Option<(&Path, &TaskFile, &Job)> = None;
253 for (p, tf) in files {
254 if let Some(job) = tf.job(name) {
255 if job.agent_allow {
256 target = Some((p.as_path(), tf, job));
257 }
258 break; }
260 }
261 let Some((target_path, target_tf, target_job)) = target else {
262 return Err(RunError::NotAllowed(name.to_string()));
263 };
264
265 let templated = target_job.script_arg_templates();
267 if !templated.is_empty() {
268 return Err(RunError::Injects {
269 task: name.to_string(),
270 args: templated.iter().map(|s| s.to_string()).collect(),
271 });
272 }
273
274 let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
277 .map_err(RunError::Dependency)?;
278 let dir = target_path.parent();
279 let plan = plan_invocations(&order, name, args, cwd, |n| {
280 target_tf.job(n).map(|j| (target_tf, j, dir))
281 })?;
282 run_plan_captured(&plan, cancel)
283}
284
285impl Invocation {
286 fn spawn_error(&self, source: std::io::Error) -> RunError {
288 RunError::Io {
289 task: self.task.clone(),
290 program: self.program.clone(),
291 cwd: self.cwd.clone(),
292 source,
293 }
294 }
295
296 fn command(&self) -> std::process::Command {
298 let mut cmd = std::process::Command::new(&self.program);
299 cmd.args(&self.args)
300 .envs(self.env.iter().map(|(k, v)| (k, v)))
301 .current_dir(&self.cwd);
302 cmd
303 }
304
305 fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
307 self.command().status()
308 }
309
310 fn run_captured(&self, cancel: Option<&Cancel>) -> std::io::Result<std::process::Output> {
318 let Some(cancel) = cancel else {
319 return self.command().output();
320 };
321
322 let mut cmd = self.command();
323 cmd.stdin(std::process::Stdio::null())
327 .stdout(std::process::Stdio::piped())
328 .stderr(std::process::Stdio::piped());
329 #[cfg(unix)]
330 {
331 use std::os::unix::process::CommandExt;
332 cmd.process_group(0);
334 }
335
336 let child = cmd.spawn()?;
337 cancel.entered(child.id());
339 let out = child.wait_with_output();
340 cancel.left();
341 out
342 }
343}
344
345pub(crate) fn interpreter(lang: &str) -> Interpreter {
363 let (program, flag, prelude, recognized) = match lang.trim().to_ascii_lowercase().as_str() {
364 "" | "sh" | "shell" => ("sh", "-c", Some("set -e"), true),
365 "bash" => ("bash", "-c", Some("set -e\nset -o pipefail"), true),
366 "zsh" => ("zsh", "-c", Some("set -e\nset -o pipefail"), true),
367 "fish" => ("fish", "-c", None, true),
368 "python" | "py" | "python3" => ("python3", "-c", None, true),
369 "ruby" => ("ruby", "-e", None, true),
370 "node" | "js" | "javascript" => ("node", "-e", None, true),
371 _ => ("sh", "-c", Some("set -e"), false),
376 };
377 Interpreter {
378 program,
379 flag,
380 prelude,
381 recognized,
382 }
383}
384
385pub(crate) struct Interpreter {
387 pub(crate) program: &'static str,
388 pub(crate) flag: &'static str,
389 pub(crate) prelude: Option<&'static str>,
412 pub(crate) recognized: bool,
418}
419
420pub(crate) fn is_known_lang(lang: &str) -> bool {
424 interpreter(lang).recognized
425}
426
427pub(crate) fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
431 let mut out = String::with_capacity(src.len());
432 let mut rest = src;
433 while let Some(open) = rest.find("{{") {
434 out.push_str(&rest[..open]);
435 let after = &rest[open + 2..];
436 if let Some(close) = after.find("}}") {
437 let name = after[..close].trim();
438 match args.get(name) {
439 Some(v) => out.push_str(v),
440 None => {
441 out.push_str("{{");
443 out.push_str(&after[..close]);
444 out.push_str("}}");
445 }
446 }
447 rest = &after[close + 2..];
448 } else {
449 out.push_str("{{");
450 rest = after;
451 }
452 }
453 out.push_str(rest);
454 out
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[cfg(unix)]
469 #[test]
470 fn cancelling_kills_the_script_s_children_not_just_the_shell() {
471 let dir = std::env::temp_dir().join(format!("mdtask-cancel-{}", std::process::id()));
472 std::fs::create_dir_all(&dir).unwrap();
473 let witness = dir.join("survived");
474 let src = format!(
475 "## t\n\nAgent: allow\n\n```sh\n(sleep 5; touch {}) &\nwait\n```\n",
476 witness.display()
477 );
478 let files = vec![(dir.join("tasks.md"), parse(&src))];
479
480 let cancel = Cancel::new();
481 let handle = {
482 let cancel = cancel.clone();
483 let dir = dir.clone();
484 std::thread::spawn(move || run_agent_cancellable(&files, "t", &[], &dir, &cancel))
485 };
486
487 std::thread::sleep(std::time::Duration::from_millis(300));
488 let started = std::time::Instant::now();
489 cancel.cancel();
490 let result = handle.join().expect("the run thread did not panic");
491 let took = started.elapsed();
492
493 assert!(
494 matches!(result, Err(RunError::Cancelled)),
495 "expected Cancelled, got {result:?}"
496 );
497 assert!(
498 took < std::time::Duration::from_secs(4),
499 "cancelling should not wait out the task: took {took:?}"
500 );
501
502 std::thread::sleep(std::time::Duration::from_secs(6));
504 let survived = witness.exists();
505 std::fs::remove_dir_all(&dir).ok();
506 assert!(!survived, "the grandchild outlived the cancellation");
507 }
508
509 #[cfg(unix)]
512 #[test]
513 fn cancelling_stops_the_rest_of_a_requires_chain() {
514 let dir = std::env::temp_dir().join(format!("mdtask-chain-cancel-{}", std::process::id()));
515 std::fs::create_dir_all(&dir).unwrap();
516 let witness = dir.join("second-ran");
517 let src = format!(
518 "## first\n\n```sh\nsleep 3\n```\n\n## second\n\nAgent: allow\nRequires: first\n\n```sh\ntouch {}\n```\n",
519 witness.display()
520 );
521 let files = vec![(dir.join("tasks.md"), parse(&src))];
522
523 let cancel = Cancel::new();
524 let handle = {
525 let cancel = cancel.clone();
526 let dir = dir.clone();
527 std::thread::spawn(move || run_agent_cancellable(&files, "second", &[], &dir, &cancel))
528 };
529 std::thread::sleep(std::time::Duration::from_millis(300));
530 cancel.cancel();
531 let result = handle.join().expect("the run thread did not panic");
532
533 let ran = witness.exists();
534 std::fs::remove_dir_all(&dir).ok();
535 assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
536 assert!(!ran, "the target ran even though the chain was cancelled");
537 }
538
539 #[cfg(unix)]
542 #[test]
543 fn a_run_cancelled_before_it_starts_never_spawns() {
544 let dir = std::env::temp_dir().join(format!("mdtask-precancel-{}", std::process::id()));
545 std::fs::create_dir_all(&dir).unwrap();
546 let witness = dir.join("ran");
547 let src = format!(
548 "## t\n\nAgent: allow\n\n```sh\ntouch {}\n```\n",
549 witness.display()
550 );
551 let files = vec![(dir.join("tasks.md"), parse(&src))];
552
553 let cancel = Cancel::new();
554 cancel.cancel();
555 let result = run_agent_cancellable(&files, "t", &[], &dir, &cancel);
556
557 let ran = witness.exists();
558 std::fs::remove_dir_all(&dir).ok();
559 assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
560 assert!(!ran, "the task ran despite being cancelled first");
561 }
562
563 #[test]
566 fn a_run_with_no_handle_still_completes_normally() {
567 let f = files(&[(
568 "tasks.md",
569 "## t\n\nAgent: allow\n\n```sh\necho done\n```\n",
570 )]);
571 let out = run_agent(&f, "t", &[], Path::new(".")).unwrap();
572 assert!(out.status.success());
573 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
574 }
575 use crate::model::DepError;
576 use crate::parse::parse;
577
578 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
579 pairs
580 .iter()
581 .map(|(k, v)| (k.to_string(), v.to_string()))
582 .collect()
583 }
584
585 fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
586 pairs
587 .iter()
588 .map(|(path, src)| (PathBuf::from(path), parse(src)))
589 .collect()
590 }
591
592 fn plan_for(src: &str, target: &str, args: &[&str]) -> Vec<Invocation> {
593 let files = vec![(PathBuf::from("tasks.md"), parse(src))];
594 let order = trusted_order(&files, target).expect("resolves");
595 let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
596 plan_invocations(&order, target, &args, Path::new("."), |n| {
597 trusted_lookup(&files, n)
598 })
599 .expect("plans")
600 }
601
602 fn env_of<'a>(inv: &'a Invocation, key: &str) -> Option<&'a str> {
604 inv.env
605 .iter()
606 .find(|(k, _)| k == key)
607 .map(|(_, v)| v.as_str())
608 }
609
610 const PARAM: &str = "\
611## dist
612
613Args: module
614
615```sh
616true
617```
618
619## lint
620
621```sh
622true
623```
624
625## release
626
627Args: module
628Requires: lint, (dist {{ module }})
629
630```sh
631true
632```
633";
634
635 #[test]
636 fn substitutes_args_and_leaves_unknown_tokens() {
637 let out = substitute(
638 "hello {{ name }} and {{ other }}",
639 &args(&[("name", "world")]),
640 );
641 assert_eq!(out, "hello world and {{ other }}");
642 }
643
644 #[test]
648 fn a_placeholder_resolves_to_the_invocations_argument() {
649 let plan = plan_for(PARAM, "release", &["foundry"]);
650 assert_eq!(plan.len(), 3, "lint, dist, release");
651 assert_eq!(env_of(&plan[1], "module"), Some("foundry"), "dist got it");
652 assert_eq!(
653 env_of(&plan[2], "module"),
654 Some("foundry"),
655 "and so did release"
656 );
657 }
658
659 #[test]
663 fn the_same_task_with_different_arguments_runs_twice() {
664 let src = PARAM.replace(
665 "Requires: lint, (dist {{ module }})",
666 "Requires: (dist {{ module }}), (dist {{ module }}-docs)",
667 );
668 let plan = plan_for(&src, "release", &["foundry"]);
669 assert_eq!(plan.len(), 3);
670 assert_eq!(env_of(&plan[0], "module"), Some("foundry"));
671 assert_eq!(env_of(&plan[1], "module"), Some("foundry-docs"));
672 }
673
674 #[test]
675 fn the_same_task_with_the_same_arguments_still_runs_once() {
676 let src = PARAM.replace(
677 "Requires: lint, (dist {{ module }})",
678 "Requires: (dist {{ module }}), (dist foundry)",
679 );
680 let plan = plan_for(&src, "release", &["foundry"]);
681 assert_eq!(plan.len(), 2, "the two dist steps are the same work");
682 }
683
684 #[test]
688 fn an_unknown_placeholder_is_left_alone() {
689 let src = PARAM.replace("(dist {{ module }})", "(dist {{ nonesuch }})");
690 let plan = plan_for(&src, "release", &["foundry"]);
691 assert_eq!(env_of(&plan[1], "module"), Some("{{ nonesuch }}"));
692 }
693
694 #[test]
698 fn a_self_reference_with_different_arguments_is_still_a_cycle() {
699 let files = vec![(
700 PathBuf::from("tasks.md"),
701 parse("## a\n\nArgs: x\nRequires: (a {{ x }}-more)\n\n```sh\ntrue\n```\n"),
702 )];
703 assert!(matches!(
704 trusted_order(&files, "a"),
705 Err(RunError::Dependency(DepError::Cycle(_)))
706 ));
707 }
708
709 #[test]
713 fn the_sh_fallback_is_strict() {
714 for lang in [
715 "console",
716 "shell-session",
717 "terminal",
718 "cmd",
719 "bash5",
720 "toml",
721 "json",
722 ] {
723 let i = interpreter(lang);
724 assert_eq!(i.program, "sh", "{lang:?} should fall back to sh");
725 assert!(!i.recognized, "{lang:?} is not a named language");
726 assert!(
727 i.prelude.is_some_and(|p| p.contains("set -e")),
728 "{lang:?} falls back to sh without failure detection"
729 );
730 }
731 }
732
733 #[test]
738 fn every_language_that_runs_a_shell_detects_failure() {
739 for lang in ["", "sh", "shell", "bash", "zsh", "console", "nonsense-tag"] {
743 let i = interpreter(lang);
744 if matches!(i.program, "sh" | "bash" | "zsh") {
745 assert!(
746 i.prelude.is_some_and(|p| p.contains("set -e")),
747 "{lang:?} runs {} with no failure detection",
748 i.program
749 );
750 }
751 }
752 }
753
754 #[test]
755 fn agent_jobs_filters_to_the_gated_ones() {
756 let f = files(&[(
757 "tasks.md",
758 "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
759 )]);
760 let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
761 assert_eq!(names, ["open"]);
762 }
763
764 #[test]
765 fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
766 let f = files(&[
769 ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
770 (
771 "tasks.md",
772 "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
773 ),
774 ]);
775 assert!(agent_jobs(&f).is_empty());
776 }
777
778 #[test]
779 fn run_captured_returns_stdout() {
780 let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
781 let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
782 assert!(out.status.success());
783 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
784 }
785
786 #[test]
787 fn run_captured_runs_requires_deps_first() {
788 let f = files(&[(
789 "tasks.md",
790 "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
791 )]);
792 let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
793 let text = String::from_utf8_lossy(&out.stdout);
794 let bpos = text.find('B').expect("B in output");
796 let apos = text.find('A').expect("A in output");
797 assert!(bpos < apos, "deps must run first: {text}");
798 }
799
800 #[test]
801 fn run_reports_an_unknown_target_as_not_found() {
802 let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
803 match run_captured(&f, "ghost", &[], Path::new(".")) {
804 Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
805 other => panic!("expected NotFound, got {other:?}"),
806 }
807 }
808
809 #[test]
812 fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
813 let f = files(&[
817 ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
818 (
819 "tasks.md",
820 "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
821 ),
822 ]);
823 let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
824 let text = String::from_utf8_lossy(&out.stdout);
825 assert!(text.contains("real-build"), "got: {text}");
826 assert!(text.contains("real-deploy"), "got: {text}");
827 assert!(!text.contains("PWNED"), "nearer build ran: {text}");
828 assert!(out.status.success());
829 }
830
831 #[test]
832 fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
833 let f = files(&[(
836 "tasks.md",
837 "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
838 )]);
839 match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
840 Err(RunError::Injects { task, args }) => {
841 assert_eq!(task, "greet");
842 assert_eq!(args, vec!["name".to_string()]);
843 }
844 other => panic!("expected Injects, got {other:?}"),
845 }
846 }
847
848 #[test]
849 fn run_agent_refuses_a_non_allowed_target() {
850 let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
851 match run_agent(&f, "secret", &[], Path::new(".")) {
852 Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
853 other => panic!("expected NotAllowed, got {other:?}"),
854 }
855 }
856
857 #[test]
858 fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
859 let f = files(&[
861 ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
862 (
863 "tasks.md",
864 "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
865 ),
866 ]);
867 match run_agent(&f, "deploy", &[], Path::new(".")) {
868 Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
869 other => panic!("expected NotAllowed, got {other:?}"),
870 }
871 }
872
873 #[test]
878 fn a_failing_early_step_fails_the_job() {
879 let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
880 let out = run_captured(
881 &[(PathBuf::from("tasks.md"), tf)],
882 "check",
883 &[],
884 Path::new("."),
885 )
886 .expect("runs");
887 assert!(
888 !out.status.success(),
889 "a job whose first command fails must not report success"
890 );
891 }
892
893 #[test]
894 fn no_strict_restores_the_old_lenient_behavior() {
895 let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
896 let out = run_captured(
897 &[(PathBuf::from("tasks.md"), tf)],
898 "check",
899 &[],
900 Path::new("."),
901 )
902 .expect("runs");
903 assert!(
904 out.status.success(),
905 "no-strict should exit with the last command's status"
906 );
907 }
908
909 #[test]
910 fn a_passing_job_is_unaffected() {
911 let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
912 let out = run_captured(
913 &[(PathBuf::from("tasks.md"), tf)],
914 "ok",
915 &[],
916 Path::new("."),
917 )
918 .expect("runs");
919 assert!(out.status.success());
920 assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
921 }
922
923 #[test]
926 fn a_hand_written_prelude_still_works() {
927 let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
928 let out = run_captured(
929 &[(PathBuf::from("tasks.md"), tf)],
930 "ok",
931 &[],
932 Path::new("."),
933 )
934 .expect("runs");
935 assert!(out.status.success());
936 }
937
938 #[test]
941 fn plain_sh_does_not_get_pipefail() {
942 assert_eq!(interpreter("sh").prelude, Some("set -e"));
943 assert_eq!(interpreter("").prelude, Some("set -e"));
944 assert!(interpreter("bash").prelude.unwrap().contains("pipefail"));
945 assert!(interpreter("zsh").prelude.unwrap().contains("pipefail"));
946 }
947
948 #[test]
951 fn non_shells_get_no_prelude() {
952 for lang in ["python", "ruby", "node", "fish"] {
953 assert_eq!(
954 interpreter(lang).prelude,
955 None,
956 "{lang} must not be given shell syntax"
957 );
958 }
959 }
960
961 #[test]
964 fn a_python_job_is_untouched() {
965 let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
966 let out = run_captured(
967 &[(PathBuf::from("tasks.md"), tf)],
968 "py",
969 &[],
970 Path::new("."),
971 )
972 .expect("runs");
973 assert!(out.status.success());
974 assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
975 }
976}