Skip to main content

sqz_engine/
zed_integration.rs

1//! Zed editor (Zed Agent) integration.
2//!
3//! Requested in issue #38. Zed has no PreToolUse-style hook that could
4//! rewrite terminal commands, so the integration has two surfaces,
5//! mirroring the Codex integration's shape:
6//!
7//! 1. **Project instructions** — Zed's Agent reads the first matching
8//!    file of `.rules`, `.cursorrules`, `.windsurfrules`, `.clinerules`,
9//!    `.github/copilot-instructions.md`, `AGENT.md`, `AGENTS.md`,
10//!    `CLAUDE.md`, `GEMINI.md` (per zed.dev/docs/ai/instructions). sqz
11//!    appends a sentinel-bracketed guidance block to `.rules` when that
12//!    file exists (it shadows everything else), otherwise to `AGENTS.md`.
13//!
14//! 2. **MCP registration** — Zed reads MCP servers from the
15//!    `context_servers` map in its `settings.json`
16//!    (`~/.config/zed/settings.json` on macOS/Linux, `%APPDATA%\Zed\`
17//!    on Windows). Registering `sqz-mcp` gives the Zed Agent the
18//!    `compress`/`passthrough`/`expand` tools plus the lossless
19//!    `sqz_read_file`/`sqz_grep`/`sqz_list_dir` file tools — which is
20//!    the part that matters for local models with small context windows.
21//!
22//! Zed's `settings.json` is JSONC (Zed's own template ships comments).
23//! Lesson learned on issue #6 (OpenCode): never round-trip a user's
24//! commented config through serde_json — it silently strips their
25//! comments. When the settings file doesn't parse as strict JSON we
26//! REFUSE to edit it and return [`ZedMcpInstall::SkippedJsonc`] so the
27//! CLI can print copy-paste instructions instead.
28
29use std::path::{Path, PathBuf};
30
31use crate::error::{Result, SqzError};
32
33// ── Paths ─────────────────────────────────────────────────────────────────
34
35/// Path of Zed's user settings file.
36///
37/// * macOS / Linux: `$XDG_CONFIG_HOME/zed/settings.json`, falling back
38///   to `~/.config/zed/settings.json`. Zed uses the XDG location on
39///   macOS too (unlike most Mac apps) — see zed.dev/docs/ai/instructions
40///   which documents `~/.config/zed/AGENTS.md` for macOS.
41/// * Windows: `%APPDATA%\Zed\settings.json`.
42pub fn zed_settings_path() -> PathBuf {
43    zed_config_dir().join("settings.json")
44}
45
46fn zed_config_dir() -> PathBuf {
47    #[cfg(windows)]
48    {
49        if let Ok(appdata) = std::env::var("APPDATA") {
50            return PathBuf::from(appdata).join("Zed");
51        }
52    }
53    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
54        if !xdg.trim().is_empty() {
55            return PathBuf::from(xdg).join("zed");
56        }
57    }
58    let home = std::env::var("HOME")
59        .or_else(|_| std::env::var("USERPROFILE"))
60        .map(PathBuf::from)
61        .unwrap_or_else(|_| PathBuf::from("."));
62    home.join(".config").join("zed")
63}
64
65/// The project file Zed will actually read sqz's guidance from.
66///
67/// Zed uses the FIRST match in its instruction-file list, and `.rules`
68/// outranks `AGENTS.md`. So when a `.rules` file exists we must write
69/// there — a block appended to `AGENTS.md` would be invisible to Zed.
70pub fn zed_guidance_target(project_dir: &Path) -> PathBuf {
71    let rules = project_dir.join(".rules");
72    if rules.exists() {
73        rules
74    } else {
75        project_dir.join("AGENTS.md")
76    }
77}
78
79// ── Sentinel-bracketed guidance block ─────────────────────────────────────
80
81/// Build the markdown block sqz appends to the Zed instruction file.
82pub fn zed_guidance_block(sqz_path: &str) -> String {
83    crate::codex_integration::agents_md_guidance_block(sqz_path)
84}
85
86// ── Guidance install / uninstall ──────────────────────────────────────────
87
88/// Install sqz's Zed guidance block into the project's effective Zed
89/// instruction file (`.rules` when present, else `AGENTS.md`).
90///
91/// Idempotent: returns `Ok(false)` when the target already contains the
92/// block. Appends to existing files rather than clobbering them.
93pub fn install_zed_guidance(project_dir: &Path, sqz_path: &str) -> Result<bool> {
94    let path = zed_guidance_target(project_dir);
95
96    if path.exists() {
97        return crate::codex_integration::write_guidance(&path, sqz_path);
98    }
99    std::fs::write(&path, zed_guidance_block(sqz_path)).map_err(|e| {
100        SqzError::Other(format!("failed to write {}: {e}", path.display()))
101    })?;
102    Ok(true)
103}
104
105pub(crate) fn zed_mcp_registered() -> bool {
106    let Ok(text) = std::fs::read_to_string(zed_settings_path()) else {
107        return false;
108    };
109    match serde_json::from_str::<serde_json::Value>(&text) {
110        Ok(root) => root.get("context_servers").and_then(|s| s.get("sqz")).is_some(),
111        Err(_) => text.contains("\"sqz\""),
112    }
113}
114
115/// Remove sqz's Zed guidance block from BOTH `.rules` and `AGENTS.md`
116/// (whichever contain it) — the target can have moved between installs
117/// if the user created `.rules` later. The shared block in `AGENTS.md`
118/// stays while Codex is registered.
119///
120/// Returns the list of files that were modified. A file left empty
121/// (or whitespace-only) after stripping is deleted, matching the
122/// AGENTS.md uninstall behaviour of the Codex integration.
123pub fn remove_zed_guidance(project_dir: &Path) -> Result<Vec<PathBuf>> {
124    remove_zed_guidance_at(project_dir, crate::codex_integration::codex_mcp_registered())
125}
126
127pub(crate) fn remove_zed_guidance_at(
128    project_dir: &Path,
129    codex_installed: bool,
130) -> Result<Vec<PathBuf>> {
131    use crate::codex_integration::{remove_guidance_from, BlockKind};
132    let from_agents_md: &[BlockKind] = if codex_installed {
133        &[BlockKind::Zed]
134    } else {
135        &[BlockKind::Zed, BlockKind::Shared]
136    };
137    let mut changed = Vec::new();
138    for (path, drop) in [
139        (project_dir.join(".rules"), &[BlockKind::Zed, BlockKind::Shared][..]),
140        (project_dir.join("AGENTS.md"), from_agents_md),
141    ] {
142        if let Some((path, true)) = remove_guidance_from(&path, drop)? {
143            changed.push(path);
144        }
145    }
146    Ok(changed)
147}
148
149// ── MCP registration in Zed's settings.json ──────────────────────────────
150
151/// Outcome of [`install_zed_mcp_config`], surfaced so the CLI can tell
152/// the user what actually happened — especially the JSONC case where we
153/// deliberately don't touch their file.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum ZedMcpInstall {
156    /// `settings.json` did not exist; created it with just our entry.
157    Created,
158    /// Existing strict-JSON settings; our entry was merged in.
159    Merged,
160    /// A `context_servers.sqz` entry already exists (ours or the
161    /// user's customisation). Left alone.
162    AlreadyPresent,
163    /// The file exists but is JSONC (comments / trailing commas). We
164    /// refuse to rewrite it — that would strip the user's comments
165    /// (issue #6 lesson). The CLI should print [`zed_mcp_snippet`]
166    /// for manual copy-paste.
167    SkippedJsonc,
168}
169
170/// The `context_servers` snippet for manual installation, printed when
171/// sqz can't safely edit a JSONC settings file.
172pub fn zed_mcp_snippet() -> String {
173    r#"  "context_servers": {
174    "sqz": {
175      "command": "sqz-mcp",
176      "args": ["--transport", "stdio"],
177      "env": {}
178    }
179  }"#
180    .to_string()
181}
182
183/// Register sqz-mcp under `context_servers.sqz` in Zed's settings.json.
184///
185/// * Missing file → created with just our entry.
186/// * Strict-JSON file → surgically merged, all user keys preserved.
187/// * Existing `sqz` entry → left alone (user customisation wins),
188///   [`ZedMcpInstall::AlreadyPresent`].
189/// * JSONC file → untouched, [`ZedMcpInstall::SkippedJsonc`].
190pub fn install_zed_mcp_config() -> Result<ZedMcpInstall> {
191    install_zed_mcp_config_at(&zed_settings_path())
192}
193
194/// Path-injectable counterpart for tests.
195pub(crate) fn install_zed_mcp_config_at(path: &Path) -> Result<ZedMcpInstall> {
196    let sqz_entry = serde_json::json!({
197        "command": "sqz-mcp",
198        "args": ["--transport", "stdio"],
199        "env": {}
200    });
201
202    if !path.exists() {
203        if let Some(parent) = path.parent() {
204            std::fs::create_dir_all(parent).map_err(|e| {
205                SqzError::Other(format!("create {}: {e}", parent.display()))
206            })?;
207        }
208        let root = serde_json::json!({ "context_servers": { "sqz": sqz_entry } });
209        std::fs::write(path, serde_json::to_string_pretty(&root).unwrap_or_default())
210            .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
211        return Ok(ZedMcpInstall::Created);
212    }
213
214    let text = std::fs::read_to_string(path)
215        .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
216    let mut root: serde_json::Value = if text.trim().is_empty() {
217        serde_json::json!({})
218    } else {
219        match serde_json::from_str(&text) {
220            Ok(v) => v,
221            // Zed settings are JSONC more often than not (Zed's own
222            // template has comments). Don't strip them — bail out and
223            // let the CLI print manual instructions.
224            Err(_) => return Ok(ZedMcpInstall::SkippedJsonc),
225        }
226    };
227
228    let root_obj = root.as_object_mut().ok_or_else(|| {
229        SqzError::Other(format!(
230            "{} root must be a JSON object",
231            path.display()
232        ))
233    })?;
234
235    let servers = root_obj
236        .entry("context_servers".to_string())
237        .or_insert_with(|| serde_json::json!({}));
238    if !servers.is_object() {
239        *servers = serde_json::json!({});
240    }
241    let servers_obj = servers.as_object_mut().expect("just ensured object");
242
243    if servers_obj.contains_key("sqz") {
244        return Ok(ZedMcpInstall::AlreadyPresent);
245    }
246    servers_obj.insert("sqz".to_string(), sqz_entry);
247
248    let mut out = serde_json::to_string_pretty(&root).unwrap_or_default();
249    if text.ends_with('\n') {
250        out.push('\n');
251    }
252    std::fs::write(path, out)
253        .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
254    Ok(ZedMcpInstall::Merged)
255}
256
257/// Outcome of [`remove_zed_mcp_config`].
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ZedMcpRemove {
260    /// Entry found and removed.
261    Removed,
262    /// File missing or no `sqz` entry — nothing to do.
263    NotPresent,
264    /// JSONC file — refused to rewrite; user must remove the entry by
265    /// hand.
266    SkippedJsonc,
267}
268
269/// Remove `context_servers.sqz` from Zed's settings.json. Never deletes
270/// the file itself — it's the user's editor config, and an empty
271/// `context_servers` key is simply dropped.
272pub fn remove_zed_mcp_config() -> Result<ZedMcpRemove> {
273    remove_zed_mcp_config_at(&zed_settings_path())
274}
275
276/// Path-injectable counterpart for tests.
277pub(crate) fn remove_zed_mcp_config_at(path: &Path) -> Result<ZedMcpRemove> {
278    if !path.exists() {
279        return Ok(ZedMcpRemove::NotPresent);
280    }
281    let text = std::fs::read_to_string(path)
282        .map_err(|e| SqzError::Other(format!("read {}: {e}", path.display())))?;
283    let mut root: serde_json::Value = match serde_json::from_str(&text) {
284        Ok(v) => v,
285        Err(_) => {
286            // Only report SkippedJsonc when there is actually an sqz
287            // entry to remove — otherwise this is a no-op either way.
288            if text.contains("\"sqz\"") {
289                return Ok(ZedMcpRemove::SkippedJsonc);
290            }
291            return Ok(ZedMcpRemove::NotPresent);
292        }
293    };
294
295    let Some(root_obj) = root.as_object_mut() else {
296        return Ok(ZedMcpRemove::NotPresent);
297    };
298    let Some(servers) = root_obj
299        .get_mut("context_servers")
300        .and_then(|s| s.as_object_mut())
301    else {
302        return Ok(ZedMcpRemove::NotPresent);
303    };
304    if servers.remove("sqz").is_none() {
305        return Ok(ZedMcpRemove::NotPresent);
306    }
307    let drop_key = servers.is_empty();
308    if drop_key {
309        root_obj.remove("context_servers");
310    }
311    let mut out = serde_json::to_string_pretty(&root).unwrap_or_default();
312    if text.ends_with('\n') {
313        out.push('\n');
314    }
315    std::fs::write(path, out)
316        .map_err(|e| SqzError::Other(format!("write {}: {e}", path.display())))?;
317    Ok(ZedMcpRemove::Removed)
318}
319
320// ── Tests ─────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    // ── Guidance target selection ────────────────────────────────────
327
328    #[test]
329    fn guidance_targets_agents_md_by_default() {
330        let dir = tempfile::tempdir().unwrap();
331        assert_eq!(zed_guidance_target(dir.path()), dir.path().join("AGENTS.md"));
332    }
333
334    #[test]
335    fn guidance_targets_rules_when_present() {
336        // Zed reads `.rules` FIRST — a block in AGENTS.md would be
337        // invisible when `.rules` exists.
338        let dir = tempfile::tempdir().unwrap();
339        std::fs::write(dir.path().join(".rules"), "my rules\n").unwrap();
340        assert_eq!(zed_guidance_target(dir.path()), dir.path().join(".rules"));
341    }
342
343    // ── Guidance install / remove ────────────────────────────────────
344
345    const BEGIN: &str = "<!-- BEGIN sqz-guidance (";
346
347    #[test]
348    fn install_creates_agents_md_when_missing() {
349        let dir = tempfile::tempdir().unwrap();
350        assert!(install_zed_guidance(dir.path(), "/usr/bin/sqz").unwrap());
351        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
352        assert!(content.contains(BEGIN));
353        assert!(content.contains("/usr/bin/sqz compress"));
354        assert_eq!(content, crate::codex_integration::agents_md_guidance_block("/usr/bin/sqz"));
355    }
356
357    #[test]
358    fn install_appends_to_existing_rules_preserving_content() {
359        let dir = tempfile::tempdir().unwrap();
360        std::fs::write(dir.path().join(".rules"), "Always use tabs.\n").unwrap();
361        assert!(install_zed_guidance(dir.path(), "sqz").unwrap());
362        let content = std::fs::read_to_string(dir.path().join(".rules")).unwrap();
363        assert!(content.starts_with("Always use tabs.\n"));
364        assert!(content.contains(BEGIN));
365        // AGENTS.md must NOT be created — .rules shadows it.
366        assert!(!dir.path().join("AGENTS.md").exists());
367    }
368
369    #[test]
370    fn install_is_idempotent() {
371        let dir = tempfile::tempdir().unwrap();
372        assert!(install_zed_guidance(dir.path(), "sqz").unwrap());
373        assert!(!install_zed_guidance(dir.path(), "sqz").unwrap());
374        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
375        assert_eq!(content.matches(BEGIN).count(), 1);
376    }
377
378    #[test]
379    fn codex_and_zed_share_one_block() {
380        let dir = tempfile::tempdir().unwrap();
381        assert!(crate::codex_integration::install_agents_md_guidance(dir.path(), "/usr/bin/sqz").unwrap());
382        assert!(!install_zed_guidance(dir.path(), "/usr/bin/sqz").unwrap());
383        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
384        assert_eq!(content.matches("<!-- BEGIN sqz-").count(), 1);
385    }
386
387    #[test]
388    fn remove_strips_block_and_keeps_user_content() {
389        let dir = tempfile::tempdir().unwrap();
390        std::fs::write(dir.path().join(".rules"), "Always use tabs.\n").unwrap();
391        install_zed_guidance(dir.path(), "sqz").unwrap();
392
393        let changed = remove_zed_guidance_at(dir.path(), false).unwrap();
394        assert_eq!(changed.len(), 1);
395        let content = std::fs::read_to_string(dir.path().join(".rules")).unwrap();
396        assert_eq!(content, "Always use tabs.\n");
397    }
398
399    #[test]
400    fn remove_deletes_file_that_was_only_our_block() {
401        let dir = tempfile::tempdir().unwrap();
402        install_zed_guidance(dir.path(), "sqz").unwrap();
403        assert!(dir.path().join("AGENTS.md").exists());
404
405        remove_zed_guidance_at(dir.path(), false).unwrap();
406        assert!(!dir.path().join("AGENTS.md").exists());
407    }
408
409    #[test]
410    fn remove_keeps_the_shared_block_while_codex_uses_it() {
411        let dir = tempfile::tempdir().unwrap();
412        let path = dir.path().join("AGENTS.md");
413        let legacy = format!(
414            "<!-- BEGIN sqz-zed-guidance (auto-installed by sqz init; remove this block to disable) -->\nold\n<!-- END sqz-zed-guidance -->\n\n{}",
415            zed_guidance_block("sqz")
416        );
417        std::fs::write(&path, &legacy).unwrap();
418
419        assert_eq!(remove_zed_guidance_at(dir.path(), true).unwrap(), vec![path.clone()]);
420        assert_eq!(std::fs::read_to_string(&path).unwrap(), zed_guidance_block("sqz"));
421
422        assert_eq!(remove_zed_guidance_at(dir.path(), true).unwrap(), Vec::<PathBuf>::new());
423        assert_eq!(remove_zed_guidance_at(dir.path(), false).unwrap(), vec![path.clone()]);
424        assert!(!path.exists());
425    }
426
427    #[test]
428    fn remove_drops_the_rules_block_even_while_codex_uses_agents_md() {
429        let dir = tempfile::tempdir().unwrap();
430        std::fs::write(dir.path().join(".rules"), "Always use tabs.\n").unwrap();
431        install_zed_guidance(dir.path(), "sqz").unwrap();
432        crate::codex_integration::install_agents_md_guidance(dir.path(), "sqz").unwrap();
433
434        let changed = remove_zed_guidance_at(dir.path(), true).unwrap();
435        assert_eq!(changed, vec![dir.path().join(".rules")]);
436        assert_eq!(std::fs::read_to_string(dir.path().join(".rules")).unwrap(), "Always use tabs.\n");
437        assert!(std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap().contains(BEGIN));
438    }
439
440    #[test]
441    fn remove_covers_stale_agents_md_after_rules_appears() {
442        // Install went to AGENTS.md; user later created .rules and
443        // re-init installed there too. Uninstall must strip both.
444        let dir = tempfile::tempdir().unwrap();
445        install_zed_guidance(dir.path(), "sqz").unwrap();
446        std::fs::write(dir.path().join(".rules"), "rules here\n").unwrap();
447        install_zed_guidance(dir.path(), "sqz").unwrap();
448
449        let changed = remove_zed_guidance_at(dir.path(), false).unwrap();
450        assert_eq!(changed.len(), 2, "both .rules and AGENTS.md should be cleaned");
451        assert_eq!(
452            std::fs::read_to_string(dir.path().join(".rules")).unwrap(),
453            "rules here\n"
454        );
455        assert!(!dir.path().join("AGENTS.md").exists());
456    }
457
458    // ── MCP config ───────────────────────────────────────────────────
459
460    #[test]
461    fn mcp_creates_fresh_settings() {
462        let dir = tempfile::tempdir().unwrap();
463        let path = dir.path().join("zed").join("settings.json");
464        assert_eq!(
465            install_zed_mcp_config_at(&path).unwrap(),
466            ZedMcpInstall::Created
467        );
468        let parsed: serde_json::Value =
469            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
470        assert_eq!(parsed["context_servers"]["sqz"]["command"], "sqz-mcp");
471        assert_eq!(parsed["context_servers"]["sqz"]["args"][1], "stdio");
472    }
473
474    #[test]
475    fn mcp_merges_into_strict_json_preserving_keys() {
476        let dir = tempfile::tempdir().unwrap();
477        let path = dir.path().join("settings.json");
478        std::fs::write(
479            &path,
480            r#"{"theme":"One Dark","context_servers":{"github":{"command":"gh-mcp"}}}"#,
481        )
482        .unwrap();
483        assert_eq!(
484            install_zed_mcp_config_at(&path).unwrap(),
485            ZedMcpInstall::Merged
486        );
487        let parsed: serde_json::Value =
488            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
489        assert_eq!(parsed["theme"], "One Dark", "user keys preserved");
490        assert_eq!(parsed["context_servers"]["github"]["command"], "gh-mcp");
491        assert_eq!(parsed["context_servers"]["sqz"]["command"], "sqz-mcp");
492    }
493
494    #[test]
495    fn mcp_leaves_existing_sqz_entry_alone() {
496        let dir = tempfile::tempdir().unwrap();
497        let path = dir.path().join("settings.json");
498        std::fs::write(
499            &path,
500            r#"{"context_servers":{"sqz":{"command":"/custom/sqz-mcp"}}}"#,
501        )
502        .unwrap();
503        assert_eq!(
504            install_zed_mcp_config_at(&path).unwrap(),
505            ZedMcpInstall::AlreadyPresent
506        );
507        let parsed: serde_json::Value =
508            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
509        assert_eq!(
510            parsed["context_servers"]["sqz"]["command"], "/custom/sqz-mcp",
511            "user customisation must win"
512        );
513    }
514
515    #[test]
516    fn mcp_refuses_to_touch_jsonc() {
517        // Zed's own settings template ships with comments. Rewriting it
518        // through serde_json would strip them — issue #6 taught us not
519        // to do that to people.
520        let dir = tempfile::tempdir().unwrap();
521        let path = dir.path().join("settings.json");
522        let jsonc = "{\n  // The user's carefully commented theme choice\n  \"theme\": \"One Dark\"\n}\n";
523        std::fs::write(&path, jsonc).unwrap();
524        assert_eq!(
525            install_zed_mcp_config_at(&path).unwrap(),
526            ZedMcpInstall::SkippedJsonc
527        );
528        assert_eq!(
529            std::fs::read_to_string(&path).unwrap(),
530            jsonc,
531            "JSONC file must be byte-identical after the skipped install"
532        );
533    }
534
535    #[test]
536    fn mcp_remove_is_surgical() {
537        let dir = tempfile::tempdir().unwrap();
538        let path = dir.path().join("settings.json");
539        std::fs::write(
540            &path,
541            r#"{"theme":"One Dark","context_servers":{"sqz":{"command":"sqz-mcp"},"github":{"command":"gh-mcp"}}}"#,
542        )
543        .unwrap();
544        assert_eq!(remove_zed_mcp_config_at(&path).unwrap(), ZedMcpRemove::Removed);
545        let parsed: serde_json::Value =
546            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
547        assert_eq!(parsed["theme"], "One Dark");
548        assert_eq!(parsed["context_servers"]["github"]["command"], "gh-mcp");
549        assert!(parsed["context_servers"].get("sqz").is_none());
550    }
551
552    #[test]
553    fn mcp_remove_drops_empty_context_servers_but_keeps_file() {
554        let dir = tempfile::tempdir().unwrap();
555        let path = dir.path().join("settings.json");
556        std::fs::write(
557            &path,
558            r#"{"theme":"One Dark","context_servers":{"sqz":{"command":"sqz-mcp"}}}"#,
559        )
560        .unwrap();
561        assert_eq!(remove_zed_mcp_config_at(&path).unwrap(), ZedMcpRemove::Removed);
562        assert!(path.exists(), "never delete the user's settings.json");
563        let parsed: serde_json::Value =
564            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
565        assert!(parsed.get("context_servers").is_none());
566        assert_eq!(parsed["theme"], "One Dark");
567    }
568
569    #[test]
570    fn mcp_remove_jsonc_with_sqz_entry_reports_skipped() {
571        let dir = tempfile::tempdir().unwrap();
572        let path = dir.path().join("settings.json");
573        let jsonc = "{\n  // comment\n  \"context_servers\": { \"sqz\": { \"command\": \"sqz-mcp\" } }\n}\n";
574        std::fs::write(&path, jsonc).unwrap();
575        assert_eq!(
576            remove_zed_mcp_config_at(&path).unwrap(),
577            ZedMcpRemove::SkippedJsonc
578        );
579        assert_eq!(std::fs::read_to_string(&path).unwrap(), jsonc);
580    }
581
582    #[test]
583    fn mcp_remove_missing_file_is_not_present() {
584        let dir = tempfile::tempdir().unwrap();
585        assert_eq!(
586            remove_zed_mcp_config_at(&dir.path().join("settings.json")).unwrap(),
587            ZedMcpRemove::NotPresent
588        );
589    }
590}