opencode_orchestrator_mcp/
version.rs1use anyhow::Context;
2use anyhow::anyhow;
3pub use opencode_rs::version::OPENCODE_BINARY_ARGS_ENV;
4pub use opencode_rs::version::OPENCODE_BINARY_ENV;
5pub use opencode_rs::version::PINNED_OPENCODE_VERSION;
6pub use opencode_rs::version::validate_exact_version;
7use std::path::Path;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone)]
16pub struct LauncherConfig {
17 pub binary: String,
19 pub launcher_args: Vec<String>,
21}
22
23#[cfg(test)]
24pub fn normalize_version(raw: &str) -> &str {
25 opencode_rs::version::normalize_version(raw)
26}
27
28pub fn default_pinned_binary_path(base_dir: &Path) -> PathBuf {
29 base_dir
30 .join(".opencode")
31 .join("bin")
32 .join(format!("opencode-v{PINNED_OPENCODE_VERSION}"))
33}
34
35pub fn resolve_opencode_binary(base_dir: &Path) -> anyhow::Result<PathBuf> {
36 if let Ok(value) = std::env::var(OPENCODE_BINARY_ENV) {
37 let value = value.trim();
38 if !value.is_empty() {
39 let path = PathBuf::from(value);
40 return path.canonicalize().with_context(|| {
41 format!("OPENCODE_BINARY points to missing path: {}", path.display())
42 });
43 }
44 }
45
46 let candidate = default_pinned_binary_path(base_dir);
47 if candidate.exists() {
48 return candidate
49 .canonicalize()
50 .with_context(|| format!("Failed to canonicalize {}", candidate.display()));
51 }
52
53 tracing::warn!(
55 "No pinned OpenCode binary found at {}; falling back to 'opencode' in PATH",
56 candidate.display()
57 );
58 Ok(PathBuf::from("opencode"))
59}
60
61pub fn parse_launcher_args() -> Vec<String> {
70 match std::env::var(OPENCODE_BINARY_ARGS_ENV) {
71 Ok(value) => {
72 let value = value.trim();
73 if value.is_empty() {
74 Vec::new()
75 } else {
76 value.split_whitespace().map(String::from).collect()
77 }
78 }
79 Err(_) => Vec::new(),
80 }
81}
82
83pub fn resolve_launcher_config(base_dir: &Path) -> anyhow::Result<LauncherConfig> {
91 let launcher_args = parse_launcher_args();
92
93 if !launcher_args.is_empty() {
94 let binary = std::env::var(OPENCODE_BINARY_ENV)
97 .map_or_else(|_| "opencode".to_string(), |v| v.trim().to_string());
98
99 if binary.is_empty() {
100 return Err(anyhow!(
101 "OPENCODE_BINARY_ARGS is set but OPENCODE_BINARY is empty.\n\
102 When using launcher args, set OPENCODE_BINARY to the launcher command (e.g., 'bunx')."
103 ));
104 }
105
106 tracing::debug!(
107 binary = %binary,
108 launcher_args = ?launcher_args,
109 "using launcher mode for OpenCode"
110 );
111
112 return Ok(LauncherConfig {
113 binary,
114 launcher_args,
115 });
116 }
117
118 let binary = resolve_opencode_binary(base_dir)?;
120 Ok(LauncherConfig {
121 binary: binary.to_string_lossy().to_string(),
122 launcher_args: Vec::new(),
123 })
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use serial_test::serial;
130
131 #[test]
132 fn normalize_strips_v_prefix() {
133 assert_eq!(normalize_version("v1.14.33"), "1.14.33");
134 assert_eq!(normalize_version("1.14.33"), "1.14.33");
135 assert_eq!(normalize_version(" v1.14.33 "), "1.14.33");
136 }
137
138 #[test]
139 fn validate_exact_version_enforces_pinned() {
140 validate_exact_version(Some(PINNED_OPENCODE_VERSION)).unwrap();
141 validate_exact_version(Some(&format!("v{PINNED_OPENCODE_VERSION}"))).unwrap();
142 assert!(validate_exact_version(Some("1.14.18")).is_err());
143 assert!(validate_exact_version(None).is_err());
144 }
145
146 #[test]
147 fn default_pinned_binary_path_uses_repo_local_recipe() {
148 let base = Path::new("/tmp/project");
149 assert_eq!(
150 default_pinned_binary_path(base),
151 PathBuf::from(format!(
152 "/tmp/project/.opencode/bin/opencode-v{PINNED_OPENCODE_VERSION}"
153 ))
154 );
155 }
156
157 #[test]
158 #[serial(env)]
159 fn parse_launcher_args_empty_when_unset() {
160 unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
162 assert!(parse_launcher_args().is_empty());
163 }
164
165 #[test]
166 #[serial(env)]
167 fn parse_launcher_args_splits_on_whitespace() {
168 unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33") };
170 assert_eq!(parse_launcher_args(), vec!["opencode-ai@1.14.33"]);
171
172 unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "--yes opencode-ai@1.14.33") };
174 assert_eq!(parse_launcher_args(), vec!["--yes", "opencode-ai@1.14.33"]);
175
176 unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
178 }
179
180 #[test]
181 #[serial(env)]
182 fn parse_launcher_args_empty_string_returns_empty() {
183 unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, " ") };
185 assert!(parse_launcher_args().is_empty());
186
187 unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
189 }
190
191 #[test]
192 #[serial(env)]
193 fn resolve_launcher_config_launcher_mode() {
194 unsafe {
196 std::env::set_var(OPENCODE_BINARY_ENV, "bunx");
197 std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33");
198 }
199
200 let base = Path::new("/tmp/project");
201 let config = resolve_launcher_config(base).unwrap();
202
203 assert_eq!(config.binary, "bunx");
204 assert_eq!(config.launcher_args, vec!["opencode-ai@1.14.33"]);
205
206 unsafe {
208 std::env::remove_var(OPENCODE_BINARY_ENV);
209 std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
210 }
211 }
212
213 #[test]
214 #[serial(env)]
215 fn resolve_launcher_config_errors_when_args_set_but_binary_empty() {
216 unsafe {
218 std::env::set_var(OPENCODE_BINARY_ENV, " ");
219 std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33");
220 }
221
222 let base = Path::new("/tmp/project");
223 let result = resolve_launcher_config(base);
224 assert!(result.is_err());
225 assert!(
226 result
227 .unwrap_err()
228 .to_string()
229 .contains("OPENCODE_BINARY is empty")
230 );
231
232 unsafe {
234 std::env::remove_var(OPENCODE_BINARY_ENV);
235 std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
236 }
237 }
238}