gatekpr_opencode/
config.rs1use crate::error::{OpenCodeError, Result};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::time::Duration;
10
11const OPENCODE_BINARY: &str = "opencode";
13
14const DEFAULT_MODEL: &str = "zai-coding-plan/glm-4.7";
16
17const DEFAULT_TIMEOUT_SECS: u64 = 120;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OpenCodeConfig {
23 pub cli_path: PathBuf,
25
26 pub model: String,
28
29 pub mcp_server_path: Option<PathBuf>,
31
32 #[serde(with = "duration_secs")]
34 pub timeout: Duration,
35
36 pub working_dir: Option<PathBuf>,
38
39 #[serde(default)]
41 pub env_vars: Vec<(String, String)>,
42
43 pub platform: Option<String>,
47}
48
49impl OpenCodeConfig {
50 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 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 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 fn find_cli_path() -> Result<PathBuf> {
118 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 which::which(OPENCODE_BINARY)
128 .map_err(|_| OpenCodeError::CliNotFound(OPENCODE_BINARY.to_string()))
129 }
130
131 pub fn with_model(mut self, model: impl Into<String>) -> Self {
133 self.model = model.into();
134 self
135 }
136
137 pub fn with_mcp_server(mut self, path: PathBuf) -> Self {
139 self.mcp_server_path = Some(path);
140 self
141 }
142
143 pub fn with_timeout(mut self, timeout: Duration) -> Self {
145 self.timeout = timeout;
146 self
147 }
148
149 pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
151 self.working_dir = Some(dir);
152 self
153 }
154
155 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 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 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
207mod 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}