helen 0.1.0

Repository review gate.
Documentation
//! Process, git, and filesystem helpers for the elenchus gate.

use super::error::{ElenchusError, Result};
use std::{
    env,
    ffi::OsStr,
    fmt::Write as _,
    fs::{self, File},
    io::{BufReader, Read as _, Write as _},
    path::{Path, PathBuf},
    process::{Command, ExitStatus, Output, Stdio},
};

/// Requires an executable to be discoverable on `PATH`.
pub(super) fn require_on_path(program: &str) -> Result<PathBuf> {
    resolve_on_path(program).ok_or_else(|| {
        ElenchusError::usage(format!("error: {program} is not installed or not on PATH"))
    })
}

/// Resolves a program name against the process `PATH`.
pub(super) fn resolve_on_path(program: &str) -> Option<PathBuf> {
    let path = Path::new(program);
    if path.components().count() > 1 {
        return path.is_file().then(|| path.to_path_buf());
    }

    env::var_os("PATH").and_then(|paths| {
        env::split_paths(&paths)
            .map(|dir| dir.join(program))
            .find(|candidate| candidate.is_file())
    })
}

/// Runs a command and returns trimmed stdout text.
pub(super) fn command_text<I, S>(program: &str, args: I) -> Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let output = command_output(program, args)?;
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}

/// Runs a command and requires success.
pub(super) fn checked_command<I, S>(program: &str, args: I) -> Result<()>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    let status = Command::new(program)
        .args(&args)
        .status()
        .map_err(|error| {
            ElenchusError::failure(format!(
                "error: failed to run {}: {error}",
                command_display(program, &args)
            ))
        })?;
    if status.success() {
        Ok(())
    } else {
        Err(ElenchusError::failure(format!(
            "error: command failed with status {}: {}",
            status_code_text(status),
            command_display(program, &args)
        )))
    }
}

/// Runs a command and requires successful captured output.
fn command_output<I, S>(program: &str, args: I) -> Result<Output>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    let output = Command::new(program)
        .args(&args)
        .output()
        .map_err(|error| {
            ElenchusError::failure(format!(
                "error: failed to run {}: {error}",
                command_display(program, &args)
            ))
        })?;
    if output.status.success() {
        Ok(output)
    } else {
        Err(ElenchusError::failure(format!(
            "error: command failed with status {}: {}\n{}",
            status_code_text(output.status),
            command_display(program, &args),
            combined_output_text(&output)
        )))
    }
}

/// Runs a command and captures output regardless of exit status.
pub(super) fn command_output_allow_failure<I, S>(program: &str, args: I) -> Result<Output>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    Command::new(program).args(&args).output().map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to run {}: {error}",
            command_display(program, &args)
        ))
    })
}

/// Runs a git command and returns trimmed stdout.
pub(super) fn git_text<I, S>(args: I) -> Result<String>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    command_text("git", args)
}

/// Runs a git command and returns its exit status.
pub(super) fn git_status<I, S>(args: I) -> Result<ExitStatus>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    Command::new("git").args(&args).status().map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to run {}: {error}",
            command_display("git", &args)
        ))
    })
}

/// Runs a git command with stdout/stderr discarded and returns its exit status.
pub(super) fn git_status_silent<I, S>(args: I) -> Result<ExitStatus>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    let args = args
        .into_iter()
        .map(|arg| arg.as_ref().to_os_string())
        .collect::<Vec<_>>();
    Command::new("git")
        .args(&args)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|error| {
            ElenchusError::failure(format!(
                "error: failed to run {}: {error}",
                command_display("git", &args)
            ))
        })
}

/// Runs a git command and requires success.
pub(super) fn git_checked<I, S>(args: I) -> Result<()>
where
    I: IntoIterator<Item = S>,
    S: AsRef<OsStr>,
{
    checked_command("git", args)
}

/// Hashes a file with `git hash-object`.
pub(super) fn git_hash_file(path: &Path) -> Result<String> {
    command_text("git", ["hash-object", &path.display().to_string()])
}

/// Hashes stdin bytes with `git hash-object --stdin`.
pub(super) fn git_hash_stdin(contents: &str) -> Result<String> {
    let mut child = Command::new("git")
        .args(["hash-object", "--stdin"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|error| {
            ElenchusError::failure(format!("error: failed to run git hash-object: {error}"))
        })?;
    let mut stdin = child
        .stdin
        .take()
        .ok_or_else(|| ElenchusError::failure("error: failed to open git hash-object stdin"))?;
    stdin.write_all(contents.as_bytes()).map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to write git hash-object stdin: {error}"
        ))
    })?;
    drop(stdin);
    let output = child.wait_with_output().map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to wait for git hash-object: {error}"
        ))
    })?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
    } else {
        Err(ElenchusError::failure(format!(
            "error: git hash-object failed: {}",
            combined_output_text(&output)
        )))
    }
}

/// Writes binary file contents.
pub(super) fn write_binary(path: &Path, contents: &[u8]) -> Result<()> {
    fs::write(path, contents).map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to write {}: {error}",
            path.display()
        ))
    })
}

