use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::EditorConfig;
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct ZedConfig {
#[serde(
default,
rename = "context_servers",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub(crate) context_servers: BTreeMap<String, ZedServer>,
#[serde(flatten)]
pub(crate) other: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ZedServer {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) env: Option<BTreeMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) headers: Option<BTreeMap<String, String>>,
}
impl EditorConfig for ZedConfig {
type Server = ZedServer;
fn has_server(&self, name: &str) -> bool {
self.context_servers.contains_key(name)
}
fn add_server(&mut self, name: String, server: Self::Server) {
self.context_servers.insert(name, server);
}
fn remove_server(&mut self, name: &str) {
self.context_servers.remove(name);
}
fn server_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(self.context_servers.keys().map(String::as_str))
}
fn preprocess(bytes: Vec<u8>) -> Vec<u8> {
strip_jsonc(&bytes)
}
}
fn strip_jsonc(bytes: &[u8]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i: usize = 0;
let mut in_string = false;
let mut escape = false;
while i < bytes.len() {
let b = bytes[i];
if in_string {
out.push(b);
if escape {
escape = false;
} else if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = false;
}
i += 1;
continue;
}
if b == b'"' {
in_string = true;
out.push(b);
i += 1;
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = i.saturating_add(2).min(bytes.len());
continue;
}
if b == b',' {
let mut j = i + 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && (bytes[j] == b']' || bytes[j] == b'}') {
i += 1;
continue;
}
}
out.push(b);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_serializes_to_empty_object() {
let cfg = ZedConfig::default();
let s = serde_json::to_string(&cfg).expect("serialize");
assert_eq!(s, "{}");
}
#[test]
fn local_server_field_order_is_command_args_env() {
let mut env = BTreeMap::new();
env.insert("DEBUG".into(), "1".into());
env.insert("PATH".into(), "/usr/bin".into());
let server = ZedServer {
command: "/bin/app".into(),
args: Some(vec!["mcp".into(), "start".into()]),
env: Some(env),
url: None,
headers: None,
};
let s = serde_json::to_string(&server).expect("serialize");
assert_eq!(
s,
r#"{"command":"/bin/app","args":["mcp","start"],"env":{"DEBUG":"1","PATH":"/usr/bin"}}"#
);
}
#[test]
fn remote_server_only_carries_url_and_headers() {
let mut headers = BTreeMap::new();
headers.insert("Authorization".into(), "Bearer x".into());
let server = ZedServer {
command: String::new(),
args: None,
env: None,
url: Some("https://example.com/mcp".into()),
headers: Some(headers),
};
let s = serde_json::to_string(&server).expect("serialize");
assert_eq!(
s,
r#"{"url":"https://example.com/mcp","headers":{"Authorization":"Bearer x"}}"#
);
}
#[test]
fn round_trip_preserves_unknown_top_level_keys() {
let raw = r#"{
"theme": "One Dark",
"font_family": "JetBrains Mono",
"context_servers": {
"existing": {"command": "/bin/x"}
}
}"#;
let cfg: ZedConfig = serde_json::from_str(raw).expect("parse");
assert!(cfg.has_server("existing"));
assert_eq!(
cfg.other.get("theme").and_then(|v| v.as_str()),
Some("One Dark")
);
assert_eq!(
cfg.other.get("font_family").and_then(|v| v.as_str()),
Some("JetBrains Mono")
);
let s = serde_json::to_string(&cfg).expect("serialize");
assert!(s.contains(r#""theme":"One Dark""#), "got {s}");
assert!(s.contains(r#""font_family":"JetBrains Mono""#), "got {s}");
assert!(s.contains(r#""context_servers""#), "got {s}");
}
#[test]
fn add_remove_server_does_not_touch_other_keys() {
let mut cfg = ZedConfig::default();
cfg.other.insert(
"theme".into(),
serde_json::Value::String("Solarized".into()),
);
cfg.add_server(
"foo".into(),
ZedServer {
command: "/bin/foo".into(),
args: None,
env: None,
url: None,
headers: None,
},
);
assert!(cfg.has_server("foo"));
assert_eq!(
cfg.other.get("theme").and_then(|v| v.as_str()),
Some("Solarized"),
"add_server must not touch `other`"
);
cfg.remove_server("foo");
assert!(!cfg.has_server("foo"));
assert_eq!(
cfg.other.get("theme").and_then(|v| v.as_str()),
Some("Solarized"),
"remove_server must not touch `other`"
);
}
#[test]
fn server_names_iterates_keys_in_sorted_order() {
let mut cfg = ZedConfig::default();
cfg.add_server(
"zebra".into(),
ZedServer {
command: "/z".into(),
..Default::default()
},
);
cfg.add_server(
"alpha".into(),
ZedServer {
command: "/a".into(),
..Default::default()
},
);
let names: Vec<&str> = cfg.server_names().collect();
assert_eq!(names, vec!["alpha", "zebra"]);
}
#[test]
fn strip_jsonc_removes_line_comments() {
let raw = "// header\n{\"a\":1} // trailing\n";
let out = strip_jsonc(raw.as_bytes());
let s = std::str::from_utf8(&out).expect("utf8");
assert!(!s.contains("header"), "got {s:?}");
assert!(!s.contains("trailing"), "got {s:?}");
let v: serde_json::Value = serde_json::from_slice(&out).expect("parse");
assert_eq!(v["a"], 1);
}
#[test]
fn strip_jsonc_removes_block_comments() {
let raw = "/* block */ {\"a\":1, /* inline */ \"b\":2}";
let out = strip_jsonc(raw.as_bytes());
let v: serde_json::Value = serde_json::from_slice(&out).expect("parse");
assert_eq!(v["a"], 1);
assert_eq!(v["b"], 2);
}
#[test]
fn strip_jsonc_preserves_comment_syntax_inside_strings() {
let raw = r#"{"url":"http://example.com/mcp"}"#;
let out = strip_jsonc(raw.as_bytes());
let v: serde_json::Value = serde_json::from_slice(&out).expect("parse");
assert_eq!(v["url"], "http://example.com/mcp");
}
#[test]
fn strip_jsonc_drops_trailing_comma_in_array_and_object() {
let raw = "{\"a\":[1,2,3,], \"b\":{\"x\":1,}}";
let out = strip_jsonc(raw.as_bytes());
let v: serde_json::Value = serde_json::from_slice(&out).expect("parse");
assert_eq!(v["a"], serde_json::json!([1, 2, 3]));
assert_eq!(v["b"]["x"], 1);
}
#[test]
fn strip_jsonc_keeps_non_trailing_commas() {
let raw = r"[1, 2, 3]";
let out = strip_jsonc(raw.as_bytes());
assert_eq!(out, raw.as_bytes());
}
#[test]
fn strip_jsonc_preserves_escaped_quote_inside_strings() {
let raw = r#"{"a":"with \"quote\" and // not-comment"}"#;
let out = strip_jsonc(raw.as_bytes());
let v: serde_json::Value = serde_json::from_slice(&out).expect("parse");
assert_eq!(v["a"], "with \"quote\" and // not-comment");
}
#[test]
fn strip_jsonc_idempotent_on_strict_json() {
let raw = r#"{"a":1,"b":[2,3],"c":"x"}"#;
let out = strip_jsonc(raw.as_bytes());
assert_eq!(out, raw.as_bytes());
}
#[test]
fn preprocess_enables_jsonc_parsing_through_editor_config_trait() {
let raw = br#"// User comment
{
"theme": "One Dark", // theme choice
"context_servers": {
"existing": {"command": "/bin/x",},
},
}"#;
let preprocessed = ZedConfig::preprocess(raw.to_vec());
let cfg: ZedConfig = serde_json::from_slice(&preprocessed).expect("parse");
assert!(cfg.has_server("existing"));
assert_eq!(
cfg.other.get("theme").and_then(|v| v.as_str()),
Some("One Dark")
);
}
}