pray-cli 1.10.0

Package manager for the language placed before inference — Prayfile CLI (binary: pray)
use pray_core::{PrayError, PrayResult};
use serde::Deserialize;
use std::path::Path;
use std::process::{Command, Output};

#[derive(Debug, Clone)]
pub(crate) struct RepositoryRevisionConfig {
    pub(crate) remote: Option<String>,
    pub(crate) push: bool,
    pub(crate) commit_message: Option<String>,
}

#[derive(Debug, Clone)]
pub(crate) struct OtherRevisionConfig {
    pub(crate) commit_command: CommandSpec,
    pub(crate) push_command: Option<CommandSpec>,
    pub(crate) push: bool,
}

#[derive(Clone, Debug, Deserialize)]
pub(crate) struct CommandSpec {
    pub(crate) program: String,

    #[serde(default)]
    pub(crate) args: Vec<String>,
}

impl CommandSpec {
    pub(crate) fn validate(self) -> PrayResult<Self> {
        if self.program.trim().is_empty() {
            return Err(PrayError::Unsupported(
                "revision command program cannot be empty".to_string(),
            ));
        }
        Ok(self)
    }
}

pub(crate) fn record_git_revision(
    root: &Path,
    config: RepositoryRevisionConfig,
    commit_message: String,
) -> PrayResult<()> {
    run_command_success(root, "git", &["add", "-A"])?;
    if git_has_staged_changes(root)? {
        run_command_success(root, "git", &["commit", "-m", &commit_message])?;
    }

    if config.push {
        let remote = config.remote.as_deref().ok_or_else(|| {
            PrayError::Unsupported(
                "git revisioning requires a remote when push is enabled".to_string(),
            )
        })?;
        let branch = git_current_branch(root)?;
        let refspec = format!("HEAD:refs/heads/{branch}");
        push_git_revision(root, remote, &refspec, &branch)?;
    }

    Ok(())
}

pub(crate) fn record_hg_revision(
    root: &Path,
    config: RepositoryRevisionConfig,
    commit_message: String,
) -> PrayResult<()> {
    run_command_success(root, "hg", &["addremove"])?;
    if !hg_has_changes(root)? {
        return Ok(());
    }

    run_command_success(root, "hg", &["commit", "-m", &commit_message])?;

    if config.push {
        let remote = config.remote.as_deref().ok_or_else(|| {
            PrayError::Unsupported(
                "hg revisioning requires a remote when push is enabled".to_string(),
            )
        })?;
        run_command_success(root, "hg", &["push", remote])?;
    }

    Ok(())
}

pub(crate) fn record_other_revision(root: &Path, config: OtherRevisionConfig) -> PrayResult<()> {
    run_command_spec(root, &config.commit_command)?;
    if config.push {
        let push_command = config.push_command.ok_or_else(|| {
            PrayError::Unsupported(
                "revision backend 'other' requires push_command when push is enabled".to_string(),
            )
        })?;
        run_command_spec(root, &push_command)?;
    }
    Ok(())
}

fn git_has_staged_changes(root: &Path) -> PrayResult<bool> {
    let output = run_command_output(root, "git", &["diff", "--cached", "--quiet"])?;
    if output.status.success() {
        return Ok(false);
    }
    if output.status.code() == Some(1) {
        return Ok(true);
    }
    Err(command_error("git diff --cached --quiet", output))
}

fn hg_has_changes(root: &Path) -> PrayResult<bool> {
    let output = run_command_output(root, "hg", &["status"])?;
    if !output.status.success() {
        return Err(command_error("hg status", output));
    }
    Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}

fn git_current_branch(root: &Path) -> PrayResult<String> {
    let output = run_command_output(root, "git", &["branch", "--show-current"])?;
    if !output.status.success() {
        return Err(command_error("git branch --show-current", output));
    }
    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if branch.is_empty() {
        return Err(PrayError::Resolution(
            "git revisioning requires a checked-out branch to push".to_string(),
        ));
    }
    Ok(branch)
}

fn run_command_success(root: &Path, program: &str, arguments: &[&str]) -> PrayResult<Output> {
    let output = run_command_output(root, program, arguments)?;
    if output.status.success() {
        Ok(output)
    } else {
        Err(command_error(program, output))
    }
}

fn run_command_output(root: &Path, program: &str, arguments: &[&str]) -> PrayResult<Output> {
    Command::new(command_program(program))
        .current_dir(root)
        .args(arguments)
        .output()
        .map_err(|error| PrayError::Unsupported(format!("failed to run `{program}`: {error}")))
}

fn command_program(program: &str) -> String {
    if program == "git" {
        [
            "/usr/bin/git",
            "/opt/homebrew/bin/git",
            "/usr/local/bin/git",
            "git",
        ]
        .into_iter()
        .find(|candidate| std::path::Path::new(candidate).exists() || *candidate == "git")
        .unwrap_or("git")
        .to_string()
    } else {
        program.to_string()
    }
}

fn push_git_revision(root: &Path, remote: &str, refspec: &str, branch: &str) -> PrayResult<()> {
    let output = run_command_output(root, "git", &["push", remote, refspec])?;
    if output.status.success() {
        return Ok(());
    }

    let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
    if stderr.contains("non-fast-forward")
        || stderr.contains("fetch first")
        || stderr.contains("rejected")
        || stderr.contains("diverged")
    {
        return Err(PrayError::Resolution(format!(
            "git push was rejected for branch {branch}; fetch or rebase the remote changes and retry"
        )));
    }

    Err(command_error("git push", output))
}

fn run_command_spec(root: &Path, spec: &CommandSpec) -> PrayResult<()> {
    let output = Command::new(&spec.program)
        .current_dir(root)
        .args(&spec.args)
        .output()
        .map_err(|error| {
            PrayError::Unsupported(format!("failed to run `{}`: {error}", spec.program))
        })?;
    if output.status.success() {
        Ok(())
    } else {
        Err(command_error(&spec.program, output))
    }
}

fn command_error(program: &str, output: Output) -> PrayError {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let mut message = format!("{program} failed with status {}", output.status);
    if !stderr.is_empty() {
        message.push_str(&format!(": {stderr}"));
    } else if !stdout.is_empty() {
        message.push_str(&format!(": {stdout}"));
    }
    PrayError::Resolution(message)
}