use crate::error::{OpenCodeError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
const OPENCODE_BINARY: &str = "opencode";
const DEFAULT_MODEL: &str = "zai-coding-plan/glm-4.7";
const DEFAULT_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenCodeConfig {
pub cli_path: PathBuf,
pub model: String,
pub mcp_server_path: Option<PathBuf>,
#[serde(with = "duration_secs")]
pub timeout: Duration,
pub working_dir: Option<PathBuf>,
#[serde(default)]
pub env_vars: Vec<(String, String)>,
pub platform: Option<String>,
}
impl OpenCodeConfig {
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,
})
}
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,
}
}
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,
})
}
fn find_cli_path() -> Result<PathBuf> {
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);
}
}
which::which(OPENCODE_BINARY)
.map_err(|_| OpenCodeError::CliNotFound(OPENCODE_BINARY.to_string()))
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_mcp_server(mut self, path: PathBuf) -> Self {
self.mcp_server_path = Some(path);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
self.working_dir = Some(dir);
self
}
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env_vars.push((key.into(), value.into()));
self
}
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(())
}
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,
})
}
}
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));
}
}