1use std::path::{Path, PathBuf};
10
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14
15use super::{ConfigEntry, HookFragmentMode, McpServerEntry, TargetAdapter};
16
17#[derive(Debug)]
18pub struct OpencodeAdapter;
19
20impl TargetAdapter for OpencodeAdapter {
21 fn name(&self) -> &str {
22 ".opencode"
23 }
24
25 fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
26 Some(HookFragmentMode::File)
27 }
28
29 fn hook_file_dest_path(&self, name: &str) -> Option<PathBuf> {
30 Some(PathBuf::from(format!("plugins/mars-{name}.ts")))
31 }
32
33 fn skill_variant_key(&self) -> Option<&str> {
34 Some("opencode")
35 }
36
37 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
38 match kind {
39 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
40 _ => None,
41 }
42 }
43
44 fn write_config_entries(
45 &self,
46 write: crate::surface_ownership::retention::ConfigWrite<'_>,
47 project_root: &Path,
48 ) -> Result<Vec<PathBuf>, MarsError> {
49 let (target_dir, entries) = write.into_parts(project_root);
50 let mcp_servers: Vec<&McpServerEntry> = entries
51 .iter()
52 .filter_map(|e| {
53 if let ConfigEntry::McpServer(s) = e {
54 Some(s)
55 } else {
56 None
57 }
58 })
59 .collect();
60
61 if mcp_servers.is_empty() {
62 return Ok(Vec::new());
63 }
64
65 let path = (write_opencode_config)(&target_dir, &mcp_servers)?;
66 Ok(vec![path])
67 }
68
69 fn mcp_config_file_names(&self) -> &'static [&'static str] {
70 &["opencode.json"]
71 }
72
73 fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
74 &["opencode.json"]
75 }
76
77 fn remove_owned_hook_entries(
78 &self,
79 operation: crate::surface_ownership::retention::RemovalOperation<'_>,
80 project_root: &Path,
81 _diag: &mut crate::diagnostic::DiagnosticCollector,
82 ) -> crate::surface_ownership::retention::RemovalReport {
83 let (target_dir, removal) = operation.into_parts(project_root);
84 let keys: Vec<String> = removal.prior_records.keys().cloned().collect();
85 match remove_opencode_entries(&keys, &target_dir) {
86 Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
87 Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
88 error,
89 removal.prior_records.clone(),
90 ),
91 }
92 }
93
94 fn remove_config_entries(
95 &self,
96 operation: crate::surface_ownership::retention::RemovalOperation<'_>,
97 project_root: &Path,
98 ) -> crate::surface_ownership::retention::RemovalReport {
99 let (target_dir, removal) = operation.into_parts(project_root);
100 match remove_opencode_entries(&removal.keys_to_remove, &target_dir) {
101 Ok(()) => crate::surface_ownership::retention::RemovalReport::confirmed(),
102 Err(error) => crate::surface_ownership::retention::RemovalReport::failed(
103 error,
104 removal.prior_records.clone(),
105 ),
106 }
107 }
108}
109
110fn write_opencode_config(
126 target_dir: &Path,
127 servers: &[&McpServerEntry],
128) -> Result<PathBuf, MarsError> {
129 let path = target_dir.join("opencode.json");
130
131 let mut root: serde_json::Value = if path.is_file() {
132 super::parse_json_file(&path)?
133 } else {
134 serde_json::json!({})
135 };
136
137 let root_obj = root.as_object_mut().ok_or_else(|| {
138 MarsError::Config(crate::error::ConfigError::Invalid {
139 message: format!("{} is not a JSON object", path.display()),
140 })
141 })?;
142
143 if !servers.is_empty() {
145 let mcp_obj = root_obj
146 .entry("mcpServers")
147 .or_insert_with(|| serde_json::json!({}));
148 let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
149 MarsError::Config(crate::error::ConfigError::Invalid {
150 message: format!("{}: mcpServers is not an object", path.display()),
151 })
152 })?;
153
154 for server in servers {
155 let mut entry = serde_json::json!({
156 "command": server.command,
157 "args": server.args,
158 });
159
160 if !server.env.is_empty() {
162 let env_obj: serde_json::Map<String, serde_json::Value> = server
163 .env
164 .iter()
165 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
166 .collect();
167 entry["env"] = serde_json::Value::Object(env_obj);
168 }
169
170 mcp_map.insert(server.name.clone(), entry);
171 }
172 }
173
174 let content = serde_json::to_string_pretty(&root).map_err(|e| {
175 MarsError::Config(crate::error::ConfigError::Invalid {
176 message: format!("failed to serialize {}: {e}", path.display()),
177 })
178 })?;
179 crate::fs::atomic_write(&path, content.as_bytes())?;
180
181 Ok(path)
182}
183
184fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
185 commands.retain(|cmd| {
186 cmd.as_str()
187 .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
188 .unwrap_or(true)
189 });
190}
191
192fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
193 let normalized = command.replace('\\', "/").replace("//", "/");
194 normalized.contains(&format!("/hooks/{hook_name}/"))
195}
196
197fn remove_opencode_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
198 let path = target_dir.join("opencode.json");
199 if !path.is_file() {
200 return Ok(());
201 }
202
203 let mut root = super::parse_json_file(&path)?;
204
205 let root_obj = match root.as_object_mut() {
206 Some(o) => o,
207 None => return Ok(()),
208 };
209
210 if let Some(mcp_map) = root_obj
212 .get_mut("mcpServers")
213 .and_then(|v| v.as_object_mut())
214 {
215 for key in entry_keys {
216 if let Some(name) = key.strip_prefix("mcp:") {
217 mcp_map.remove(name);
218 }
219 }
220 }
221
222 let hook_names: Vec<&str> = entry_keys
225 .iter()
226 .filter_map(|k| {
227 let rest = k.strip_prefix("hook:")?;
228 let (_, name) = rest.split_once(':')?;
229 Some(name)
230 })
231 .collect();
232
233 if !hook_names.is_empty()
234 && let Some(hooks_map) = root_obj.get_mut("hooks").and_then(|v| v.as_object_mut())
235 {
236 for commands in hooks_map
237 .values_mut()
238 .filter_map(|value| value.as_array_mut())
239 {
240 for name in &hook_names {
241 remove_managed_hook_commands(commands, name);
242 }
243 }
244 hooks_map.retain(|_, value| !value.as_array().is_some_and(Vec::is_empty));
245 if hooks_map.is_empty() {
246 root_obj.remove("hooks");
247 }
248 }
249
250 let content = serde_json::to_string_pretty(&root).map_err(|e| {
251 MarsError::Config(crate::error::ConfigError::Invalid {
252 message: format!("failed to serialize {}: {e}", path.display()),
253 })
254 })?;
255 crate::fs::atomic_write(&path, content.as_bytes())?;
256 Ok(())
257}
258
259#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::surface_ownership::retention::WritePermit;
267
268 fn write_permit(entries: &[ConfigEntry]) -> WritePermit<'static> {
269 WritePermit::for_test("", entries[0].surface())
270 }
271 use indexmap::IndexMap;
272 use tempfile::TempDir;
273
274 fn make_mcp_entry(name: &str) -> ConfigEntry {
275 let mut env = IndexMap::new();
276 env.insert("TOKEN".to_string(), "MY_TOKEN".to_string());
277 ConfigEntry::McpServer(McpServerEntry {
278 name: name.to_string(),
279 command: "node".to_string(),
280 args: vec![],
281 env,
282 })
283 }
284
285 #[test]
286 fn write_config_entries_creates_opencode_json() {
287 let tmp = TempDir::new().unwrap();
288 let adapter = OpencodeAdapter;
289 let entries = vec![make_mcp_entry("context7")];
290 let written = adapter
291 .write_config_entries(
292 write_permit(&entries)
293 .bind_config_entries(entries.clone())
294 .unwrap(),
295 tmp.path(),
296 )
297 .unwrap();
298 assert_eq!(written.len(), 1);
299 assert!(tmp.path().join("opencode.json").exists());
300
301 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
302 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
303 assert!(json["mcpServers"]["context7"].is_object());
304 }
305
306 #[test]
307 fn write_mcp_env_as_plain_name_map() {
308 let tmp = TempDir::new().unwrap();
309 let adapter = OpencodeAdapter;
310 let entries = vec![make_mcp_entry("server")];
311 adapter
312 .write_config_entries(
313 write_permit(&entries)
314 .bind_config_entries(entries.clone())
315 .unwrap(),
316 tmp.path(),
317 )
318 .unwrap();
319
320 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
321 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
322 assert_eq!(json["mcpServers"]["server"]["env"]["TOKEN"], "MY_TOKEN");
324 }
325
326 #[test]
327 fn remove_entries_removes_mcp() {
328 let tmp = TempDir::new().unwrap();
329 let adapter = OpencodeAdapter;
330 let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
331 adapter
332 .write_config_entries(
333 write_permit(&entries)
334 .bind_config_entries(entries.clone())
335 .unwrap(),
336 tmp.path(),
337 )
338 .unwrap();
339
340 remove_opencode_entries(&["mcp:to-remove".to_string()], tmp.path()).unwrap();
341
342 let raw = std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap();
343 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
344 assert!(json["mcpServers"]["to-remove"].is_null());
345 assert!(json["mcpServers"]["to-keep"].is_object());
346 }
347
348 #[test]
349 fn removal_only_sweep_cleans_fabricated_managed_hooks() {
350 let tmp = TempDir::new().unwrap();
351 std::fs::write(
352 tmp.path().join("opencode.json"),
353 serde_json::to_vec_pretty(&serde_json::json!({
354 "hooks": {
355 "tool:before": ["bash '/cache/hooks/audit/run.sh'"],
356 "session:start": ["echo user-owned"]
357 }
358 }))
359 .unwrap(),
360 )
361 .unwrap();
362
363 remove_opencode_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
364
365 let json: serde_json::Value = serde_json::from_str(
366 &std::fs::read_to_string(tmp.path().join("opencode.json")).unwrap(),
367 )
368 .unwrap();
369 assert!(json["hooks"]["tool:before"].is_null());
370 assert_eq!(json["hooks"]["session:start"][0], "echo user-owned");
371 }
372}