use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::{Error, Result};
pub(crate) struct Headless<'a> {
pub tool: &'static str,
pub program: &'a Path,
pub script: Option<&'a Path>,
pub default_script: (&'static str, &'static str),
pub work_dir: Option<&'a Path>,
}
pub(crate) struct Invocation {
pub input: PathBuf,
pub name: String,
pub work_dir: PathBuf,
pub script_dir: PathBuf,
pub script_name: OsString,
}
impl Headless<'_> {
pub(crate) fn lift(
&self,
input: &Path,
output: &Path,
args: impl FnOnce(&Invocation) -> Vec<OsString>,
) -> Result<()> {
let (script, _script_tmp) = self.resolve_script()?;
let (work_dir, _work_tmp) = self.resolve_work_dir()?;
let invocation = Self::resolve(input, work_dir, script)?;
let program = std::fs::canonicalize(self.program)
.map_err(|e| Error::Io(self.program.to_path_buf(), e))?;
let mut command = std::process::Command::new(&program);
command
.args(args(&invocation))
.current_dir(&invocation.work_dir);
log::info!("Executing {}: {:?}", self.tool, command);
let result = command.output().map_err(|e| Error::Io(program, e))?;
if !result.status.success() {
return Err(Error::Parse(format!(
"{} failed with status {}.\nSTDOUT: {}\nSTDERR: {}",
self.tool,
result.status,
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr),
)));
}
self.take_output(&invocation, output, &result)
}
fn resolve_script(&self) -> Result<(PathBuf, Option<tempfile::TempDir>)> {
match self.script {
Some(s) => {
let s = std::fs::canonicalize(s).map_err(|e| Error::Io(s.to_path_buf(), e))?;
Ok((s, None))
}
None => {
let dir = Self::temp_dir("oinkie_script")?;
let (name, text) = self.default_script;
let path = dir.path().join(name);
std::fs::write(&path, text).map_err(|e| Error::Io(path.clone(), e))?;
Ok((path, Some(dir)))
}
}
}
fn resolve_work_dir(&self) -> Result<(PathBuf, Option<tempfile::TempDir>)> {
match self.work_dir {
Some(d) => {
std::fs::create_dir_all(d).map_err(|e| Error::Io(d.to_path_buf(), e))?;
let d = std::fs::canonicalize(d).map_err(|e| Error::Io(d.to_path_buf(), e))?;
Ok((d, None))
}
None => {
let dir = Self::temp_dir("oinkie_work")?;
Ok((dir.path().to_path_buf(), Some(dir)))
}
}
}
fn temp_dir(prefix: &str) -> Result<tempfile::TempDir> {
tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.map_err(|e| Error::Io(PathBuf::from(prefix), e))
}
fn resolve(input: &Path, work_dir: PathBuf, script: PathBuf) -> Result<Invocation> {
let invalid = |what: &str, p: &Path| Error::Parse(format!("Invalid {what} path: {p:?}"));
let name = input
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| invalid("input", input))?
.to_string();
let script_dir = script
.parent()
.ok_or_else(|| invalid("script", &script))?
.to_path_buf();
let script_name = script
.file_name()
.ok_or_else(|| invalid("script", &script))?
.to_os_string();
let input = std::fs::canonicalize(input).map_err(|e| Error::Io(input.to_path_buf(), e))?;
Ok(Invocation {
input,
name,
work_dir,
script_dir,
script_name,
})
}
fn take_output(
&self,
invocation: &Invocation,
output: &Path,
result: &std::process::Output,
) -> Result<()> {
let generated = invocation
.work_dir
.join(format!("{}.json", invocation.name));
if !generated.exists() {
return Err(Error::Parse(format!(
"Expected {} to generate {:?}, but it was not found. {} exited successfully, so its own output is the only account of why:\nSTDOUT: {}\nSTDERR: {}",
self.tool,
generated,
self.tool,
tail(&String::from_utf8_lossy(&result.stdout)),
tail(&String::from_utf8_lossy(&result.stderr)),
)));
}
if std::fs::rename(&generated, output).is_err() {
std::fs::copy(&generated, output).map_err(|e| Error::Io(output.to_path_buf(), e))?;
if let Err(e) = std::fs::remove_file(&generated) {
log::warn!(
"copied to {} but could not remove {}: {e}",
output.display(),
generated.display()
);
}
}
Ok(())
}
}
fn tail(text: &str) -> String {
const LINES: usize = 20;
let text = text.trim_end();
let mut kept: Vec<&str> = text.lines().rev().take(LINES).collect();
kept.reverse();
if text.lines().count() > LINES {
format!("(last {LINES} lines)\n{}", kept.join("\n"))
} else {
kept.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn headless<'a>(work_dir: Option<&'a Path>, script: Option<&'a Path>) -> Headless<'a> {
Headless {
tool: "test",
program: Path::new("/usr/bin/true"),
script,
default_script: ("script.txt", "built-in"),
work_dir,
}
}
#[test]
fn test_paths_reach_the_command_absolute() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let relative = pathdiff(&input);
let seen = std::cell::RefCell::new(None);
let _ = headless(None, None).lift(&relative, &dir.path().join("out.json"), |i| {
*seen.borrow_mut() = Some((i.input.clone(), i.work_dir.clone(), i.name.clone()));
vec![]
});
let (resolved, work_dir, name) = seen.into_inner().expect("args were never built");
assert!(
resolved.is_absolute(),
"input stayed relative: {resolved:?}"
);
assert!(
work_dir.is_absolute(),
"work dir stayed relative: {work_dir:?}"
);
assert_eq!(name, "sample.bin");
}
#[test]
fn test_named_work_dir_is_created_and_absolute() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let work = dir.path().join("nested/work");
assert!(!work.exists());
let seen = std::cell::RefCell::new(None);
let _ = headless(Some(&work), None).lift(&input, &dir.path().join("out.json"), |i| {
*seen.borrow_mut() = Some(i.work_dir.clone());
vec![]
});
assert!(work.is_dir(), "the working directory was not created");
let used = seen.into_inner().expect("args were never built");
assert!(used.is_absolute());
assert_eq!(used, std::fs::canonicalize(&work).unwrap());
}
#[test]
fn test_the_built_in_script_is_written_out() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let seen = std::cell::RefCell::new(None);
let _ = headless(None, None).lift(&input, &dir.path().join("out.json"), |i| {
let script = i.script_dir.join(&i.script_name);
*seen.borrow_mut() = Some((
std::fs::read_to_string(script).unwrap(),
i.script_name.clone(),
));
vec![]
});
let (text, name) = seen.into_inner().expect("args were never built");
assert_eq!(text, "built-in");
assert_eq!(name, "script.txt");
}
#[test]
fn test_a_missing_result_is_reported() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
match headless(None, None).lift(&input, &dir.path().join("out.json"), |_| vec![]) {
Err(Error::Parse(msg)) => assert!(
msg.contains("sample.bin.json") && msg.contains("STDOUT"),
"unhelpful message: {msg}"
),
other => panic!("expected a missing-output error, got {:?}", other.err()),
}
}
#[test]
fn test_a_missing_result_carries_what_the_tool_said() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let mut h = headless(None, None);
h.program = Path::new("/bin/echo");
match h.lift(&input, &dir.path().join("out.json"), |_| {
vec!["the script threw".into()]
}) {
Err(Error::Parse(msg)) => assert!(
msg.contains("the script threw"),
"the tool's own output was dropped: {msg}"
),
other => panic!("expected a missing-output error, got {:?}", other.err()),
}
}
#[test]
fn test_tail_keeps_the_end_and_says_so() {
let short = (1..=3)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
assert_eq!(tail(&short), "1\n2\n3");
let long = (1..=30)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("\n");
let tailed = tail(&long);
assert!(tailed.starts_with("(last 20 lines)"), "{tailed}");
assert!(tailed.ends_with("\n30"), "{tailed}");
assert!(
!tailed.contains("\n10\n"),
"kept more than the last 20: {tailed}"
);
}
#[test]
fn test_the_result_is_moved_to_the_destination() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let work = dir.path().join("work");
let output = dir.path().join("out.json");
std::fs::create_dir_all(&work).unwrap();
std::fs::write(work.join("sample.bin.json"), r#"{"program":"sample"}"#).unwrap();
headless(Some(&work), None)
.lift(&input, &output, |_| vec![])
.expect("the staged output should have been moved");
assert_eq!(
std::fs::read_to_string(&output).unwrap(),
r#"{"program":"sample"}"#
);
assert!(
!work.join("sample.bin.json").exists(),
"the result was copied rather than moved"
);
}
#[cfg(unix)]
#[test]
fn test_a_relative_program_is_resolved_before_the_directory_changes() {
let dir = tempfile::Builder::new()
.prefix("oinkie_t")
.tempdir()
.unwrap();
let input = dir.path().join("sample.bin");
std::fs::write(&input, b"binary").unwrap();
let relative = PathBuf::from("target").join("oinkie_relative_program_test");
let _ = std::fs::remove_file(&relative);
std::os::unix::fs::symlink("/bin/echo", &relative)
.expect("could not stage a relative program");
let mut h = headless(None, None);
h.program = &relative;
let err = h
.lift(&input, &dir.path().join("out.json"), |_| vec![])
.expect_err("echo writes no JSON, so this cannot succeed");
let _ = std::fs::remove_file(&relative);
assert!(
matches!(&err, Error::Parse(m) if m.contains("STDOUT")),
"the program was not resolved before the directory changed: {err}"
);
}
fn pathdiff(absolute: &Path) -> PathBuf {
let cwd = std::env::current_dir().unwrap();
match absolute.strip_prefix(&cwd) {
Ok(rest) => rest.to_path_buf(),
Err(_) => absolute.to_path_buf(),
}
}
}