1use std::path::{Path, PathBuf};
30
31use crate::error::{Result, SqzError};
32
33pub 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
65pub 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
79pub fn zed_guidance_block(sqz_path: &str) -> String {
83 crate::codex_integration::agents_md_guidance_block(sqz_path)
84}
85
86pub 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
115pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum ZedMcpInstall {
156 Created,
158 Merged,
160 AlreadyPresent,
163 SkippedJsonc,
168}
169
170pub 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
183pub fn install_zed_mcp_config() -> Result<ZedMcpInstall> {
191 install_zed_mcp_config_at(&zed_settings_path())
192}
193
194pub(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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ZedMcpRemove {
260 Removed,
262 NotPresent,
264 SkippedJsonc,
267}
268
269pub fn remove_zed_mcp_config() -> Result<ZedMcpRemove> {
273 remove_zed_mcp_config_at(&zed_settings_path())
274}
275
276pub(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 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#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[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 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 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 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 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 #[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 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}