use anyhow::{Context, Result};
use std::io::{BufRead, Write};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Answer {
pub path: std::path::PathBuf,
pub body: String,
pub said: String,
}
pub fn how_is_it_installed(
marker: &crate::stack::Marker,
input: &mut dyn BufRead,
out: &mut dyn Write,
) -> Result<Option<Answer>> {
writeln!(
out,
"\n {} is here, and omh has no {} stack.\n \
It can still build the sandbox — it just needs to be told how.",
marker.file, marker.stack
)?;
let Some(install) = prompt(
&format!(" what installs {}? (Enter to skip)", marker.stack),
input,
out,
)?
else {
return Ok(None);
};
let Some(needs) = prompt(
" and what should then be on PATH? (space-separated)",
input,
out,
)?
else {
writeln!(out, " nothing written — a stack needs both")?;
return Ok(None);
};
let programs: Vec<&str> = needs.split_whitespace().collect();
if programs
.iter()
.any(|p| crate::detect::program(p) != Some(*p))
{
writeln!(
out,
" nothing written — `{needs}` is not a list of program names"
)?;
return Ok(None);
}
let body = toml::to_string_pretty(&toml::toml! {
name = (marker.stack.clone())
marker = (marker.file.clone())
[[provide]]
name = "toolchain"
needs = (programs.iter().map(|p| p.to_string()).collect::<Vec<_>>())
install = (install.clone())
because = (format!("{} is what this project is written in", marker.stack))
})
.context("writing the stack you described")?;
Ok(Some(Answer {
path: Path::new("stacks").join(format!("{}.toml", marker.stack)),
body,
said: format!("stack {} — from what you told it", marker.stack),
}))
}
pub fn what_tests_it(input: &mut dyn BufRead, out: &mut dyn Write) -> Result<Option<Answer>> {
writeln!(
out,
"\n omh has no test command for this project — nothing it could read \
told it one.\n With one, the agent can check its own work before \
handing it back."
)?;
let Some(command) = prompt(" what command runs the tests? (Enter to skip)", input, out)?
else {
return Ok(None);
};
let hook = crate::hook::Hook {
on: crate::hook::Event::TurnEnd,
stack: None,
tools: Vec::new(),
when: None,
action: crate::hook::Action::Run(command.clone()),
};
let body = serde_json::to_string_pretty(&hook).context("writing the hook you described")?;
if let Err(e) = crate::hook::Hook::parse(&body, "the hook you described") {
writeln!(out, " nothing written — {e:#}")?;
return Ok(None);
}
Ok(Some(Answer {
path: Path::new("hooks").join("test.json"),
body: format!("{body}\n"),
said: format!("hook test — `{command}`, from what you told it"),
}))
}
fn prompt(question: &str, input: &mut dyn BufRead, out: &mut dyn Write) -> Result<Option<String>> {
write!(out, "{question}\n > ")?;
out.flush()?;
let mut line = String::new();
if input.read_line(&mut line)? == 0 {
return Ok(None);
}
let line = line.trim();
Ok((!line.is_empty()).then(|| line.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn marker() -> crate::stack::Marker {
crate::stack::Marker {
file: "mix.exs".into(),
stack: "elixir".into(),
}
}
fn asked_about_stack(typed: &str) -> (Option<Answer>, String) {
let mut out = Vec::new();
let answer = how_is_it_installed(
&marker(),
&mut std::io::BufReader::new(typed.as_bytes()),
&mut out,
)
.unwrap();
(answer, String::from_utf8(out).unwrap())
}
fn asked_about_tests(typed: &str) -> (Option<Answer>, String) {
let mut out = Vec::new();
let answer =
what_tests_it(&mut std::io::BufReader::new(typed.as_bytes()), &mut out).unwrap();
(answer, String::from_utf8(out).unwrap())
}
#[test]
fn what_somebody_types_becomes_a_stack_omh_can_load() {
let (answer, said) = asked_about_stack("apt-get install -y elixir\nmix elixir\n");
let answer = answer.expect("both questions answered");
assert_eq!(answer.path, Path::new("stacks/elixir.toml"));
assert!(
said.contains("mix.exs"),
"the question names its evidence: {said}"
);
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("elixir.toml"), &answer.body).unwrap();
let loaded = crate::stack::load_dir(dir.path()).expect("must load");
assert_eq!(loaded.len(), 1, "got: {loaded:?}");
assert_eq!(loaded[0].name, "elixir");
assert_eq!(loaded[0].marker, "mix.exs");
assert_eq!(
loaded[0].provides[0].install.as_deref(),
Some("apt-get install -y elixir")
);
assert_eq!(loaded[0].provides[0].needs, ["mix", "elixir"]);
}
#[test]
fn an_empty_answer_writes_nothing() {
assert_eq!(asked_about_stack("\n").0, None);
assert_eq!(asked_about_tests("\n").0, None);
}
#[test]
fn a_closed_pipe_records_nothing() {
assert_eq!(asked_about_stack("").0, None);
assert_eq!(asked_about_tests("").0, None);
let (answer, said) = asked_about_stack("apt-get install -y elixir\n");
assert_eq!(answer, None);
assert!(said.contains("nothing written"), "and says so: {said}");
}
#[test]
fn a_needs_that_is_not_program_names_is_refused() {
let (answer, said) = asked_about_stack("apt-get install -y elixir\nmix; rm -rf $HOME\n");
assert_eq!(answer, None);
assert!(said.contains("not a list of program names"), "got: {said}");
}
#[test]
fn what_somebody_types_becomes_a_hook_omh_can_render() {
let (answer, _) = asked_about_tests("mix test\n");
let answer = answer.expect("answered");
assert_eq!(answer.path, Path::new("hooks/test.json"));
let parsed = crate::hook::Hook::parse(&answer.body, "test.json").expect("must parse");
assert_eq!(parsed.on, crate::hook::Event::TurnEnd);
assert_eq!(
parsed.action,
crate::hook::Action::Run("mix test".into()),
"the command reaches the hook"
);
}
#[test]
fn a_command_with_a_quote_survives_being_written() {
let (answer, said) = asked_about_tests("sh -c \"mix test\"\n");
let answer = answer.unwrap_or_else(|| panic!("a quoted command is a command: {said}"));
assert_eq!(
crate::hook::Hook::parse(&answer.body, "test.json")
.expect("must parse back")
.action,
crate::hook::Action::Run("sh -c \"mix test\"".into())
);
}
#[test]
fn a_variable_in_a_command_is_shell_not_a_hole_in_a_sentence() {
let (answer, said) = asked_about_tests("mix test $MIX_ENV\n");
let answer = answer.unwrap_or_else(|| panic!("got: {said}"));
assert_eq!(
crate::hook::Hook::parse(&answer.body, "test.json")
.expect("must parse")
.action,
crate::hook::Action::Run("mix test $MIX_ENV".into())
);
}
}