use crate::kind::ReviewerKind;
use crate::normalize::compute_hash;
use crate::path::ArtifactPath;
use crate::record::ArtifactRecord;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use time::format_description::well_known::Iso8601;
use time::OffsetDateTime;
#[derive(Debug, Clone)]
pub struct RecordOptions {
pub repo_root: PathBuf,
pub round: u32,
pub kind: ReviewerKind,
pub name: String,
pub reviewer: String,
}
#[derive(Debug)]
pub struct RecordSummary {
pub artifact_path: PathBuf,
pub exit_code: i32,
pub output_bytes: usize,
pub wall_time: Duration,
pub hash: String,
}
#[derive(Debug)]
pub enum RunError {
EmptyCommand,
BadOptions(String),
Spawn(io::Error),
Wait(io::Error),
Write(io::Error),
}
impl std::fmt::Display for RunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyCommand => write!(f, "command is empty (nothing after `--`)"),
Self::BadOptions(s) => write!(f, "{s}"),
Self::Spawn(e) => write!(f, "spawning command failed: {e}"),
Self::Wait(e) => write!(f, "waiting on command failed: {e}"),
Self::Write(e) => write!(f, "writing artifact failed: {e}"),
}
}
}
impl std::error::Error for RunError {}
pub fn run_and_record(opts: &RecordOptions, command: &[String]) -> Result<RecordSummary, RunError> {
if command.is_empty() {
return Err(RunError::EmptyCommand);
}
let path = ArtifactPath::new(opts.round, opts.kind, opts.name.clone())
.map_err(|e| RunError::BadOptions(e.to_string()))?;
let started = Instant::now();
let mut cmd = Command::new(&command[0]);
cmd.args(&command[1..]).current_dir(&opts.repo_root);
let out = cmd.output().map_err(RunError::Spawn)?;
let wall_time = started.elapsed();
let exit_code = out.status.code().unwrap_or(-1);
let mut combined = Vec::with_capacity(out.stdout.len() + out.stderr.len());
combined.extend_from_slice(&out.stdout);
if !out.stderr.is_empty() {
if !combined.is_empty() && !combined.ends_with(b"\n") {
combined.push(b'\n');
}
combined.extend_from_slice(&out.stderr);
}
let output = String::from_utf8_lossy(&combined).into_owned();
let hash = compute_hash(command, exit_code, &output, &opts.repo_root);
let timestamp = OffsetDateTime::now_utc()
.format(&Iso8601::DEFAULT)
.unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z"));
let commit = read_git_head_short(&opts.repo_root);
let record = ArtifactRecord {
path: path.clone(),
reviewer: opts.reviewer.clone(),
timestamp,
commit,
command: command.to_vec(),
exit_code,
output: output.clone(),
hash: hash.clone(),
};
let abs = path.absolute(&opts.repo_root);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(RunError::Write)?;
}
fs::write(&abs, record.render()).map_err(RunError::Write)?;
Ok(RecordSummary {
artifact_path: abs,
exit_code,
output_bytes: output.len(),
wall_time,
hash,
})
}
fn read_git_head_short(root: &Path) -> Option<String> {
let head = fs::read_to_string(root.join(".git/HEAD")).ok()?;
let head = head.trim();
let sha = if let Some(refname) = head.strip_prefix("ref: ") {
let ref_path = root.join(".git").join(refname);
fs::read_to_string(&ref_path).ok()?.trim().to_string()
} else {
head.to_string()
};
if sha.len() < 7 {
return None;
}
Some(sha[..7].to_string())
}