1use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
15
16#[derive(Debug)]
17pub struct OpencodeAdapter;
18
19impl TargetAdapter for OpencodeAdapter {
20 fn name(&self) -> &str {
21 ".opencode"
22 }
23
24 fn skill_variant_key(&self) -> Option<&str> {
25 Some("opencode")
26 }
27
28 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
29 match kind {
30 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
31 _ => None,
32 }
33 }
34
35 fn write_config_entries(
36 &self,
37 entries: &[ConfigEntry],
38 target_dir: &Path,
39 ) -> Result<Vec<PathBuf>, MarsError> {
40 let mcp_servers: Vec<&McpServerEntry> = entries
41 .iter()
42 .filter_map(|e| {
43 if let ConfigEntry::McpServer(s) = e {
44 Some(s)
45 } else {
46 None
47 }
48 })
49 .collect();
50
51 let hooks: Vec<&HookEntry> = entries
52 .iter()
53 .filter_map(|e| {
54 if let ConfigEntry::Hook(h) = e {
55 Some(h)
56 } else {
57 None
58 }
59 })
60 .collect();
61
62 if mcp_servers.is_empty() && hooks.is_empty() {
63 return Ok(Vec::new());
64 }
65
66 let path = write_opencode_config(target_dir, &mcp_servers, &hooks)?;
68 Ok(vec![path])
69 }
70
71 fn remove_config_entries(
72 &self,
73 entry_keys: &[String],
74 target_dir: &Path,
75 ) -> Result<(), MarsError> {
76 remove_opencode_entries(entry_keys, target_dir)
77 }
78}
79
80fn write_opencode_config(
100 target_dir: &Path,
101 servers: &[&McpServerEntry],
102 hooks: &[&HookEntry],
103) -> Result<PathBuf, MarsError> {
104 let path = target_dir.join("opencode.json");
105
106 let mut root: serde_json::Value = if path.is_file() {
107 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
108 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
109 } else {
110 serde_json::json!({})
111 };
112
113 let root_obj = root.as_object_mut().ok_or_else(|| {
114 MarsError::Config(crate::error::ConfigError::Invalid {
115 message: format!("{} is not a JSON object", path.display()),
116 })
117 })?;
118
119 if !servers.is_empty() {
121 let mcp_obj = root_obj
122 .entry("mcpServers")
123 .or_insert_with(|| serde_json::json!({}));
124 let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
125 MarsError::Config(crate::error::ConfigError::Invalid {
126 message: format!("{}: mcpServers is not an object", path.display()),
127 })
128 })?;
129
130 for server in servers {
131 let mut entry = serde_json::json!({
132 "command": server.command,
133 "args": server.args,
134 });
135
136 if !server.env.is_empty() {
138 let env_obj: serde_json::Map<String, serde_json::Value> = server
139 .env
140 .iter()
141 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
142 .collect();
143 entry["env"] = serde_json::Value::Object(env_obj);
144 }
145
146 mcp_map.insert(server.name.clone(), entry);
147 }
148 }
149
150 if !hooks.is_empty() {
152 let hooks_obj = root_obj
153 .entry("hooks")
154 .or_insert_with(|| serde_json::json!({}));
155 let hooks_map = hooks_obj.as_object_mut().ok_or_else(|| {
156 MarsError::Config(crate::error::ConfigError::Invalid {
157 message: format!("{}: hooks is not an object", path.display()),
158 })
159 })?;
160
161 for hook in hooks {
162 let command = hook_command(&hook.script_path);
163 let native_event = hook.native_event.clone();
164 let event_hooks = hooks_map
165 .entry(native_event.clone())
166 .or_insert_with(|| serde_json::json!([]))
167 .as_array_mut()
168 .ok_or_else(|| {
169 MarsError::Config(crate::error::ConfigError::Invalid {
170 message: format!(
171 "{}: hooks.{native_event} is not an array",
172 path.display()
173 ),
174 })
175 })?;
176 remove_managed_hook_commands(event_hooks, &hook.name);
177 event_hooks.push(serde_json::Value::String(command));
178 }
179 }
180
181 let content = serde_json::to_string_pretty(&root).map_err(|e| {
182 MarsError::Config(crate::error::ConfigError::Invalid {
183 message: format!("failed to serialize {}: {e}", path.display()),
184 })
185 })?;
186 crate::fs::atomic_write(&path, content.as_bytes())?;
187
188 Ok(path)
189}
190
191fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
192 commands.retain(|cmd| {
193 cmd.as_str()
194 .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
195 .unwrap_or(true)
196 });
197}
198
199fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
200 let normalized = command.replace('\\', "/").replace("//", "/");
201 normalized.contains(&format!("/hooks/{hook_name}/"))
202}
203
204fn remove_opencode_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
205 let path = target_dir.join("opencode.json");
206 if !path.is_file() {
207 return Ok(());
208 }
209
210 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
211 let mut root: serde_json::Value =
212 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
213
214 let root_obj = match root.as_object_mut() {
215 Some(o) => o,
216 None => return Ok(()),
217 };
218
219 if let Some(mcp_map) = root_obj
221 .get_mut("mcpServers")
222 .and_then(|v| v.as_object_mut())
223 {
224 for key in entry_keys {
225 if let Some(name) = key.strip_prefix("mcp:") {
226 mcp_map.remove(name);
227 }
228 }
229 }
230
231 let hook_keys: Vec<(String, &str)> = entry_keys
233 .iter()
234 .filter_map(|k| {
235 let rest = k.strip_prefix("hook:")?;
236 let (event, name) = rest.split_once(':')?;
237 Some((opencode_hook_event(event)?.to_string(), name))
238 })
239 .collect();
240
241 if !hook_keys.is_empty()
242 && let Some(hooks_map) = root_obj.get_mut("hooks").and_then(|v| v.as_object_mut())
243 {
244 for (event, name) in &hook_keys {
245 if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
246 arr.retain(|cmd| {
247 let cmd_str = cmd.as_str().unwrap_or("");
248 !is_managed_hook_command_for(cmd_str, name)
250 });
251 }
252 }
253 }
254
255 let content = serde_json::to_string_pretty(&root).map_err(|e| {
256 MarsError::Config(crate::error::ConfigError::Invalid {
257 message: format!("failed to serialize {}: {e}", path.display()),
258 })
259 })?;
260 crate::fs::atomic_write(&path, content.as_bytes())?;
261 Ok(())
262}
263
264fn opencode_hook_event(event: &str) -> Option<&'static str> {
265 match event {
266 "session.start" => Some("session:start"),
267 "session.end" => Some("session:end"),
268 "tool.pre" => Some("tool:before"),
269 "tool.post" => Some("tool:after"),
270 _ => None,
271 }
272}
273
274#[cfg(test)]
279mod tests {
280 use super::*;
281 use indexmap::IndexMap;
282 use tempfile::TempDir;
283
284 fn make_mcp_entry(name: &str) -> ConfigEntry {
285 let mut env = IndexMap::new();
286 env.insert("TOKEN".to_string(), "MY_TOKEN".to_string());
287 ConfigEntry::McpServer(McpServerEntry {
288 name: name.to_string(),
289 command: "node".to_string(),
290 args: vec![],
291 env,
292 })
293 }
294
295 fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
296 ConfigEntry::Hook(HookEntry {
297 name: name.to_string(),
298 event: "tool.pre".to_string(),
299 native_event: native.to_string(),
300 script_path: format!("/hooks/{name}/run.sh"),
301 order: 0,
302 })
303 }
304
305 fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
306 ConfigEntry::Hook(HookEntry {
307 name: name.to_string(),
308 event: "tool.pre".to_string(),
309 native_event: native.to_string(),
310 script_path: script_path.to_string(),
311 order: 0,
312 })
313 }
314
315 #[test]
316 fn write_config_entries_creates_opencode_json() {
317 let tmp = TempDir::new().unwrap();
318 let adapter = OpencodeAdapter;
319 let entries = vec![make_mcp_entry("context7")];
320 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
321 assert_eq!(written.len(), 1);
322 assert!(tmp.path().join("opencode.json").exists());
323
324 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
325 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
326 assert!(json["mcpServers"]["context7"].is_object());
327 }
328
329 #[test]
330 fn write_mcp_env_as_plain_name_map() {
331 let tmp = TempDir::new().unwrap();
332 let adapter = OpencodeAdapter;
333 let entries = vec![make_mcp_entry("server")];
334 adapter.write_config_entries(&entries, tmp.path()).unwrap();
335
336 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
337 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
338 assert_eq!(json["mcpServers"]["server"]["env"]["TOKEN"], "MY_TOKEN");
340 }
341
342 #[test]
343 fn write_hooks_into_same_file() {
344 let tmp = TempDir::new().unwrap();
345 let adapter = OpencodeAdapter;
346 let entries = vec![
347 make_mcp_entry("ctx"),
348 make_hook_entry("audit", "tool:before"),
349 ];
350 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
351 assert_eq!(written.len(), 1);
353
354 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
355 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
356 assert!(json["mcpServers"]["ctx"].is_object());
357 assert!(json["hooks"]["tool:before"].is_array());
358 }
359
360 #[test]
361 fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
362 let tmp = TempDir::new().unwrap();
363 let adapter = OpencodeAdapter;
364 adapter
365 .write_config_entries(
366 &[make_hook_entry_with_path(
367 "audit",
368 "tool:before",
369 "/old/hooks/audit/run.sh",
370 )],
371 tmp.path(),
372 )
373 .unwrap();
374 adapter
375 .write_config_entries(
376 &[make_hook_entry_with_path(
377 "audit",
378 "tool:before",
379 "/new/hooks/audit/run.sh",
380 )],
381 tmp.path(),
382 )
383 .unwrap();
384
385 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
386 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
387 let hooks = json["hooks"]["tool:before"].as_array().unwrap();
388 assert_eq!(hooks.len(), 1);
389 assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
390 }
391
392 #[test]
393 fn remove_entries_removes_mcp_and_hooks() {
394 let tmp = TempDir::new().unwrap();
395 let adapter = OpencodeAdapter;
396 let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
397 adapter.write_config_entries(&entries, tmp.path()).unwrap();
398
399 adapter
400 .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
401 .unwrap();
402
403 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
404 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
405 assert!(json["mcpServers"]["to-remove"].is_null());
406 assert!(json["mcpServers"]["to-keep"].is_object());
407 }
408}