Skip to main content

koala_artifact/
runner.rs

1//! `record` runs the reviewer's command, normalizes the output, and writes
2//! the artifact to disk.
3
4use 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
57/// Run the reviewer command in `opts.repo_root` and write the canonical
58/// artifact at `.review/round-N/<kind>-<name>.md`.
59pub 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    // Killed by signal → no exit code; -1 is our sentinel.
73    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
117/// Read `.git/HEAD` and resolve the ref to a 7-char short SHA. Returns
118/// `None` if anything goes wrong — commit metadata is advisory.
119fn 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}