1use crate::kind::ReviewerKind;
5use crate::normalize::compute_hash;
6use crate::path::ArtifactPath;
7use crate::record::ArtifactRecord;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::{Duration, Instant};
13use time::format_description::well_known::Iso8601;
14use time::OffsetDateTime;
15
16#[derive(Debug, Clone)]
17pub struct RecordOptions {
18 pub repo_root: PathBuf,
19 pub round: u32,
20 pub kind: ReviewerKind,
21 pub name: String,
22 pub reviewer: String,
23}
24
25#[derive(Debug)]
26pub struct RecordSummary {
27 pub artifact_path: PathBuf,
28 pub exit_code: i32,
29 pub output_bytes: usize,
30 pub wall_time: Duration,
31 pub hash: String,
32}
33
34#[derive(Debug)]
35pub enum RunError {
36 EmptyCommand,
37 BadOptions(String),
38 Spawn(io::Error),
39 Wait(io::Error),
40 Write(io::Error),
41}
42
43impl std::fmt::Display for RunError {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 Self::EmptyCommand => write!(f, "command is empty (nothing after `--`)"),
47 Self::BadOptions(s) => write!(f, "{s}"),
48 Self::Spawn(e) => write!(f, "spawning command failed: {e}"),
49 Self::Wait(e) => write!(f, "waiting on command failed: {e}"),
50 Self::Write(e) => write!(f, "writing artifact failed: {e}"),
51 }
52 }
53}
54
55impl std::error::Error for RunError {}
56
57pub fn run_and_record(opts: &RecordOptions, command: &[String]) -> Result<RecordSummary, RunError> {
60 if command.is_empty() {
61 return Err(RunError::EmptyCommand);
62 }
63 let path = ArtifactPath::new(opts.round, opts.kind, opts.name.clone())
64 .map_err(|e| RunError::BadOptions(e.to_string()))?;
65
66 let started = Instant::now();
67 let mut cmd = Command::new(&command[0]);
68 cmd.args(&command[1..]).current_dir(&opts.repo_root);
69 let out = cmd.output().map_err(RunError::Spawn)?;
70 let wall_time = started.elapsed();
71
72 let exit_code = out.status.code().unwrap_or(-1);
74
75 let mut combined = Vec::with_capacity(out.stdout.len() + out.stderr.len());
76 combined.extend_from_slice(&out.stdout);
77 if !out.stderr.is_empty() {
78 if !combined.is_empty() && !combined.ends_with(b"\n") {
79 combined.push(b'\n');
80 }
81 combined.extend_from_slice(&out.stderr);
82 }
83 let output = String::from_utf8_lossy(&combined).into_owned();
84
85 let hash = compute_hash(command, exit_code, &output, &opts.repo_root);
86 let timestamp = OffsetDateTime::now_utc()
87 .format(&Iso8601::DEFAULT)
88 .unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z"));
89 let commit = read_git_head_short(&opts.repo_root);
90
91 let record = ArtifactRecord {
92 path: path.clone(),
93 reviewer: opts.reviewer.clone(),
94 timestamp,
95 commit,
96 command: command.to_vec(),
97 exit_code,
98 output: output.clone(),
99 hash: hash.clone(),
100 };
101
102 let abs = path.absolute(&opts.repo_root);
103 if let Some(parent) = abs.parent() {
104 fs::create_dir_all(parent).map_err(RunError::Write)?;
105 }
106 fs::write(&abs, record.render()).map_err(RunError::Write)?;
107
108 Ok(RecordSummary {
109 artifact_path: abs,
110 exit_code,
111 output_bytes: output.len(),
112 wall_time,
113 hash,
114 })
115}
116
117fn read_git_head_short(root: &Path) -> Option<String> {
120 let head = fs::read_to_string(root.join(".git/HEAD")).ok()?;
121 let head = head.trim();
122 let sha = if let Some(refname) = head.strip_prefix("ref: ") {
123 let ref_path = root.join(".git").join(refname);
124 fs::read_to_string(&ref_path).ok()?.trim().to_string()
125 } else {
126 head.to_string()
127 };
128 if sha.len() < 7 {
129 return None;
130 }
131 Some(sha[..7].to_string())
132}