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 invalid: Vec<String> = job
107 .args
108 .iter()
109 .filter(|a| !a.is_valid_name())
110 .map(|a| a.name.clone())
111 .collect();
112 if !invalid.is_empty() {
113 return Err(RunError::InvalidArgName {
114 task: name.to_string(),
115 args: invalid,
116 });
117 }
118 let values = TaskFile::bind(job, &step_args).map_err(RunError::MissingArg)?;
119 let inv = tf
120 .invocation(job, &values, cwd, dir)
121 .map_err(RunError::MissingArg)?;
122 plan.push(inv);
123 }
124 Ok(plan)
125}
126
127fn run_plan_captured(
132 plan: &[Invocation],
133 cancel: Option<&Cancel>,
134) -> Result<std::process::Output, RunError> {
135 let mut stdout = Vec::new();
136 let mut stderr = Vec::new();
137 let mut status = None;
138 for inv in plan {
139 if cancel.is_some_and(Cancel::is_cancelled) {
143 return Err(RunError::Cancelled);
144 }
145 let out = inv.run_captured(cancel).map_err(|e| inv.spawn_error(e))?;
146 stdout.extend_from_slice(&out.stdout);
147 stderr.extend_from_slice(&out.stderr);
148 if cancel.is_some_and(Cancel::is_cancelled) {
151 return Err(RunError::Cancelled);
152 }
153 let failed = !out.status.success();
154 status = Some(out.status);
155 if failed {
156 break;
157 }
158 }
159 Ok(std::process::Output {
160 status: status.expect("the plan always contains the target"),
161 stdout,
162 stderr,
163 })
164}
165
166pub fn run(
174 files: &[(PathBuf, TaskFile)],
175 name: &str,
176 args: &[String],
177 cwd: &Path,
178) -> Result<std::process::ExitStatus, RunError> {
179 let order = trusted_order(files, name)?;
180 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
181 let mut last = None;
182 for inv in &plan {
183 let status = inv.run_inherit().map_err(|e| inv.spawn_error(e))?;
184 if !status.success() {
185 return Ok(status);
186 }
187 last = Some(status);
188 }
189 Ok(last.expect("the plan always contains the target"))
190}
191
192pub fn run_captured(
198 files: &[(PathBuf, TaskFile)],
199 name: &str,
200 args: &[String],
201 cwd: &Path,
202) -> Result<std::process::Output, RunError> {
203 let order = trusted_order(files, name)?;
204 let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
205 run_plan_captured(&plan, None)
206}
207
208pub fn run_agent(
229 files: &[(PathBuf, TaskFile)],
230 name: &str,
231 args: &[String],
232 cwd: &Path,
233) -> Result<std::process::Output, RunError> {
234 run_agent_inner(files, name, args, cwd, None)
235}
236
237pub fn run_agent_cancellable(
249 files: &[(PathBuf, TaskFile)],
250 name: &str,
251 args: &[String],
252 cwd: &Path,
253 cancel: &Cancel,
254) -> Result<std::process::Output, RunError> {
255 run_agent_inner(files, name, args, cwd, Some(cancel))
256}
257
258fn run_agent_inner(
259 files: &[(PathBuf, TaskFile)],
260 name: &str,
261 args: &[String],
262 cwd: &Path,
263 cancel: Option<&Cancel>,
264) -> Result<std::process::Output, RunError> {
265 let mut target: Option<(&Path, &TaskFile, &Job)> = None;
268 for (p, tf) in files {
269 if let Some(job) = tf.job(name) {
270 if job.agent_allow {
271 target = Some((p.as_path(), tf, job));
272 }
273 break; }
275 }
276 let Some((target_path, target_tf, target_job)) = target else {
277 return Err(RunError::NotAllowed(name.to_string()));
278 };
279
280 let templated = target_job.script_arg_templates();
282 if !templated.is_empty() {
283 return Err(RunError::Injects {
284 task: name.to_string(),
285 args: templated.iter().map(|s| s.to_string()).collect(),
286 });
287 }
288
289 let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
292 .map_err(RunError::Dependency)?;
293 let dir = target_path.parent();
294 let plan = plan_invocations(&order, name, args, cwd, |n| {
295 target_tf.job(n).map(|j| (target_tf, j, dir))
296 })?;
297 run_plan_captured(&plan, cancel)
298}
299
300impl Invocation {
301 fn spawn_error(&self, source: std::io::Error) -> RunError {
303 RunError::Io {
304 task: self.task.clone(),
305 program: self.program.clone(),
306 cwd: self.cwd.clone(),
307 source,
308 }
309 }
310
311 fn command(&self) -> std::process::Command {
313 let mut cmd = std::process::Command::new(&self.program);
314 cmd.args(&self.args)
315 .envs(self.env.iter().map(|(k, v)| (k, v)))
316 .current_dir(&self.cwd);
317 cmd
318 }
319
320 fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
322 self.command().status()
323 }
324
325 fn run_captured(&self, cancel: Option<&Cancel>) -> std::io::Result<std::process::Output> {
333 let Some(cancel) = cancel else {
334 return self.command().output();
335 };
336
337 let mut cmd = self.command();
338 cmd.stdin(std::process::Stdio::null())
342 .stdout(std::process::Stdio::piped())
343 .stderr(std::process::Stdio::piped());
344 #[cfg(unix)]
345 {
346 use std::os::unix::process::CommandExt;
347 cmd.process_group(0);
349 }
350
351 let child = cmd.spawn()?;
352 cancel.entered(child.id());
354 let out = child.wait_with_output();
355 cancel.left();
356 out
357 }
358}
359
360pub(crate) fn interpreter(lang: &str) -> Interpreter {
378 let (program, flag, prelude, recognized) = match lang.trim().to_ascii_lowercase().as_str() {
379 "" | "sh" | "shell" => ("sh", "-c", Some("set -e"), true),
380 "bash" => ("bash", "-c", Some("set -e\nset -o pipefail"), true),
381 "zsh" => ("zsh", "-c", Some("set -e\nset -o pipefail"), true),
382 "fish" => ("fish", "-c", None, true),
383 "python" | "py" | "python3" => ("python3", "-c", None, true),
384 "ruby" => ("ruby", "-e", None, true),
385 "node" | "js" | "javascript" => ("node", "-e", None, true),
386 _ => ("sh", "-c", Some("set -e"), false),
391 };
392 Interpreter {
393 program,
394 flag,
395 prelude,
396 recognized,
397 }
398}
399
400pub(crate) struct Interpreter {
402 pub(crate) program: &'static str,
403 pub(crate) flag: &'static str,
404 pub(crate) prelude: Option<&'static str>,
427 pub(crate) recognized: bool,
433}
434
435pub(crate) fn is_known_lang(lang: &str) -> bool {
439 interpreter(lang).recognized
440}
441
442pub(crate) fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
446 let mut out = String::with_capacity(src.len());
447 let mut rest = src;
448 while let Some(open) = rest.find("{{") {
449 out.push_str(&rest[..open]);
450 let after = &rest[open + 2..];
451 if let Some(close) = after.find("}}") {
452 let name = after[..close].trim();
453 match args.get(name) {
454 Some(v) => out.push_str(v),
455 None => {
456 out.push_str("{{");
458 out.push_str(&after[..close]);
459 out.push_str("}}");
460 }
461 }
462 rest = &after[close + 2..];
463 } else {
464 out.push_str("{{");
465 rest = after;
466 }
467 }
468 out.push_str(rest);
469 out
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[cfg(unix)]
484 #[test]
485 fn cancelling_kills_the_script_s_children_not_just_the_shell() {
486 let dir = std::env::temp_dir().join(format!("mdtask-cancel-{}", std::process::id()));
487 std::fs::create_dir_all(&dir).unwrap();
488 let witness = dir.join("survived");
489 let src = format!(
490 "## t\n\nAgent: allow\n\n```sh\n(sleep 5; touch {}) &\nwait\n```\n",
491 witness.display()
492 );
493 let files = vec![(dir.join("tasks.md"), parse(&src))];
494
495 let cancel = Cancel::new();
496 let handle = {
497 let cancel = cancel.clone();
498 let dir = dir.clone();
499 std::thread::spawn(move || run_agent_cancellable(&files, "t", &[], &dir, &cancel))
500 };
501
502 std::thread::sleep(std::time::Duration::from_millis(300));
503 let started = std::time::Instant::now();
504 cancel.cancel();
505 let result = handle.join().expect("the run thread did not panic");
506 let took = started.elapsed();
507
508 assert!(
509 matches!(result, Err(RunError::Cancelled)),
510 "expected Cancelled, got {result:?}"
511 );
512 assert!(
513 took < std::time::Duration::from_secs(4),
514 "cancelling should not wait out the task: took {took:?}"
515 );
516
517 std::thread::sleep(std::time::Duration::from_secs(6));
519 let survived = witness.exists();
520 std::fs::remove_dir_all(&dir).ok();
521 assert!(!survived, "the grandchild outlived the cancellation");
522 }
523
524 #[cfg(unix)]
527 #[test]
528 fn cancelling_stops_the_rest_of_a_requires_chain() {
529 let dir = std::env::temp_dir().join(format!("mdtask-chain-cancel-{}", std::process::id()));
530 std::fs::create_dir_all(&dir).unwrap();
531 let witness = dir.join("second-ran");
532 let src = format!(
533 "## first\n\n```sh\nsleep 3\n```\n\n## second\n\nAgent: allow\nRequires: first\n\n```sh\ntouch {}\n```\n",
534 witness.display()
535 );
536 let files = vec![(dir.join("tasks.md"), parse(&src))];
537
538 let cancel = Cancel::new();
539 let handle = {
540 let cancel = cancel.clone();
541 let dir = dir.clone();
542 std::thread::spawn(move || run_agent_cancellable(&files, "second", &[], &dir, &cancel))
543 };
544 std::thread::sleep(std::time::Duration::from_millis(300));
545 cancel.cancel();
546 let result = handle.join().expect("the run thread did not panic");
547
548 let ran = witness.exists();
549 std::fs::remove_dir_all(&dir).ok();
550 assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
551 assert!(!ran, "the target ran even though the chain was cancelled");
552 }
553
554 #[cfg(unix)]
557 #[test]
558 fn a_run_cancelled_before_it_starts_never_spawns() {
559 let dir = std::env::temp_dir().join(format!("mdtask-precancel-{}", std::process::id()));
560 std::fs::create_dir_all(&dir).unwrap();
561 let witness = dir.join("ran");
562 let src = format!(
563 "## t\n\nAgent: allow\n\n```sh\ntouch {}\n```\n",
564 witness.display()
565 );
566 let files = vec![(dir.join("tasks.md"), parse(&src))];
567
568 let cancel = Cancel::new();
569 cancel.cancel();
570 let result = run_agent_cancellable(&files, "t", &[], &dir, &cancel);
571
572 let ran = witness.exists();
573 std::fs::remove_dir_all(&dir).ok();
574 assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
575 assert!(!ran, "the task ran despite being cancelled first");
576 }
577
578 #[test]
581 fn a_run_with_no_handle_still_completes_normally() {
582 let f = files(&[(
583 "tasks.md",
584 "## t\n\nAgent: allow\n\n```sh\necho done\n```\n",
585 )]);
586 let out = run_agent(&f, "t", &[], Path::new(".")).unwrap();
587 assert!(out.status.success());
588 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
589 }
590 use crate::model::DepError;
591 use crate::parse::parse;
592
593 fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
594 pairs
595 .iter()
596 .map(|(k, v)| (k.to_string(), v.to_string()))
597 .collect()
598 }
599
600 fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
601 pairs
602 .iter()
603 .map(|(path, src)| (PathBuf::from(path), parse(src)))
604 .collect()
605 }
606
607 fn plan_for(src: &str, target: &str, args: &[&str]) -> Vec<Invocation> {
608 let files = vec![(PathBuf::from("tasks.md"), parse(src))];
609 let order = trusted_order(&files, target).expect("resolves");
610 let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
611 plan_invocations(&order, target, &args, Path::new("."), |n| {
612 trusted_lookup(&files, n)
613 })
614 .expect("plans")
615 }
616
617 fn env_of<'a>(inv: &'a Invocation, key: &str) -> Option<&'a str> {
619 inv.env
620 .iter()
621 .find(|(k, _)| k == key)
622 .map(|(_, v)| v.as_str())
623 }
624
625 const PARAM: &str = "\
626## dist
627
628Args: module
629
630```sh
631true
632```
633
634## lint
635
636```sh
637true
638```
639
640## release
641
642Args: module
643Requires: lint, (dist {{ module }})
644
645```sh
646true
647```
648";
649
650 #[test]
651 fn substitutes_args_and_leaves_unknown_tokens() {
652 let out = substitute(
653 "hello {{ name }} and {{ other }}",
654 &args(&[("name", "world")]),
655 );
656 assert_eq!(out, "hello world and {{ other }}");
657 }
658
659 #[test]
663 fn a_placeholder_resolves_to_the_invocations_argument() {
664 let plan = plan_for(PARAM, "release", &["foundry"]);
665 assert_eq!(plan.len(), 3, "lint, dist, release");
666 assert_eq!(env_of(&plan[1], "module"), Some("foundry"), "dist got it");
667 assert_eq!(
668 env_of(&plan[2], "module"),
669 Some("foundry"),
670 "and so did release"
671 );
672 }
673
674 #[test]
678 fn the_same_task_with_different_arguments_runs_twice() {
679 let src = PARAM.replace(
680 "Requires: lint, (dist {{ module }})",
681 "Requires: (dist {{ module }}), (dist {{ module }}-docs)",
682 );
683 let plan = plan_for(&src, "release", &["foundry"]);
684 assert_eq!(plan.len(), 3);
685 assert_eq!(env_of(&plan[0], "module"), Some("foundry"));
686 assert_eq!(env_of(&plan[1], "module"), Some("foundry-docs"));
687 }
688
689 #[test]
690 fn the_same_task_with_the_same_arguments_still_runs_once() {
691 let src = PARAM.replace(
692 "Requires: lint, (dist {{ module }})",
693 "Requires: (dist {{ module }}), (dist foundry)",
694 );
695 let plan = plan_for(&src, "release", &["foundry"]);
696 assert_eq!(plan.len(), 2, "the two dist steps are the same work");
697 }
698
699 #[test]
703 fn an_unknown_placeholder_is_left_alone() {
704 let src = PARAM.replace("(dist {{ module }})", "(dist {{ nonesuch }})");
705 let plan = plan_for(&src, "release", &["foundry"]);
706 assert_eq!(env_of(&plan[1], "module"), Some("{{ nonesuch }}"));
707 }
708
709 #[test]
713 fn a_self_reference_with_different_arguments_is_still_a_cycle() {
714 let files = vec![(
715 PathBuf::from("tasks.md"),
716 parse("## a\n\nArgs: x\nRequires: (a {{ x }}-more)\n\n```sh\ntrue\n```\n"),
717 )];
718 assert!(matches!(
719 trusted_order(&files, "a"),
720 Err(RunError::Dependency(DepError::Cycle(_)))
721 ));
722 }
723
724 #[test]
728 fn the_sh_fallback_is_strict() {
729 for lang in [
730 "console",
731 "shell-session",
732 "terminal",
733 "cmd",
734 "bash5",
735 "toml",
736 "json",
737 ] {
738 let i = interpreter(lang);
739 assert_eq!(i.program, "sh", "{lang:?} should fall back to sh");
740 assert!(!i.recognized, "{lang:?} is not a named language");
741 assert!(
742 i.prelude.is_some_and(|p| p.contains("set -e")),
743 "{lang:?} falls back to sh without failure detection"
744 );
745 }
746 }
747
748 #[test]
753 fn every_language_that_runs_a_shell_detects_failure() {
754 for lang in ["", "sh", "shell", "bash", "zsh", "console", "nonsense-tag"] {
758 let i = interpreter(lang);
759 if matches!(i.program, "sh" | "bash" | "zsh") {
760 assert!(
761 i.prelude.is_some_and(|p| p.contains("set -e")),
762 "{lang:?} runs {} with no failure detection",
763 i.program
764 );
765 }
766 }
767 }
768
769 #[test]
770 fn agent_jobs_filters_to_the_gated_ones() {
771 let f = files(&[(
772 "tasks.md",
773 "## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
774 )]);
775 let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
776 assert_eq!(names, ["open"]);
777 }
778
779 #[test]
780 fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
781 let f = files(&[
784 ("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
785 (
786 "tasks.md",
787 "## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
788 ),
789 ]);
790 assert!(agent_jobs(&f).is_empty());
791 }
792
793 #[test]
794 fn run_captured_returns_stdout() {
795 let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
796 let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
797 assert!(out.status.success());
798 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
799 }
800
801 #[test]
802 fn run_captured_runs_requires_deps_first() {
803 let f = files(&[(
804 "tasks.md",
805 "## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
806 )]);
807 let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
808 let text = String::from_utf8_lossy(&out.stdout);
809 let bpos = text.find('B').expect("B in output");
811 let apos = text.find('A').expect("A in output");
812 assert!(bpos < apos, "deps must run first: {text}");
813 }
814
815 #[test]
819 fn a_task_with_an_unusable_argument_name_is_refused_before_it_runs() {
820 let f = files(&[(
821 "tasks.md",
822 "## build\n\nArgs: slug, repo\n\n```sh\necho \"$slug\"\n```\n",
823 )]);
824 match run_captured(&f, "build", &["a".into(), "b".into()], Path::new(".")) {
825 Err(RunError::InvalidArgName { task, args }) => {
826 assert_eq!(task, "build");
827 assert_eq!(args, vec!["slug,".to_string()]);
828 }
829 other => panic!("expected InvalidArgName, got {other:?}"),
830 }
831 }
832
833 #[test]
836 fn the_refusal_explains_the_comma() {
837 let e = RunError::InvalidArgName {
838 task: "build".into(),
839 args: vec!["slug,".into()],
840 };
841 let text = e.to_string();
842 assert!(text.contains("slug,"), "{text}");
843 assert!(text.contains("whitespace-separated"), "{text}");
844 assert!(text.contains("Args: a b"), "{text}");
845 }
846
847 #[test]
850 fn a_dependency_with_an_unusable_argument_name_is_refused_too() {
851 let f = files(&[(
852 "tasks.md",
853 "## a\n\nRequires: b\n\n```sh\ntrue\n```\n\n\
854 ## b\n\nArgs: x, y\n\n```sh\ntrue\n```\n",
855 )]);
856 match run_captured(&f, "a", &[], Path::new(".")) {
857 Err(RunError::InvalidArgName { task, .. }) => assert_eq!(task, "b"),
858 other => panic!("expected InvalidArgName for the dependency, got {other:?}"),
859 }
860 }
861
862 #[test]
863 fn run_reports_an_unknown_target_as_not_found() {
864 let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
865 match run_captured(&f, "ghost", &[], Path::new(".")) {
866 Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
867 other => panic!("expected NotFound, got {other:?}"),
868 }
869 }
870
871 #[test]
874 fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
875 let f = files(&[
879 ("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
880 (
881 "tasks.md",
882 "## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
883 ),
884 ]);
885 let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
886 let text = String::from_utf8_lossy(&out.stdout);
887 assert!(text.contains("real-build"), "got: {text}");
888 assert!(text.contains("real-deploy"), "got: {text}");
889 assert!(!text.contains("PWNED"), "nearer build ran: {text}");
890 assert!(out.status.success());
891 }
892
893 #[test]
894 fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
895 let f = files(&[(
898 "tasks.md",
899 "## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
900 )]);
901 match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
902 Err(RunError::Injects { task, args }) => {
903 assert_eq!(task, "greet");
904 assert_eq!(args, vec!["name".to_string()]);
905 }
906 other => panic!("expected Injects, got {other:?}"),
907 }
908 }
909
910 #[test]
911 fn run_agent_refuses_a_non_allowed_target() {
912 let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
913 match run_agent(&f, "secret", &[], Path::new(".")) {
914 Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
915 other => panic!("expected NotAllowed, got {other:?}"),
916 }
917 }
918
919 #[test]
920 fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
921 let f = files(&[
923 ("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
924 (
925 "tasks.md",
926 "## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
927 ),
928 ]);
929 match run_agent(&f, "deploy", &[], Path::new(".")) {
930 Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
931 other => panic!("expected NotAllowed, got {other:?}"),
932 }
933 }
934
935 #[test]
940 fn a_failing_early_step_fails_the_job() {
941 let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
942 let out = run_captured(
943 &[(PathBuf::from("tasks.md"), tf)],
944 "check",
945 &[],
946 Path::new("."),
947 )
948 .expect("runs");
949 assert!(
950 !out.status.success(),
951 "a job whose first command fails must not report success"
952 );
953 }
954
955 #[test]
956 fn no_strict_restores_the_old_lenient_behavior() {
957 let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
958 let out = run_captured(
959 &[(PathBuf::from("tasks.md"), tf)],
960 "check",
961 &[],
962 Path::new("."),
963 )
964 .expect("runs");
965 assert!(
966 out.status.success(),
967 "no-strict should exit with the last command's status"
968 );
969 }
970
971 #[test]
972 fn a_passing_job_is_unaffected() {
973 let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
974 let out = run_captured(
975 &[(PathBuf::from("tasks.md"), tf)],
976 "ok",
977 &[],
978 Path::new("."),
979 )
980 .expect("runs");
981 assert!(out.status.success());
982 assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
983 }
984
985 #[test]
988 fn a_hand_written_prelude_still_works() {
989 let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
990 let out = run_captured(
991 &[(PathBuf::from("tasks.md"), tf)],
992 "ok",
993 &[],
994 Path::new("."),
995 )
996 .expect("runs");
997 assert!(out.status.success());
998 }
999
1000 #[test]
1003 fn plain_sh_does_not_get_pipefail() {
1004 assert_eq!(interpreter("sh").prelude, Some("set -e"));
1005 assert_eq!(interpreter("").prelude, Some("set -e"));
1006 assert!(interpreter("bash").prelude.unwrap().contains("pipefail"));
1007 assert!(interpreter("zsh").prelude.unwrap().contains("pipefail"));
1008 }
1009
1010 #[test]
1013 fn non_shells_get_no_prelude() {
1014 for lang in ["python", "ruby", "node", "fish"] {
1015 assert_eq!(
1016 interpreter(lang).prelude,
1017 None,
1018 "{lang} must not be given shell syntax"
1019 );
1020 }
1021 }
1022
1023 #[test]
1026 fn a_python_job_is_untouched() {
1027 let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
1028 let out = run_captured(
1029 &[(PathBuf::from("tasks.md"), tf)],
1030 "py",
1031 &[],
1032 Path::new("."),
1033 )
1034 .expect("runs");
1035 assert!(out.status.success());
1036 assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
1037 }
1038}