Skip to main content

gatekpr_opencode/
config.rs

1//! Configuration for OpenCode CLI integration
2//!
3//! Configures how the Rust client invokes the OpenCode CLI and
4//! sets up MCP server connections for RAG-powered validation.
5
6use crate::error::{OpenCodeError, Result};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// Default OpenCode CLI binary name
12const OPENCODE_BINARY: &str = "opencode";
13
14/// Default model for Z.AI Coding Plan
15const DEFAULT_MODEL: &str = "zai-coding-plan/glm-4.7";
16
17/// Default timeout for CLI operations
18const DEFAULT_TIMEOUT_SECS: u64 = 120;
19
20/// Configuration for OpenCode CLI integration
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OpenCodeConfig {
23    /// Path to the OpenCode CLI binary
24    pub cli_path: PathBuf,
25
26    /// Model to use (e.g., "zai-coding-plan/glm-4.7")
27    pub model: String,
28
29    /// Path to the MCP server binary (for RAG integration)
30    pub mcp_server_path: Option<PathBuf>,
31
32    /// Timeout for CLI operations
33    #[serde(with = "duration_secs")]
34    pub timeout: Duration,
35
36    /// Working directory for OpenCode (defaults to codebase path)
37    pub working_dir: Option<PathBuf>,
38
39    /// Additional environment variables to pass to OpenCode
40    #[serde(default)]
41    pub env_vars: Vec<(String, String)>,
42
43    /// Platform name for prompt customization (e.g., "Shopify", "WordPress", "Salesforce")
44    /// Loaded from GATEKPR_PLATFORM or SHOPIFY_APPROVER_PLATFORM env var.
45    /// Defaults to "Shopify" if not set.
46    pub platform: Option<String>,
47}
48
49impl OpenCodeConfig {
50    /// Create a new configuration with auto-detected CLI path
51    pub fn new() -> Result<Self> {
52        let cli_path = Self::find_cli_path()?;
53
54        Ok(Self {
55            cli_path,
56            model: DEFAULT_MODEL.to_string(),
57            mcp_server_path: None,
58            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
59            working_dir: None,
60            env_vars: Vec::new(),
61            platform: None,
62        })
63    }
64
65    /// Create configuration with explicit CLI path
66    pub fn with_cli_path(cli_path: PathBuf) -> Self {
67        Self {
68            cli_path,
69            model: DEFAULT_MODEL.to_string(),
70            mcp_server_path: None,
71            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
72            working_dir: None,
73            env_vars: Vec::new(),
74            platform: None,
75        }
76    }
77
78    /// Create configuration from environment variables
79    ///
80    /// Environment variables:
81    /// - `OPENCODE_CLI_PATH`: Path to opencode binary
82    /// - `OPENCODE_MODEL`: Model to use (default: zai-coding-plan/glm-4.7)
83    /// - `OPENCODE_TIMEOUT`: Timeout in seconds (default: 120)
84    /// - `MCP_SERVER_PATH`: Path to gatekpr-mcp-server binary
85    /// - `GATEKPR_PLATFORM` or `SHOPIFY_APPROVER_PLATFORM`: Platform name (default: "Shopify")
86    pub fn from_env() -> Result<Self> {
87        let cli_path = std::env::var("OPENCODE_CLI_PATH")
88            .map(PathBuf::from)
89            .or_else(|_| Self::find_cli_path())?;
90
91        let model = std::env::var("OPENCODE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
92
93        let timeout = std::env::var("OPENCODE_TIMEOUT")
94            .ok()
95            .and_then(|s| s.parse().ok())
96            .map(Duration::from_secs)
97            .unwrap_or_else(|| Duration::from_secs(DEFAULT_TIMEOUT_SECS));
98
99        let mcp_server_path = std::env::var("MCP_SERVER_PATH").ok().map(PathBuf::from);
100
101        let platform = std::env::var("GATEKPR_PLATFORM")
102            .or_else(|_| std::env::var("SHOPIFY_APPROVER_PLATFORM"))
103            .ok();
104
105        Ok(Self {
106            cli_path,
107            model,
108            mcp_server_path,
109            timeout,
110            working_dir: None,
111            env_vars: Vec::new(),
112            platform,
113        })
114    }
115
116    /// Find the OpenCode CLI binary path
117    fn find_cli_path() -> Result<PathBuf> {
118        // Try ~/.opencode/bin/opencode first
119        if let Some(home) = dirs::home_dir() {
120            let opencode_path = home.join(".opencode").join("bin").join("opencode");
121            if opencode_path.exists() {
122                return Ok(opencode_path);
123            }
124        }
125
126        // Try to find in PATH
127        which::which(OPENCODE_BINARY)
128            .map_err(|_| OpenCodeError::CliNotFound(OPENCODE_BINARY.to_string()))
129    }
130
131    /// Set the model to use
132    pub fn with_model(mut self, model: impl Into<String>) -> Self {
133        self.model = model.into();
134        self
135    }
136
137    /// Set the MCP server path
138    pub fn with_mcp_server(mut self, path: PathBuf) -> Self {
139        self.mcp_server_path = Some(path);
140        self
141    }
142
143    /// Set the timeout
144    pub fn with_timeout(mut self, timeout: Duration) -> Self {
145        self.timeout = timeout;
146        self
147    }
148
149    /// Set the working directory
150    pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
151        self.working_dir = Some(dir);
152        self
153    }
154
155    /// Add an environment variable
156    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
157        self.env_vars.push((key.into(), value.into()));
158        self
159    }
160
161    /// Validate the configuration
162    pub fn validate(&self) -> Result<()> {
163        if !self.cli_path.exists() {
164            return Err(OpenCodeError::CliNotFound(
165                self.cli_path.display().to_string(),
166            ));
167        }
168
169        if self.model.is_empty() {
170            return Err(OpenCodeError::InvalidConfig(
171                "Model cannot be empty".to_string(),
172            ));
173        }
174
175        if let Some(ref mcp_path) = self.mcp_server_path {
176            if !mcp_path.exists() {
177                return Err(OpenCodeError::InvalidConfig(format!(
178                    "MCP server not found: {}",
179                    mcp_path.display()
180                )));
181            }
182        }
183
184        Ok(())
185    }
186
187    /// Check if MCP integration is available
188    pub fn has_mcp(&self) -> bool {
189        self.mcp_server_path.is_some()
190    }
191}
192
193impl Default for OpenCodeConfig {
194    fn default() -> Self {
195        Self::new().unwrap_or_else(|_| Self {
196            cli_path: PathBuf::from("opencode"),
197            model: DEFAULT_MODEL.to_string(),
198            mcp_server_path: None,
199            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
200            working_dir: None,
201            env_vars: Vec::new(),
202            platform: None,
203        })
204    }
205}
206
207/// Serde helper for Duration as seconds
208mod duration_secs {
209    use serde::{Deserialize, Deserializer, Serializer};
210    use std::time::Duration;
211
212    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
213    where
214        S: Serializer,
215    {
216        serializer.serialize_u64(duration.as_secs())
217    }
218
219    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
220    where
221        D: Deserializer<'de>,
222    {
223        let secs = u64::deserialize(deserializer)?;
224        Ok(Duration::from_secs(secs))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_default_model() {
234        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"));
235        assert_eq!(config.model, DEFAULT_MODEL);
236    }
237
238    #[test]
239    fn test_with_model() {
240        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
241            .with_model("custom/model");
242        assert_eq!(config.model, "custom/model");
243    }
244
245    #[test]
246    fn test_with_mcp_server() {
247        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
248            .with_mcp_server(PathBuf::from("/usr/bin/mcp-server"));
249        assert!(config.has_mcp());
250    }
251
252    #[test]
253    fn test_with_timeout() {
254        let config = OpenCodeConfig::with_cli_path(PathBuf::from("/usr/bin/opencode"))
255            .with_timeout(Duration::from_secs(300));
256        assert_eq!(config.timeout, Duration::from_secs(300));
257    }
258}