use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::cancel::Cancel;
use crate::deps::{Step, dependency_order};
use crate::model::{Invocation, Job, RunError, TaskFile};
pub fn agent_jobs(files: &[(PathBuf, TaskFile)]) -> Vec<&Job> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for (_, tf) in files {
for job in &tf.jobs {
if seen.insert(job.name.clone()) && job.agent_allow {
out.push(job);
}
}
}
out
}
fn trusted_lookup<'a>(
files: &'a [(PathBuf, TaskFile)],
name: &str,
) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)> {
files
.iter()
.find_map(|(p, tf)| tf.job(name).map(|j| (tf, j, p.parent())))
}
fn trusted_order(files: &[(PathBuf, TaskFile)], target: &str) -> Result<Vec<Step>, RunError> {
if trusted_lookup(files, target).is_none() {
return Err(RunError::NotFound(target.to_string()));
}
dependency_order(target, |n| {
trusted_lookup(files, n).map(|(_, j, _)| j.requires.clone())
})
.map_err(RunError::Dependency)
}
fn plan_invocations<'a>(
order: &[Step],
target: &str,
args: &[String],
cwd: &Path,
lookup: impl Fn(&str) -> Option<(&'a TaskFile, &'a Job, Option<&'a Path>)>,
) -> Result<Vec<Invocation>, RunError> {
let scope = {
let (_, job, _) = lookup(target).expect("the target resolves");
TaskFile::bind(job, args).map_err(RunError::MissingArg)?
};
let mut seen = BTreeSet::new();
let mut resolved: Vec<(&str, Vec<String>)> = Vec::with_capacity(order.len());
for step in order {
let step_args: Vec<String> = if step.name == target {
args.to_vec()
} else {
step.args.iter().map(|a| substitute(a, &scope)).collect()
};
if seen.insert((step.name.clone(), step_args.clone())) {
resolved.push((step.name.as_str(), step_args));
}
}
let mut plan = Vec::with_capacity(resolved.len());
for (name, step_args) in resolved {
let (tf, job, dir) = lookup(name).expect("a resolved name still resolves");
let invalid: Vec<String> = job
.args
.iter()
.filter(|a| !a.is_valid_name())
.map(|a| a.name.clone())
.collect();
if !invalid.is_empty() {
return Err(RunError::InvalidArgName {
task: name.to_string(),
args: invalid,
});
}
let values = TaskFile::bind(job, &step_args).map_err(RunError::MissingArg)?;
let inv = tf
.invocation(job, &values, cwd, dir)
.map_err(RunError::MissingArg)?;
plan.push(inv);
}
Ok(plan)
}
fn run_plan_captured(
plan: &[Invocation],
cancel: Option<&Cancel>,
) -> Result<std::process::Output, RunError> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut status = None;
for inv in plan {
if cancel.is_some_and(Cancel::is_cancelled) {
return Err(RunError::Cancelled);
}
let out = inv.run_captured(cancel).map_err(|e| inv.spawn_error(e))?;
stdout.extend_from_slice(&out.stdout);
stderr.extend_from_slice(&out.stderr);
if cancel.is_some_and(Cancel::is_cancelled) {
return Err(RunError::Cancelled);
}
let failed = !out.status.success();
status = Some(out.status);
if failed {
break;
}
}
Ok(std::process::Output {
status: status.expect("the plan always contains the target"),
stdout,
stderr,
})
}
pub fn run(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::ExitStatus, RunError> {
let order = trusted_order(files, name)?;
let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
let mut last = None;
for inv in &plan {
let status = inv.run_inherit().map_err(|e| inv.spawn_error(e))?;
if !status.success() {
return Ok(status);
}
last = Some(status);
}
Ok(last.expect("the plan always contains the target"))
}
pub fn run_captured(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::Output, RunError> {
let order = trusted_order(files, name)?;
let plan = plan_invocations(&order, name, args, cwd, |n| trusted_lookup(files, n))?;
run_plan_captured(&plan, None)
}
pub fn run_agent(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
) -> Result<std::process::Output, RunError> {
run_agent_inner(files, name, args, cwd, None)
}
pub fn run_agent_cancellable(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
cancel: &Cancel,
) -> Result<std::process::Output, RunError> {
run_agent_inner(files, name, args, cwd, Some(cancel))
}
fn run_agent_inner(
files: &[(PathBuf, TaskFile)],
name: &str,
args: &[String],
cwd: &Path,
cancel: Option<&Cancel>,
) -> Result<std::process::Output, RunError> {
let mut target: Option<(&Path, &TaskFile, &Job)> = None;
for (p, tf) in files {
if let Some(job) = tf.job(name) {
if job.agent_allow {
target = Some((p.as_path(), tf, job));
}
break; }
}
let Some((target_path, target_tf, target_job)) = target else {
return Err(RunError::NotAllowed(name.to_string()));
};
let templated = target_job.script_arg_templates();
if !templated.is_empty() {
return Err(RunError::Injects {
task: name.to_string(),
args: templated.iter().map(|s| s.to_string()).collect(),
});
}
let order = dependency_order(name, |n| target_tf.job(n).map(|j| j.requires.clone()))
.map_err(RunError::Dependency)?;
let dir = target_path.parent();
let plan = plan_invocations(&order, name, args, cwd, |n| {
target_tf.job(n).map(|j| (target_tf, j, dir))
})?;
run_plan_captured(&plan, cancel)
}
impl Invocation {
fn spawn_error(&self, source: std::io::Error) -> RunError {
RunError::Io {
task: self.task.clone(),
program: self.program.clone(),
cwd: self.cwd.clone(),
source,
}
}
fn command(&self) -> std::process::Command {
let mut cmd = std::process::Command::new(&self.program);
cmd.args(&self.args)
.envs(self.env.iter().map(|(k, v)| (k, v)))
.current_dir(&self.cwd);
cmd
}
fn run_inherit(&self) -> std::io::Result<std::process::ExitStatus> {
self.command().status()
}
fn run_captured(&self, cancel: Option<&Cancel>) -> std::io::Result<std::process::Output> {
let Some(cancel) = cancel else {
return self.command().output();
};
let mut cmd = self.command();
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn()?;
cancel.entered(child.id());
let out = child.wait_with_output();
cancel.left();
out
}
}
pub(crate) fn interpreter(lang: &str) -> Interpreter {
let (program, flag, prelude, recognized) = match lang.trim().to_ascii_lowercase().as_str() {
"" | "sh" | "shell" => ("sh", "-c", Some("set -e"), true),
"bash" => ("bash", "-c", Some("set -e\nset -o pipefail"), true),
"zsh" => ("zsh", "-c", Some("set -e\nset -o pipefail"), true),
"fish" => ("fish", "-c", None, true),
"python" | "py" | "python3" => ("python3", "-c", None, true),
"ruby" => ("ruby", "-e", None, true),
"node" | "js" | "javascript" => ("node", "-e", None, true),
_ => ("sh", "-c", Some("set -e"), false),
};
Interpreter {
program,
flag,
prelude,
recognized,
}
}
pub(crate) struct Interpreter {
pub(crate) program: &'static str,
pub(crate) flag: &'static str,
pub(crate) prelude: Option<&'static str>,
pub(crate) recognized: bool,
}
pub(crate) fn is_known_lang(lang: &str) -> bool {
interpreter(lang).recognized
}
pub(crate) fn substitute(src: &str, args: &BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(src.len());
let mut rest = src;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
if let Some(close) = after.find("}}") {
let name = after[..close].trim();
match args.get(name) {
Some(v) => out.push_str(v),
None => {
out.push_str("{{");
out.push_str(&after[..close]);
out.push_str("}}");
}
}
rest = &after[close + 2..];
} else {
out.push_str("{{");
rest = after;
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn cancelling_kills_the_script_s_children_not_just_the_shell() {
let dir = std::env::temp_dir().join(format!("mdtask-cancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let witness = dir.join("survived");
let src = format!(
"## t\n\nAgent: allow\n\n```sh\n(sleep 5; touch {}) &\nwait\n```\n",
witness.display()
);
let files = vec![(dir.join("tasks.md"), parse(&src))];
let cancel = Cancel::new();
let handle = {
let cancel = cancel.clone();
let dir = dir.clone();
std::thread::spawn(move || run_agent_cancellable(&files, "t", &[], &dir, &cancel))
};
std::thread::sleep(std::time::Duration::from_millis(300));
let started = std::time::Instant::now();
cancel.cancel();
let result = handle.join().expect("the run thread did not panic");
let took = started.elapsed();
assert!(
matches!(result, Err(RunError::Cancelled)),
"expected Cancelled, got {result:?}"
);
assert!(
took < std::time::Duration::from_secs(4),
"cancelling should not wait out the task: took {took:?}"
);
std::thread::sleep(std::time::Duration::from_secs(6));
let survived = witness.exists();
std::fs::remove_dir_all(&dir).ok();
assert!(!survived, "the grandchild outlived the cancellation");
}
#[cfg(unix)]
#[test]
fn cancelling_stops_the_rest_of_a_requires_chain() {
let dir = std::env::temp_dir().join(format!("mdtask-chain-cancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let witness = dir.join("second-ran");
let src = format!(
"## first\n\n```sh\nsleep 3\n```\n\n## second\n\nAgent: allow\nRequires: first\n\n```sh\ntouch {}\n```\n",
witness.display()
);
let files = vec![(dir.join("tasks.md"), parse(&src))];
let cancel = Cancel::new();
let handle = {
let cancel = cancel.clone();
let dir = dir.clone();
std::thread::spawn(move || run_agent_cancellable(&files, "second", &[], &dir, &cancel))
};
std::thread::sleep(std::time::Duration::from_millis(300));
cancel.cancel();
let result = handle.join().expect("the run thread did not panic");
let ran = witness.exists();
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
assert!(!ran, "the target ran even though the chain was cancelled");
}
#[cfg(unix)]
#[test]
fn a_run_cancelled_before_it_starts_never_spawns() {
let dir = std::env::temp_dir().join(format!("mdtask-precancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let witness = dir.join("ran");
let src = format!(
"## t\n\nAgent: allow\n\n```sh\ntouch {}\n```\n",
witness.display()
);
let files = vec![(dir.join("tasks.md"), parse(&src))];
let cancel = Cancel::new();
cancel.cancel();
let result = run_agent_cancellable(&files, "t", &[], &dir, &cancel);
let ran = witness.exists();
std::fs::remove_dir_all(&dir).ok();
assert!(matches!(result, Err(RunError::Cancelled)), "{result:?}");
assert!(!ran, "the task ran despite being cancelled first");
}
#[test]
fn a_run_with_no_handle_still_completes_normally() {
let f = files(&[(
"tasks.md",
"## t\n\nAgent: allow\n\n```sh\necho done\n```\n",
)]);
let out = run_agent(&f, "t", &[], Path::new(".")).unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
}
use crate::model::DepError;
use crate::parse::parse;
fn args(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn files(pairs: &[(&str, &str)]) -> Vec<(PathBuf, TaskFile)> {
pairs
.iter()
.map(|(path, src)| (PathBuf::from(path), parse(src)))
.collect()
}
fn plan_for(src: &str, target: &str, args: &[&str]) -> Vec<Invocation> {
let files = vec![(PathBuf::from("tasks.md"), parse(src))];
let order = trusted_order(&files, target).expect("resolves");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
plan_invocations(&order, target, &args, Path::new("."), |n| {
trusted_lookup(&files, n)
})
.expect("plans")
}
fn env_of<'a>(inv: &'a Invocation, key: &str) -> Option<&'a str> {
inv.env
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
const PARAM: &str = "\
## dist
Args: module
```sh
true
```
## lint
```sh
true
```
## release
Args: module
Requires: lint, (dist {{ module }})
```sh
true
```
";
#[test]
fn substitutes_args_and_leaves_unknown_tokens() {
let out = substitute(
"hello {{ name }} and {{ other }}",
&args(&[("name", "world")]),
);
assert_eq!(out, "hello world and {{ other }}");
}
#[test]
fn a_placeholder_resolves_to_the_invocations_argument() {
let plan = plan_for(PARAM, "release", &["foundry"]);
assert_eq!(plan.len(), 3, "lint, dist, release");
assert_eq!(env_of(&plan[1], "module"), Some("foundry"), "dist got it");
assert_eq!(
env_of(&plan[2], "module"),
Some("foundry"),
"and so did release"
);
}
#[test]
fn the_same_task_with_different_arguments_runs_twice() {
let src = PARAM.replace(
"Requires: lint, (dist {{ module }})",
"Requires: (dist {{ module }}), (dist {{ module }}-docs)",
);
let plan = plan_for(&src, "release", &["foundry"]);
assert_eq!(plan.len(), 3);
assert_eq!(env_of(&plan[0], "module"), Some("foundry"));
assert_eq!(env_of(&plan[1], "module"), Some("foundry-docs"));
}
#[test]
fn the_same_task_with_the_same_arguments_still_runs_once() {
let src = PARAM.replace(
"Requires: lint, (dist {{ module }})",
"Requires: (dist {{ module }}), (dist foundry)",
);
let plan = plan_for(&src, "release", &["foundry"]);
assert_eq!(plan.len(), 2, "the two dist steps are the same work");
}
#[test]
fn an_unknown_placeholder_is_left_alone() {
let src = PARAM.replace("(dist {{ module }})", "(dist {{ nonesuch }})");
let plan = plan_for(&src, "release", &["foundry"]);
assert_eq!(env_of(&plan[1], "module"), Some("{{ nonesuch }}"));
}
#[test]
fn a_self_reference_with_different_arguments_is_still_a_cycle() {
let files = vec![(
PathBuf::from("tasks.md"),
parse("## a\n\nArgs: x\nRequires: (a {{ x }}-more)\n\n```sh\ntrue\n```\n"),
)];
assert!(matches!(
trusted_order(&files, "a"),
Err(RunError::Dependency(DepError::Cycle(_)))
));
}
#[test]
fn the_sh_fallback_is_strict() {
for lang in [
"console",
"shell-session",
"terminal",
"cmd",
"bash5",
"toml",
"json",
] {
let i = interpreter(lang);
assert_eq!(i.program, "sh", "{lang:?} should fall back to sh");
assert!(!i.recognized, "{lang:?} is not a named language");
assert!(
i.prelude.is_some_and(|p| p.contains("set -e")),
"{lang:?} falls back to sh without failure detection"
);
}
}
#[test]
fn every_language_that_runs_a_shell_detects_failure() {
for lang in ["", "sh", "shell", "bash", "zsh", "console", "nonsense-tag"] {
let i = interpreter(lang);
if matches!(i.program, "sh" | "bash" | "zsh") {
assert!(
i.prelude.is_some_and(|p| p.contains("set -e")),
"{lang:?} runs {} with no failure detection",
i.program
);
}
}
}
#[test]
fn agent_jobs_filters_to_the_gated_ones() {
let f = files(&[(
"tasks.md",
"## open\n\nAgent: allow\n\n```sh\ntrue\n```\n\n## closed\n\n```sh\ntrue\n```\n",
)]);
let names: Vec<_> = agent_jobs(&f).iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["open"]);
}
#[test]
fn agent_jobs_shadows_a_farther_allowed_with_a_nearer_non_allowed() {
let f = files(&[
("child/tasks.md", "## deploy\n\n```sh\ntrue\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\n\n```sh\ntrue\n```\n",
),
]);
assert!(agent_jobs(&f).is_empty());
}
#[test]
fn run_captured_returns_stdout() {
let f = files(&[("tasks.md", "## hello\n\n```sh\necho hello-out\n```\n")]);
let out = run_captured(&f, "hello", &[], Path::new(".")).unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello-out");
}
#[test]
fn run_captured_runs_requires_deps_first() {
let f = files(&[(
"tasks.md",
"## a\n\nRequires: b\n\n```sh\necho A\n```\n\n## b\n\n```sh\necho B\n```\n",
)]);
let out = run_captured(&f, "a", &[], Path::new(".")).unwrap();
let text = String::from_utf8_lossy(&out.stdout);
let bpos = text.find('B').expect("B in output");
let apos = text.find('A').expect("A in output");
assert!(bpos < apos, "deps must run first: {text}");
}
#[test]
fn a_task_with_an_unusable_argument_name_is_refused_before_it_runs() {
let f = files(&[(
"tasks.md",
"## build\n\nArgs: slug, repo\n\n```sh\necho \"$slug\"\n```\n",
)]);
match run_captured(&f, "build", &["a".into(), "b".into()], Path::new(".")) {
Err(RunError::InvalidArgName { task, args }) => {
assert_eq!(task, "build");
assert_eq!(args, vec!["slug,".to_string()]);
}
other => panic!("expected InvalidArgName, got {other:?}"),
}
}
#[test]
fn the_refusal_explains_the_comma() {
let e = RunError::InvalidArgName {
task: "build".into(),
args: vec!["slug,".into()],
};
let text = e.to_string();
assert!(text.contains("slug,"), "{text}");
assert!(text.contains("whitespace-separated"), "{text}");
assert!(text.contains("Args: a b"), "{text}");
}
#[test]
fn a_dependency_with_an_unusable_argument_name_is_refused_too() {
let f = files(&[(
"tasks.md",
"## a\n\nRequires: b\n\n```sh\ntrue\n```\n\n\
## b\n\nArgs: x, y\n\n```sh\ntrue\n```\n",
)]);
match run_captured(&f, "a", &[], Path::new(".")) {
Err(RunError::InvalidArgName { task, .. }) => assert_eq!(task, "b"),
other => panic!("expected InvalidArgName for the dependency, got {other:?}"),
}
}
#[test]
fn run_reports_an_unknown_target_as_not_found() {
let f = files(&[("tasks.md", "## a\n\n```sh\ntrue\n```\n")]);
match run_captured(&f, "ghost", &[], Path::new(".")) {
Err(RunError::NotFound(n)) => assert_eq!(n, "ghost"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn run_agent_resolves_requires_within_the_targets_file_not_a_nearer_shadow() {
let f = files(&[
("child/tasks.md", "## build\n\n```sh\necho PWNED\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\nRequires: build\n\n```sh\necho real-deploy\n```\n\n## build\n\n```sh\necho real-build\n```\n",
),
]);
let out = run_agent(&f, "deploy", &[], Path::new(".")).unwrap();
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("real-build"), "got: {text}");
assert!(text.contains("real-deploy"), "got: {text}");
assert!(!text.contains("PWNED"), "nearer build ran: {text}");
assert!(out.status.success());
}
#[test]
fn run_agent_refuses_a_target_that_injects_an_arg_via_double_brace() {
let f = files(&[(
"tasks.md",
"## greet\n\nAgent: allow\nArgs: name\n\n```sh\necho hi {{ name }}\n```\n",
)]);
match run_agent(&f, "greet", &["x; echo PWNED".into()], Path::new(".")) {
Err(RunError::Injects { task, args }) => {
assert_eq!(task, "greet");
assert_eq!(args, vec!["name".to_string()]);
}
other => panic!("expected Injects, got {other:?}"),
}
}
#[test]
fn run_agent_refuses_a_non_allowed_target() {
let f = files(&[("tasks.md", "## secret\n\n```sh\ntrue\n```\n")]);
match run_agent(&f, "secret", &[], Path::new(".")) {
Err(RunError::NotAllowed(n)) => assert_eq!(n, "secret"),
other => panic!("expected NotAllowed, got {other:?}"),
}
}
#[test]
fn run_agent_refuses_when_a_nearer_non_allowed_shadows_an_allowed_one() {
let f = files(&[
("child/tasks.md", "## deploy\n\n```sh\necho PWNED\n```\n"),
(
"tasks.md",
"## deploy\n\nAgent: allow\n\n```sh\necho real\n```\n",
),
]);
match run_agent(&f, "deploy", &[], Path::new(".")) {
Err(RunError::NotAllowed(n)) => assert_eq!(n, "deploy"),
other => panic!("expected NotAllowed, got {other:?}"),
}
}
#[test]
fn a_failing_early_step_fails_the_job() {
let tf = parse("## check\n\n```sh\nfalse\ntrue\n```\n");
let out = run_captured(
&[(PathBuf::from("tasks.md"), tf)],
"check",
&[],
Path::new("."),
)
.expect("runs");
assert!(
!out.status.success(),
"a job whose first command fails must not report success"
);
}
#[test]
fn no_strict_restores_the_old_lenient_behavior() {
let tf = parse("## check\n\nOpts: no-strict\n\n```sh\nfalse\ntrue\n```\n");
let out = run_captured(
&[(PathBuf::from("tasks.md"), tf)],
"check",
&[],
Path::new("."),
)
.expect("runs");
assert!(
out.status.success(),
"no-strict should exit with the last command's status"
);
}
#[test]
fn a_passing_job_is_unaffected() {
let tf = parse("## ok\n\n```sh\ntrue\necho fine\n```\n");
let out = run_captured(
&[(PathBuf::from("tasks.md"), tf)],
"ok",
&[],
Path::new("."),
)
.expect("runs");
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains("fine"));
}
#[test]
fn a_hand_written_prelude_still_works() {
let tf = parse("## ok\n\n```sh\nset -eu\necho fine\n```\n");
let out = run_captured(
&[(PathBuf::from("tasks.md"), tf)],
"ok",
&[],
Path::new("."),
)
.expect("runs");
assert!(out.status.success());
}
#[test]
fn plain_sh_does_not_get_pipefail() {
assert_eq!(interpreter("sh").prelude, Some("set -e"));
assert_eq!(interpreter("").prelude, Some("set -e"));
assert!(interpreter("bash").prelude.unwrap().contains("pipefail"));
assert!(interpreter("zsh").prelude.unwrap().contains("pipefail"));
}
#[test]
fn non_shells_get_no_prelude() {
for lang in ["python", "ruby", "node", "fish"] {
assert_eq!(
interpreter(lang).prelude,
None,
"{lang} must not be given shell syntax"
);
}
}
#[test]
fn a_python_job_is_untouched() {
let tf = parse("## py\n\n```python\nprint(\"hi\")\n```\n");
let out = run_captured(
&[(PathBuf::from("tasks.md"), tf)],
"py",
&[],
Path::new("."),
)
.expect("runs");
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
}
}