ag-forge 0.15.15

Agentty is an ADE (Agentic Development Environment) for structured, controllable AI-assisted software development.
Documentation
//! Forge CLI command boundary used by review-request adapters.

use std::io::ErrorKind;
use std::path::PathBuf;
use std::time::Duration;

use tokio::process::Command;
use tokio::time;

use super::ForgeFuture;

/// Maximum time one forge CLI command may run before it is canceled.
const FORGE_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);

/// One forge CLI invocation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ForgeCommand {
    /// Argument vector passed to the executable.
    pub(crate) arguments: Vec<String>,
    /// Environment variables applied to the spawned process.
    pub(crate) environment: Vec<(String, String)>,
    /// Executable name passed to the OS process launcher.
    pub(crate) executable: &'static str,
    /// Working directory used for one repository-aware forge command.
    pub(crate) working_directory: Option<PathBuf>,
}

impl ForgeCommand {
    /// Builds one forge CLI command with no extra environment.
    pub(crate) fn new(executable: &'static str, arguments: Vec<String>) -> Self {
        Self {
            arguments,
            environment: Vec::new(),
            executable,
            working_directory: None,
        }
    }

    /// Adds one environment variable to the command.
    pub(crate) fn with_environment(mut self, key: &str, value: impl Into<String>) -> Self {
        self.environment.push((key.to_string(), value.into()));

        self
    }

    /// Sets the working directory for one repository-aware forge command.
    pub(crate) fn with_working_directory(mut self, working_directory: PathBuf) -> Self {
        self.working_directory = Some(working_directory);

        self
    }

    /// Sets the working directory when one repository path is available.
    pub(crate) fn with_optional_working_directory(
        self,
        working_directory: Option<PathBuf>,
    ) -> Self {
        match working_directory {
            Some(working_directory) => self.with_working_directory(working_directory),
            None => self,
        }
    }
}

/// Raw process output captured from one forge CLI invocation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ForgeCommandOutput {
    /// Process exit code, or `None` when the process terminated without one.
    pub(crate) exit_code: Option<i32>,
    /// Captured standard error text.
    pub(crate) stderr: String,
    /// Captured standard output text.
    pub(crate) stdout: String,
}

impl ForgeCommandOutput {
    /// Returns whether the command exited successfully.
    pub(crate) fn success(&self) -> bool {
        self.exit_code == Some(0)
    }
}

/// Spawn-time failures before a forge CLI command can complete.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ForgeCommandError {
    /// The requested executable was not found on the local machine.
    ExecutableNotFound { executable: String },
    /// The process could not be started for another reason.
    SpawnFailed {
        /// Executable name that failed to spawn.
        executable: String,
        /// Human-readable spawn error detail.
        message: String,
    },
    /// The process exceeded the bounded forge-command runtime.
    TimedOut {
        /// Executable whose process exceeded the timeout.
        executable: String,
        /// Configured command timeout.
        timeout: Duration,
    },
}

/// Async command boundary used by forge adapters.
#[cfg_attr(test, mockall::automock)]
pub(crate) trait ForgeCommandRunner: Send + Sync {
    /// Runs one forge CLI command and returns the captured output.
    fn run(
        &self,
        command: ForgeCommand,
    ) -> ForgeFuture<Result<ForgeCommandOutput, ForgeCommandError>>;
}

/// Production [`ForgeCommandRunner`] backed by `tokio::process::Command`.
pub(crate) struct RealForgeCommandRunner;

impl RealForgeCommandRunner {
    /// Runs one forge CLI command with an explicit upper bound.
    async fn run_command_with_timeout(
        command: ForgeCommand,
        timeout: Duration,
    ) -> Result<ForgeCommandOutput, ForgeCommandError> {
        let mut process = Command::new(command.executable);
        process.args(&command.arguments);
        process.kill_on_drop(true);

        for (key, value) in &command.environment {
            process.env(key, value);
        }

        if let Some(working_directory) = &command.working_directory {
            process.current_dir(working_directory);
        }

        let output = time::timeout(timeout, process.output())
            .await
            .map_err(|_| ForgeCommandError::TimedOut {
                executable: command.executable.to_string(),
                timeout,
            })?
            .map_err(|error| {
                if error.kind() == ErrorKind::NotFound {
                    return ForgeCommandError::ExecutableNotFound {
                        executable: command.executable.to_string(),
                    };
                }

                ForgeCommandError::SpawnFailed {
                    executable: command.executable.to_string(),
                    message: error.to_string(),
                }
            })?;

        Ok(ForgeCommandOutput {
            exit_code: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        })
    }
}

impl ForgeCommandRunner for RealForgeCommandRunner {
    fn run(
        &self,
        command: ForgeCommand,
    ) -> ForgeFuture<Result<ForgeCommandOutput, ForgeCommandError>> {
        Box::pin(
            async move { Self::run_command_with_timeout(command, FORGE_COMMAND_TIMEOUT).await },
        )
    }
}

/// Extracts the best human-readable error detail from command output.
pub(crate) fn command_output_detail(output: &ForgeCommandOutput) -> String {
    let stderr_text = output.stderr.trim();
    if !stderr_text.is_empty() {
        return stderr_text.to_string();
    }

    let stdout_text = output.stdout.trim();
    if !stdout_text.is_empty() {
        return stdout_text.to_string();
    }

    "Unknown forge CLI error".to_string()
}

#[cfg(test)]
#[path = "command_test.rs"]
mod tests;