gatekpr-opencode 0.2.3

OpenCode CLI integration for RAG-powered validation enrichment
Documentation
//! Configuration for OpenCode CLI integration
//!
//! Configures how the Rust client invokes the OpenCode CLI and
//! sets up MCP server connections for RAG-powered validation.

use crate::error::{OpenCodeError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;

/// Default OpenCode CLI binary name
const OPENCODE_BINARY: &str = "opencode";

/// Default model for Z.AI Coding Plan
const DEFAULT_MODEL: &str = "zai-coding-plan/glm-4.7";

/// Default timeout for CLI operations
const DEFAULT_TIMEOUT_SECS: u64 = 120;

/// Configuration for OpenCode CLI integration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeConfig {
    /// Path to the OpenCode CLI binary
    pub cli_path: PathBuf,

    /// Model to use (e.g., "zai-coding-plan/glm-4.7")
    pub model: String,

    /// Path to the MCP server binary (for RAG integration)
    pub mcp_server_path: Option<PathBuf>,

    /// Timeout for CLI operations
    #[serde(with = "duration_secs")]
    pub timeout: Duration,

    /// Working directory for OpenCode (defaults to codebase path)
    pub working_dir: Option<PathBuf>,

    /// Additional environment variables to pass to OpenCode
    #[serde(default)]
    pub env_vars: Vec<(String, String)>,

    /// Platform name for prompt customization (e.g., "Shopify", "WordPress", "Salesforce")
    /// Loaded from GATEKPR_PLATFORM or SHOPIFY_APPROVER_PLATFORM env var.
    /// Defaults to "Shopify" if not set.
    pub platform: Option<String>,
}

impl OpenCodeConfig {
    /// Create a new configuration with auto-detected CLI path
    pub fn new() -> Result<Self> {
        let cli_path = Self::find_cli_path()?;

        Ok(Self {
            cli_path,
            model: DEFAULT_MODEL.to_string(),
            mcp_server_path: None,
            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
            working_dir: None,
            env_vars: Vec::new(),
            platform: None,
        })
    }

    /// Create configuration with explicit CLI path
    pub fn with_cli_path(cli_path: PathBuf) -> Self {
        Self {
            cli_path,
            model: DEFAULT_MODEL.to_string(),
            mcp_server_path: None,
            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
            working_dir: None,
            env_vars: Vec::new(),
            platform: None,
        }
    }

    /// Create configuration from environment variables
    ///
    /// Environment variables:
    /// - `OPENCODE_CLI_PATH`: Path to opencode binary
    /// - `OPENCODE_MODEL`: Model to use (default: zai-coding-plan/glm-4.7)
    /// - `OPENCODE_TIMEOUT`: Timeout in seconds (default: 120)
    /// - `MCP_SERVER_PATH`: Path to gatekpr-mcp-server binary
    /// - `GATEKPR_PLATFORM` or `SHOPIFY_APPROVER_PLATFORM`: Platform name (default: "Shopify")
    pub fn from_env() -> Result<Self> {
        let cli_path = std::env::var("OPENCODE_CLI_PATH")
            .map(PathBuf::from)
            .or_else(|_| Self::find_cli_path())?;

        let model = std::env::var("OPENCODE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());

        let timeout = std::env::var("OPENCODE_TIMEOUT")
            .ok()
            .and_then(|s| s.parse().ok())
            .map(Duration::from_secs)
            .unwrap_or_else(|| Duration::from_secs(DEFAULT_TIMEOUT_SECS));

        let mcp_server_path = std::env::var("MCP_SERVER_PATH").ok().map(PathBuf::from);

        let platform = std::env::var("GATEKPR_PLATFORM")
            .or_else(|_| std::env::var("SHOPIFY_APPROVER_PLATFORM"))
            .ok();

        Ok(Self {
            cli_path,
            model,
            mcp_server_path,
            timeout,
            working_dir: None,
            env_vars: Vec::new(),
            platform,
        })
    }

    /// Find the OpenCode CLI binary path
    fn find_cli_path() -> Result<PathBuf> {
        // Try ~/.opencode/bin/opencode first
        if let Some(home) = dirs::home_dir() {
            let opencode_path = home.join(".opencode").join("bin").join("opencode");
            if opencode_path.exists() {
                return Ok(opencode_path);
            }
        }

        // Try to find in PATH
        which::which(OPENCODE_BINARY)
            .map_err(|_| OpenCodeError::CliNotFound(OPENCODE_BINARY.to_string()))
    }

    /// Set the model to use
    pub fn with_model(mut self, model: impl Into<String>) -> Self {
        self.model = model.into();
        self
    }

    /// Set the MCP server path
    pub fn with_mcp_server(mut self, path: PathBuf) -> Self {
        self.mcp_server_path = Some(path);
        self
    }

    /// Set the timeout
    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Set the working directory
    pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
        self.working_dir = Some(dir);
        self
    }

    /// Add an environment variable
    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.env_vars.push((key.into(), value.into()));
        self
    }

    /// Validate the configuration
    pub fn validate(&self) -> Result<()> {
        if !self.cli_path.exists() {
            return Err(OpenCodeError::CliNotFound(
                self.cli_path.display().to_string(),
            ));
        }

        if self.model.is_empty() {
            return Err(OpenCodeError::InvalidConfig(
                "Model cannot be empty".to_string(),
            ));
        }

        if let Some(ref mcp_path) = self.mcp_server_path {
            if !mcp_path.exists() {
                return Err(OpenCodeError::InvalidConfig(format!(
                    "MCP server not found: {}",
                    mcp_path.display()
                )));
            }
        }

        Ok(())
    }

    /// Check if MCP integration is available
    pub fn has_mcp(&self) -> bool {
        self.mcp_server_path.is_some()
    }
}

impl Default for OpenCodeConfig {
    fn default() -> Self {
        Self::new().unwrap_or_else(|_| Self {
            cli_path: PathBuf::from("opencode"),
            model: DEFAULT_MODEL.to_string(),
            mcp_server_path: None,
            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
            working_dir: None,
            env_vars: Vec::new(),
            platform: None,
        })
    }
}

/// Serde helper for Duration as seconds
mod duration_secs {
    use serde::{Deserialize, Deserializer, Serializer};
    use std::time::Duration;

    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_u64(duration.as_secs())
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
    where
        D: Deserializer<'de>,
    {
        let secs = u64::deserialize(deserializer)?;
        Ok(Duration::from_secs(secs))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_model() {
        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"));
        assert_eq!(config.model, DEFAULT_MODEL);
    }

    #[test]
    fn test_with_model() {
        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
            .with_model("custom/model");
        assert_eq!(config.model, "custom/model");
    }

    #[test]
    fn test_with_mcp_server() {
        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
            .with_mcp_server(PathBuf::from("/usr/bin/mcp-server"));
        assert!(config.has_mcp());
    }

    #[test]
    fn test_with_timeout() {
        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
            .with_timeout(Duration::from_secs(300));
        assert_eq!(config.timeout, Duration::from_secs(300));
    }
}