koala-artifact 1.0.4

Reviewer artifact format and sampling verifier.
Documentation
//! `record` runs the reviewer's command, normalizes the output, and writes
//! the artifact to disk.

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 {}

/// Run the reviewer command in `opts.repo_root` and write the canonical
/// artifact at `.review/round-N/<kind>-<name>.md`.
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();

    // Killed by signal → no exit code; -1 is our sentinel.
    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,
    })
}

/// Read `.git/HEAD` and resolve the ref to a 7-char short SHA. Returns
/// `None` if anything goes wrong — commit metadata is advisory.
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())
}