Skip to main content

wire/adapters/
harness.rs

1//! Harness adapters — per-host MCP-config registration contract.
2//!
3//! Each adapter is a small static record declaring (1) the probable
4//! on-disk config paths the host reads at startup, and (2) which JSON
5//! shape upsert function to merge wire's MCP entry with.
6//!
7//! ## The contract
8//!
9//! ```ignore
10//! pub struct HarnessAdapter {
11//!     pub name: &'static str,
12//!     pub paths_fn: fn() -> Vec<PathBuf>,
13//!     pub upsert_fn: fn(&Path, &str, &Value) -> Result<bool>,
14//! }
15//! ```
16//!
17//! - `name` — operator-facing label printed by `wire setup`.
18//! - `paths_fn` — returns every probable config path on the running
19//!   platform. May be empty (the host isn't installed on this OS).
20//!   Honor `$XDG_CONFIG_HOME` + per-platform conventions inside.
21//! - `upsert_fn` — atomically merges the provided MCP entry into the
22//!   host's config file at `path`. Returns `Ok(true)` if the file was
23//!   changed, `Ok(false)` if the exact entry was already present
24//!   (idempotent), `Err` on unrecoverable I/O / parse failure.
25//!
26//! ## Adding a new harness — three-step recipe
27//!
28//! 1. Write a `paths_fn` returning the host's probable config paths.
29//! 2. Pick (or write) an `upsert_fn`. Three pre-built shapes ship:
30//!    - [`upsert_standard`] for `{"mcpServers": {"<name>": {...}}}` —
31//!      Claude Code, Cursor, Claude Desktop, GitHub Copilot CLI, Pi,
32//!      project-local `.mcp.json`.
33//!    - [`upsert_vscode`] for `{"mcp": {"servers": {"<name>": {...}}}}` —
34//!      VS Code (Copilot), VS Code Insiders, `.vscode/settings.json`.
35//!    - [`upsert_opencode`] for `{"mcp": {"<name>": {"type":"local",
36//!      "command":["<bin>",...args], "enabled":true}}}` — OpenCode.
37//! 3. Add a [`HarnessAdapter`] entry to [`HARNESS_ADAPTERS`].
38//! 4. Add a test in the local `tests` module covering the new path
39//!    detection + shape.
40//!
41//! Walkthrough: `docs/adapters/HARNESS.md`.
42
43use std::path::{Path, PathBuf};
44
45use anyhow::{Context, Result};
46use serde_json::{Value, json};
47
48/// One harness this build can register the wire MCP server with.
49pub struct HarnessAdapter {
50    /// Operator-facing label printed by `wire setup`.
51    pub name: &'static str,
52    /// Returns every probable config path on the running platform.
53    /// May be empty when the host isn't shipped for the current OS.
54    pub paths_fn: fn() -> Vec<PathBuf>,
55    /// Atomic merge of `(server_name, entry)` into the host's
56    /// config file at `path`. Returns `Ok(true)` on change,
57    /// `Ok(false)` on no-op (entry already exact).
58    pub upsert_fn: fn(&Path, &str, &Value) -> Result<bool>,
59    /// Remove the `(server_name)` entry from the host's config at
60    /// `path`. Returns `Ok(true)` on change, `Ok(false)` if the file
61    /// is absent or the entry wasn't present (idempotent). MUST NOT
62    /// create a missing file.
63    pub remove_fn: fn(&Path, &str) -> Result<bool>,
64}
65
66/// The registry. Walked by `cli::cmd_setup` in order — first match
67/// wins for display order, every match upserts. Adding a harness is
68/// one entry here + one test below.
69pub const HARNESS_ADAPTERS: &[HarnessAdapter] = &[
70    HarnessAdapter {
71        name: "Claude Code",
72        paths_fn: claude_code_paths,
73        upsert_fn: upsert_standard,
74        remove_fn: remove_standard,
75    },
76    HarnessAdapter {
77        name: "Claude Code (alt)",
78        paths_fn: claude_code_alt_paths,
79        upsert_fn: upsert_standard,
80        remove_fn: remove_standard,
81    },
82    HarnessAdapter {
83        name: "Claude Desktop",
84        paths_fn: claude_desktop_paths,
85        upsert_fn: upsert_standard,
86        remove_fn: remove_standard,
87    },
88    HarnessAdapter {
89        name: "Cursor",
90        paths_fn: cursor_paths,
91        upsert_fn: upsert_standard,
92        remove_fn: remove_standard,
93    },
94    HarnessAdapter {
95        name: "VS Code (GitHub Copilot)",
96        paths_fn: vscode_paths,
97        upsert_fn: upsert_vscode,
98        remove_fn: remove_vscode,
99    },
100    HarnessAdapter {
101        name: "VS Code Insiders",
102        paths_fn: vscode_insiders_paths,
103        upsert_fn: upsert_vscode,
104        remove_fn: remove_vscode,
105    },
106    HarnessAdapter {
107        name: "GitHub Copilot CLI",
108        paths_fn: copilot_cli_paths,
109        upsert_fn: upsert_standard,
110        remove_fn: remove_standard,
111    },
112    HarnessAdapter {
113        name: "Pi",
114        paths_fn: pi_paths,
115        upsert_fn: upsert_standard,
116        remove_fn: remove_standard,
117    },
118    HarnessAdapter {
119        name: "OpenCode",
120        paths_fn: opencode_paths,
121        upsert_fn: upsert_opencode,
122        remove_fn: remove_opencode,
123    },
124    HarnessAdapter {
125        name: "VS Code (workspace)",
126        paths_fn: vscode_workspace_paths,
127        upsert_fn: upsert_vscode,
128        remove_fn: remove_vscode,
129    },
130    HarnessAdapter {
131        name: "project-local (.mcp.json)",
132        paths_fn: project_mcp_paths,
133        upsert_fn: upsert_standard,
134        remove_fn: remove_standard,
135    },
136    HarnessAdapter {
137        name: "OpenCode (project-local)",
138        paths_fn: opencode_project_paths,
139        upsert_fn: upsert_opencode,
140        remove_fn: remove_opencode,
141    },
142];
143
144// ---------- per-host path resolvers ----------
145
146fn claude_code_paths() -> Vec<PathBuf> {
147    dirs::home_dir()
148        .into_iter()
149        .map(|h| h.join(".claude.json"))
150        .collect()
151}
152
153fn claude_code_alt_paths() -> Vec<PathBuf> {
154    dirs::home_dir()
155        .into_iter()
156        .map(|h| h.join(".config/claude/mcp.json"))
157        .collect()
158}
159
160#[cfg(target_os = "macos")]
161fn claude_desktop_paths() -> Vec<PathBuf> {
162    dirs::home_dir()
163        .into_iter()
164        .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
165        .collect()
166}
167
168#[cfg(target_os = "windows")]
169fn claude_desktop_paths() -> Vec<PathBuf> {
170    std::env::var("APPDATA")
171        .ok()
172        .map(|appdata| PathBuf::from(appdata).join("Claude/claude_desktop_config.json"))
173        .into_iter()
174        .collect()
175}
176
177#[cfg(not(any(target_os = "macos", target_os = "windows")))]
178fn claude_desktop_paths() -> Vec<PathBuf> {
179    // Claude Desktop doesn't ship on linux/BSD as of v0.14.x.
180    Vec::new()
181}
182
183fn cursor_paths() -> Vec<PathBuf> {
184    dirs::home_dir()
185        .into_iter()
186        .map(|h| h.join(".cursor/mcp.json"))
187        .collect()
188}
189
190#[cfg(target_os = "macos")]
191fn vscode_paths() -> Vec<PathBuf> {
192    dirs::home_dir()
193        .into_iter()
194        .map(|h| h.join("Library/Application Support/Code/User/settings.json"))
195        .collect()
196}
197
198#[cfg(target_os = "linux")]
199fn vscode_paths() -> Vec<PathBuf> {
200    dirs::home_dir()
201        .into_iter()
202        .map(|h| h.join(".config/Code/User/settings.json"))
203        .collect()
204}
205
206#[cfg(target_os = "windows")]
207fn vscode_paths() -> Vec<PathBuf> {
208    std::env::var("APPDATA")
209        .ok()
210        .map(|appdata| PathBuf::from(appdata).join("Code/User/settings.json"))
211        .into_iter()
212        .collect()
213}
214
215#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
216fn vscode_paths() -> Vec<PathBuf> {
217    Vec::new()
218}
219
220#[cfg(target_os = "macos")]
221fn vscode_insiders_paths() -> Vec<PathBuf> {
222    dirs::home_dir()
223        .into_iter()
224        .map(|h| h.join("Library/Application Support/Code - Insiders/User/settings.json"))
225        .collect()
226}
227
228#[cfg(target_os = "linux")]
229fn vscode_insiders_paths() -> Vec<PathBuf> {
230    dirs::home_dir()
231        .into_iter()
232        .map(|h| h.join(".config/Code - Insiders/User/settings.json"))
233        .collect()
234}
235
236#[cfg(target_os = "windows")]
237fn vscode_insiders_paths() -> Vec<PathBuf> {
238    std::env::var("APPDATA")
239        .ok()
240        .map(|appdata| PathBuf::from(appdata).join("Code - Insiders/User/settings.json"))
241        .into_iter()
242        .collect()
243}
244
245#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
246fn vscode_insiders_paths() -> Vec<PathBuf> {
247    Vec::new()
248}
249
250fn copilot_cli_paths() -> Vec<PathBuf> {
251    let mut out = Vec::new();
252    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
253        out.push(PathBuf::from(xdg).join("copilot/mcp-config.json"));
254    }
255    if let Some(home) = dirs::home_dir() {
256        out.push(home.join(".copilot/mcp-config.json"));
257    }
258    out
259}
260
261fn pi_paths() -> Vec<PathBuf> {
262    let mut out = Vec::new();
263    if let Ok(pi_dir) = std::env::var("PI_CODING_AGENT_DIR") {
264        out.push(PathBuf::from(pi_dir).join("mcp.json"));
265    }
266    if let Some(home) = dirs::home_dir() {
267        out.push(home.join(".pi/agent/mcp.json"));
268    }
269    out
270}
271
272fn opencode_paths() -> Vec<PathBuf> {
273    let mut out = Vec::new();
274    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
275        out.push(PathBuf::from(xdg).join("opencode/opencode.json"));
276    }
277    if let Some(home) = dirs::home_dir() {
278        out.push(home.join(".config/opencode/opencode.json"));
279    }
280    out
281}
282
283fn vscode_workspace_paths() -> Vec<PathBuf> {
284    vec![PathBuf::from(".vscode/settings.json")]
285}
286
287fn project_mcp_paths() -> Vec<PathBuf> {
288    vec![PathBuf::from(".mcp.json")]
289}
290
291fn opencode_project_paths() -> Vec<PathBuf> {
292    vec![PathBuf::from("opencode.json")]
293}
294
295// ---------- per-shape upsert functions ----------
296
297/// Shared loader: read existing JSON file (or default to empty),
298/// guard against non-JSON / non-object roots. Used by every upsert
299/// shape so all three behave identically on parse / IO failure.
300fn read_config_value(path: &Path) -> Result<Value> {
301    if !path.exists() {
302        return Ok(json!({}));
303    }
304    let body = std::fs::read_to_string(path).context("reading config")?;
305    if body.trim().is_empty() {
306        return Ok(json!({}));
307    }
308    let parsed: Value = serde_json::from_str(&body).with_context(|| {
309        format!(
310            "{} is not strict JSON (comments / trailing commas?); \
311             add the wire MCP entry manually to avoid overwriting it",
312            path.display()
313        )
314    })?;
315    if parsed.is_object() {
316        Ok(parsed)
317    } else {
318        Ok(json!({}))
319    }
320}
321
322/// Shared writer: atomic-ish write of the merged config. Creates the
323/// parent dir on demand. All three upsert shapes share this so file
324/// permissions + newline conventions stay consistent.
325fn write_config_value(path: &Path, cfg: &Value) -> Result<()> {
326    if let Some(parent) = path.parent()
327        && !parent.as_os_str().is_empty()
328    {
329        std::fs::create_dir_all(parent).context("creating parent dir")?;
330    }
331    let out = serde_json::to_string_pretty(cfg)? + "\n";
332    std::fs::write(path, out).context("writing config")?;
333    Ok(())
334}
335
336/// Standard MCP shape: `{"mcpServers": {"<name>": {"command":
337/// "<bin>", "args": [...]}}}`. Used by Claude Code, Cursor, Claude
338/// Desktop, GitHub Copilot CLI, Pi, and the project-local
339/// `.mcp.json` convention.
340pub fn upsert_standard(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
341    let mut cfg = read_config_value(path)?;
342    let root = cfg.as_object_mut().unwrap();
343    let servers = root
344        .entry("mcpServers".to_string())
345        .or_insert_with(|| json!({}));
346    if !servers.is_object() {
347        *servers = json!({});
348    }
349    let map = servers.as_object_mut().unwrap();
350    if map.get(server_name) == Some(entry) {
351        return Ok(false);
352    }
353    map.insert(server_name.to_string(), entry.clone());
354    write_config_value(path, &cfg)?;
355    Ok(true)
356}
357
358/// VS Code shape: `{"mcp": {"servers": {"<name>": {...}}}}`. Used by
359/// VS Code (User settings.json), VS Code Insiders, and the
360/// `.vscode/settings.json` workspace convention.
361pub fn upsert_vscode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
362    let mut cfg = read_config_value(path)?;
363    let root = cfg.as_object_mut().unwrap();
364    let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
365    if !mcp.is_object() {
366        *mcp = json!({});
367    }
368    let mcp_obj = mcp.as_object_mut().unwrap();
369    let servers = mcp_obj
370        .entry("servers".to_string())
371        .or_insert_with(|| json!({}));
372    if !servers.is_object() {
373        *servers = json!({});
374    }
375    let map = servers.as_object_mut().unwrap();
376    if map.get(server_name) == Some(entry) {
377        return Ok(false);
378    }
379    map.insert(server_name.to_string(), entry.clone());
380    write_config_value(path, &cfg)?;
381    Ok(true)
382}
383
384/// OpenCode shape: `{"mcp": {"<name>": {"type": "local", "command":
385/// ["<bin>", ...args], "enabled": true}}}`. Three differences vs.
386/// standard: top-level `mcp` (not `mcpServers`); no `servers`
387/// intermediate; `command` is a single combined array, not the
388/// `{command, args}` pair.
389pub fn upsert_opencode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
390    let mut cfg = read_config_value(path)?;
391    let root = cfg.as_object_mut().unwrap();
392    // Map standard {command, args} → OpenCode combined command array.
393    let cmd_str = entry
394        .get("command")
395        .and_then(Value::as_str)
396        .unwrap_or("wire");
397    let args_arr: Vec<Value> = entry
398        .get("args")
399        .and_then(Value::as_array)
400        .cloned()
401        .unwrap_or_default();
402    let mut combined: Vec<Value> = vec![Value::String(cmd_str.to_string())];
403    combined.extend(args_arr);
404    let opencode_entry = json!({
405        "type": "local",
406        "command": combined,
407        "enabled": true,
408    });
409    let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
410    if !mcp.is_object() {
411        *mcp = json!({});
412    }
413    let map = mcp.as_object_mut().unwrap();
414    if map.get(server_name) == Some(&opencode_entry) {
415        return Ok(false);
416    }
417    map.insert(server_name.to_string(), opencode_entry);
418    write_config_value(path, &cfg)?;
419    Ok(true)
420}
421
422/// Remove `server_name` from `{"mcpServers": {...}}`. No-op if the
423/// file is absent or the key is missing.
424pub fn remove_standard(path: &Path, server_name: &str) -> Result<bool> {
425    if !path.exists() {
426        return Ok(false);
427    }
428    let mut cfg = read_config_value(path)?;
429    let changed = cfg
430        .get_mut("mcpServers")
431        .and_then(Value::as_object_mut)
432        .map(|m| m.remove(server_name).is_some())
433        .unwrap_or(false);
434    if changed {
435        write_config_value(path, &cfg)?;
436    }
437    Ok(changed)
438}
439
440/// Remove `server_name` from `{"mcp": {"servers": {...}}}` (VS Code).
441pub fn remove_vscode(path: &Path, server_name: &str) -> Result<bool> {
442    if !path.exists() {
443        return Ok(false);
444    }
445    let mut cfg = read_config_value(path)?;
446    let changed = cfg
447        .get_mut("mcp")
448        .and_then(Value::as_object_mut)
449        .and_then(|m| m.get_mut("servers"))
450        .and_then(Value::as_object_mut)
451        .map(|m| m.remove(server_name).is_some())
452        .unwrap_or(false);
453    if changed {
454        write_config_value(path, &cfg)?;
455    }
456    Ok(changed)
457}
458
459/// Remove `server_name` from `{"mcp": {"<name>": {...}}}` (OpenCode).
460pub fn remove_opencode(path: &Path, server_name: &str) -> Result<bool> {
461    if !path.exists() {
462        return Ok(false);
463    }
464    let mut cfg = read_config_value(path)?;
465    let changed = cfg
466        .get_mut("mcp")
467        .and_then(Value::as_object_mut)
468        .map(|m| m.remove(server_name).is_some())
469        .unwrap_or(false);
470    if changed {
471        write_config_value(path, &cfg)?;
472    }
473    Ok(changed)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn standard_entry() -> Value {
481        json!({"command": "wire", "args": ["mcp"]})
482    }
483
484    #[test]
485    fn registry_includes_every_v0_14_2_published_harness() {
486        // The published-v0.14.2 docs (PI.md + OPENCODE.md +
487        // README.md integrations list) commit to these adapters
488        // existing. Adding a harness is fine; removing one needs a
489        // deliberate doc + migration story.
490        let names: Vec<&str> = HARNESS_ADAPTERS.iter().map(|a| a.name).collect();
491        for required in [
492            "Claude Code",
493            "Cursor",
494            "VS Code (GitHub Copilot)",
495            "GitHub Copilot CLI",
496            "Pi",
497            "OpenCode",
498        ] {
499            assert!(
500                names.contains(&required),
501                "registry missing required adapter `{required}`"
502            );
503        }
504    }
505
506    #[test]
507    fn upsert_standard_writes_mcpservers_shape_and_is_idempotent() {
508        let dir = tempfile::tempdir().unwrap();
509        let path = dir.path().join("config.json");
510        let entry = standard_entry();
511        assert!(upsert_standard(&path, "wire", &entry).unwrap());
512        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
513        assert_eq!(v["mcpServers"]["wire"]["command"], "wire");
514        assert_eq!(v["mcpServers"]["wire"]["args"][0], "mcp");
515        assert!(
516            !upsert_standard(&path, "wire", &entry).unwrap(),
517            "idempotent"
518        );
519    }
520
521    #[test]
522    fn upsert_vscode_writes_mcp_servers_intermediate_and_is_idempotent() {
523        let dir = tempfile::tempdir().unwrap();
524        let path = dir.path().join("settings.json");
525        let entry = standard_entry();
526        assert!(upsert_vscode(&path, "wire", &entry).unwrap());
527        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
528        assert_eq!(v["mcp"]["servers"]["wire"]["command"], "wire");
529        assert!(v.get("mcpServers").is_none());
530        assert!(!upsert_vscode(&path, "wire", &entry).unwrap(), "idempotent");
531    }
532
533    #[test]
534    fn upsert_opencode_writes_combined_command_and_enabled_flag() {
535        let dir = tempfile::tempdir().unwrap();
536        let path = dir.path().join("opencode.json");
537        let entry = standard_entry();
538        assert!(upsert_opencode(&path, "wire", &entry).unwrap());
539        let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
540        let wire = &v["mcp"]["wire"];
541        assert_eq!(wire["type"], "local");
542        assert_eq!(wire["enabled"], true);
543        assert_eq!(wire["command"][0], "wire");
544        assert_eq!(wire["command"][1], "mcp");
545        assert!(v.get("mcpServers").is_none());
546        assert!(
547            !upsert_opencode(&path, "wire", &entry).unwrap(),
548            "idempotent"
549        );
550    }
551
552    #[test]
553    fn upsert_preserves_sibling_keys_across_all_three_shapes() {
554        // Author-friction guarantee: a host's existing config keys
555        // survive a `wire setup --apply` run.
556        let dir = tempfile::tempdir().unwrap();
557        let entry = standard_entry();
558        for sub in ["standard.json", "vscode.json", "opencode.json"] {
559            let path = dir.path().join(sub);
560            std::fs::write(
561                &path,
562                r#"{"theme":"dark","providers":{"openai":{"apiKey":"sk-test"}}}"#,
563            )
564            .unwrap();
565            // Pick the matching upsert by filename.
566            let upsert: fn(&Path, &str, &Value) -> Result<bool> = if sub == "standard.json" {
567                upsert_standard
568            } else if sub == "vscode.json" {
569                upsert_vscode
570            } else {
571                upsert_opencode
572            };
573            assert!(upsert(&path, "wire", &entry).unwrap());
574            let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
575            assert_eq!(v["theme"], "dark");
576            assert_eq!(v["providers"]["openai"]["apiKey"], "sk-test");
577        }
578    }
579
580    #[test]
581    fn upsert_refuses_to_overwrite_unparseable_json() {
582        // JSONC files (VS Code settings.json with comments / trailing
583        // commas) are common. We must NOT replace them with our own
584        // `{...wire only...}` content. Instead return Err so the
585        // caller surfaces the target under "Skipped" and the
586        // operator edits the file by hand.
587        let dir = tempfile::tempdir().unwrap();
588        let path = dir.path().join("settings.json");
589        std::fs::write(&path, "// theme override\n{\"theme\":\"dark\",}").unwrap();
590        let entry = standard_entry();
591        let err = upsert_vscode(&path, "wire", &entry).unwrap_err();
592        // The Err message must mention the JSON parse problem so the
593        // operator knows why we didn't write.
594        let msg = format!("{err:#}");
595        assert!(
596            msg.contains("not strict JSON"),
597            "expected 'not strict JSON' diagnostic, got: {msg}"
598        );
599        // File must be unchanged.
600        let body = std::fs::read_to_string(&path).unwrap();
601        assert!(body.starts_with("// theme override"));
602    }
603
604    #[test]
605    fn remove_standard_drops_only_wire_and_preserves_siblings() {
606        let tmp = tempfile::tempdir().unwrap();
607        let p = tmp.path().join("mcp.json");
608        std::fs::write(
609            &p,
610            r#"{"mcpServers":{"wire":{"command":"wire","args":["mcp"]},"other":{"command":"x"}}}"#,
611        )
612        .unwrap();
613        assert!(
614            remove_standard(&p, "wire").unwrap(),
615            "should report changed"
616        );
617        let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
618        assert!(v["mcpServers"].get("wire").is_none(), "wire removed");
619        assert!(v["mcpServers"].get("other").is_some(), "sibling preserved");
620        // idempotent: second remove is a no-op
621        assert!(!remove_standard(&p, "wire").unwrap());
622    }
623
624    #[test]
625    fn remove_vscode_drops_wire_under_mcp_servers() {
626        let tmp = tempfile::tempdir().unwrap();
627        let p = tmp.path().join("settings.json");
628        std::fs::write(
629            &p,
630            r#"{"mcp":{"servers":{"wire":{"command":"wire"},"keep":{}}},"editor.fontSize":12}"#,
631        )
632        .unwrap();
633        assert!(remove_vscode(&p, "wire").unwrap());
634        let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
635        assert!(v["mcp"]["servers"].get("wire").is_none());
636        assert!(v["mcp"]["servers"].get("keep").is_some());
637        assert_eq!(v["editor.fontSize"], 12, "unrelated keys preserved");
638    }
639
640    #[test]
641    fn remove_opencode_drops_wire_under_mcp() {
642        let tmp = tempfile::tempdir().unwrap();
643        let p = tmp.path().join("opencode.json");
644        std::fs::write(&p, r#"{"mcp":{"wire":{"type":"local","command":["wire","mcp"],"enabled":true},"keep":{}}}"#).unwrap();
645        assert!(remove_opencode(&p, "wire").unwrap());
646        let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
647        assert!(v["mcp"].get("wire").is_none());
648        assert!(v["mcp"].get("keep").is_some());
649    }
650
651    #[test]
652    fn remove_is_noop_when_file_absent_or_key_missing() {
653        let tmp = tempfile::tempdir().unwrap();
654        let absent = tmp.path().join("nope.json");
655        assert!(
656            !remove_standard(&absent, "wire").unwrap(),
657            "absent file → no-op, no create"
658        );
659        assert!(!absent.exists(), "must not create the file");
660        let p = tmp.path().join("c.json");
661        std::fs::write(&p, r#"{"mcpServers":{"other":{}}}"#).unwrap();
662        assert!(!remove_standard(&p, "wire").unwrap(), "missing key → no-op");
663    }
664
665    #[test]
666    fn every_adapter_has_a_remove_fn() {
667        for a in HARNESS_ADAPTERS {
668            // remove_fn is a real fn pointer; calling it on an absent path is a safe no-op.
669            let tmp = tempfile::tempdir().unwrap();
670            let p = tmp.path().join("absent.json");
671            assert!(
672                !(a.remove_fn)(&p, "wire").unwrap(),
673                "{} remove_fn on absent → false",
674                a.name
675            );
676        }
677    }
678}