/// Writes `git diff --binary` directly to a file.
pub(super) fn git_diff_binary_to(path: &Path) -> Result<()> {
    let file = File::create(path).map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to create {}: {error}",
            path.display()
        ))
    })?;
    let output = Command::new("git")
        .args(["diff", "--binary"])
        .stdout(Stdio::from(file))
        .stderr(Stdio::piped())
        .output()
        .map_err(|error| {
            ElenchusError::failure(format!("error: failed to run git diff --binary: {error}"))
        })?;
    if output.status.success() {
        Ok(())
    } else {
        Err(ElenchusError::failure(format!(
            "error: git diff --binary failed with status {}:\n{}",
            status_code_text(output.status),
            String::from_utf8_lossy(&output.stderr)
        )))
    }
}

/// Creates the reviewer transcript file.
pub(super) fn review_transcript_file(path: &Path) -> Result<File> {
    File::create(path).map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to create {}: {error}",
            path.display()
        ))
    })
}

/// Returns true when two files contain identical bytes.
pub(super) fn same_file_bytes(left: &Path, right: &Path) -> Result<bool> {
    let left = File::open(left).map_err(|error| {
        ElenchusError::failure(format!("error: failed to open {}: {error}", left.display()))
    })?;
    let right = File::open(right).map_err(|error| {
        ElenchusError::failure(format!(
            "error: failed to open {}: {error}",
            right.display()
        ))
    })?;
    readers_have_same_bytes(BufReader::new(left), BufReader::new(right))
}

/// Compares two readers without buffering full files.
fn readers_have_same_bytes(mut left: BufReader<File>, mut right: BufReader<File>) -> Result<bool> {
    let mut left_chunk = [0_u8; 16 * 1024];
    let mut right_chunk = [0_u8; 16 * 1024];

    loop {
        let left_read = left.read(&mut left_chunk).map_err(|error| {
            ElenchusError::failure(format!("error: failed to read comparison input: {error}"))
        })?;
        let right_read = right.read(&mut right_chunk).map_err(|error| {
            ElenchusError::failure(format!("error: failed to read comparison input: {error}"))
        })?;
        if left_read != right_read {
            return Ok(false);
        }
        if left_read == 0 {
            return Ok(true);
        }
        if left_chunk[..left_read] != right_chunk[..right_read] {
            return Ok(false);
        }
    }
}

/// Returns true when a path is a non-empty regular file.
pub(super) fn is_non_empty_file(path: &Path) -> bool {
    fs::metadata(path).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}

/// Checks whether a metadata file contains an exact line.
pub(super) fn meta_contains(path: &Path, needle: &str) -> Result<bool> {
    let contents = fs::read_to_string(path).map_err(|error| {
        ElenchusError::failure(format!("error: failed to read {}: {error}", path.display()))
    })?;
    Ok(contents.lines().any(|line| line == needle))
}

/// Computes the added plus deleted line count from `git diff --numstat`.
pub(super) fn diff_line_count() -> Result<u64> {
    let numstat = git_text(["diff", "--numstat"])?;
    Ok(numstat
        .lines()
        .map(|line| {
            let mut columns = line.split_whitespace();
            let added = columns
                .next()
                .and_then(|value| value.parse::<u64>().ok())
                .unwrap_or(0);
            let deleted = columns
                .next()
                .and_then(|value| value.parse::<u64>().ok())
                .unwrap_or(0);
            added + deleted
        })
        .sum())
}

/// Reads 16 bytes from `/dev/urandom` and renders them as lowercase hex.
pub(super) fn random_hex() -> Result<String> {
    let mut file = File::open("/dev/urandom").map_err(|error| {
        ElenchusError::failure(format!("error: failed to open /dev/urandom: {error}"))
    })?;
    let mut bytes = [0_u8; 16];
    file.read_exact(&mut bytes).map_err(|error| {
        ElenchusError::failure(format!("error: failed to read /dev/urandom: {error}"))
    })?;
    let mut hex = String::with_capacity(32);
    for byte in bytes {
        let _result = write!(&mut hex, "{byte:02x}");
    }
    Ok(hex)
}

/// Combines stdout and stderr bytes from a captured command.
pub(super) fn combined_output_bytes(output: &Output) -> Vec<u8> {
    let mut bytes = output.stdout.clone();
    bytes.extend_from_slice(&output.stderr);
    bytes
}

/// Combines stdout and stderr as lossy text.
pub(super) fn combined_output_text(output: &Output) -> String {
    String::from_utf8_lossy(&combined_output_bytes(output)).into_owned()
}

/// Formats a process status for diagnostics.
pub(super) fn status_code_text(status: ExitStatus) -> String {
    status
        .code()
        .map_or_else(|| String::from("signal"), |code| code.to_string())
}

/// Formats a command and arguments for diagnostics.
fn command_display(program: &str, args: &[std::ffi::OsString]) -> String {
    let mut text = String::from(program);
    for arg in args {
        text.push(' ');
        text.push_str(&arg.to_string_lossy());
    }
    text
}