use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use super::format;
use crate::harness::Harness;
use crate::process::{self, Control, Interruption};
pub(crate) struct Requested {
commit: String,
harness: Harness,
}
impl Requested {
pub fn new(commit: &str, harness: &str) -> Result<Self> {
format::commit_id(commit)?;
Ok(Self {
commit: commit.into(),
harness: Harness::named(harness).context("unknown Arm Harness")?,
})
}
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct FixedHarness {
pub name: String,
pub path: PathBuf,
pub version: String,
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct Arm {
pub commit: String,
pub binary: PathBuf,
pub harness: FixedHarness,
}
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct Prepared {
format: u32,
pub baseline: Arm,
pub candidate: Arm,
}
pub(super) fn prepare(repository: &Path, arms: [Requested; 2], record: &Path) -> Result<Prepared> {
if record.try_exists()? {
bail!("Arm record already exists: {}", record.display());
}
let [baseline, candidate] = arms;
let baseline_harness = fixed_harness(baseline.harness)?;
let candidate_harness = if baseline.harness == candidate.harness {
baseline_harness.clone()
} else {
fixed_harness(candidate.harness)?
};
let builds = crate::config::home()?.join(".thirdshift/evals/builds");
fs::create_dir_all(&builds)?;
let builds = builds.canonicalize()?;
let prepared = Prepared {
format: 1,
baseline: Arm {
binary: build(repository, &builds, &baseline.commit)?,
commit: baseline.commit,
harness: baseline_harness,
},
candidate: Arm {
binary: build(repository, &builds, &candidate.commit)?,
commit: candidate.commit,
harness: candidate_harness,
},
};
let json = serde_json::to_string_pretty(&prepared)?;
let parent = record
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(json.as_bytes())?;
temporary
.persist_noclobber(record)
.with_context(|| format!("can't publish Arm record {}", record.display()))?;
Ok(prepared)
}
pub(super) fn fixed_harness(harness: Harness) -> Result<FixedHarness> {
let name = harness.name();
let path = process::find_executable(name)
.with_context(|| format!("Arm Harness {name} is not on PATH"))?
.canonicalize()?;
let output = checked(
Command::new(&path)
.arg("--version")
.envs(harness.adapter().environment().iter().copied()),
"Arm Harness version",
)?;
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
let version = if stdout.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
}
.to_owned();
if version.is_empty() {
bail!("Arm Harness {name} reported an empty version");
}
Ok(FixedHarness {
name: name.into(),
path,
version,
})
}
pub(super) fn build(repository: &Path, builds: &Path, commit: &str) -> Result<PathBuf> {
let git = crate::git::Git::new(repository);
if git.run(&["--no-replace-objects", "cat-file", "-t", commit])? != "commit" {
bail!("Arm {commit} is not a commit");
}
let lock = fs::File::create(builds.join(format!("{commit}.lock")))?;
lock.lock()?;
let cached = builds.join(commit);
let binary = cached.join("thirdshift");
if process::is_executable(&binary) {
return Ok(binary);
}
if cached.try_exists()? {
bail!("invalid Arm build cache entry: {}", cached.display());
}
let temporary = tempfile::Builder::new()
.prefix(".arm-build-")
.tempdir_in(builds)?;
let worktree = temporary.path().join("worktree");
let worktree_path = worktree
.to_str()
.context("Arm worktree path must be UTF-8")?;
git.run(&[
"--no-replace-objects",
"-c",
"core.hooksPath=/dev/null",
"worktree",
"add",
"--detach",
worktree_path,
commit,
])?;
let target = temporary.path().join("target");
let result = checked(
Command::new("cargo")
.args(["build", "--release", "--locked"])
.current_dir(&worktree)
.env("GIT_NO_REPLACE_OBJECTS", "1")
.env("CARGO_TARGET_DIR", &target),
"Arm build",
);
let cleanup = git
.completion()
.run(&["worktree", "remove", "--force", worktree_path]);
result.with_context(|| format!("could not build Arm {commit}"))?;
cleanup?;
let built = target.join("release/thirdshift");
if !process::is_executable(&built) {
bail!(
"Arm build {commit} produced no executable at {}",
built.display()
);
}
let published = temporary.path().join("published");
fs::create_dir(&published)?;
fs::copy(built, published.join("thirdshift"))?;
fs::rename(published, cached)?;
Ok(binary)
}
fn checked(command: &mut Command, name: &str) -> Result<Output> {
let output = process::output(
command,
None,
Control {
name,
interruption: Interruption::Ordinary,
stop: &|child| process::stop(child, &[libc::SIGTERM, libc::SIGKILL]),
},
)?;
if !output.status.success() {
bail!(
"{name} failed ({}): {}\n{}",
output.status,
String::from_utf8_lossy(&output.stderr).trim(),
String::from_utf8_lossy(&output.stdout).trim()
);
}
Ok(output)
}